diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cb262164f..41f39a82b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -51,7 +51,7 @@ docker/ # Docker configurations ## Build Instructions ### Prerequisites -- **.NET 9.0 SDK** (verified working version: 9.0.305) +- **.NET 10.0 SDK** - **Build time**: Initial restore ~1-2 minutes, full compile ~5-10 minutes ### Critical Build Information @@ -111,11 +111,10 @@ find test/unit -name "*.csproj" | head -5 | xargs -I {} dotnet build {} ### Expected Build Warnings - `NU1900`: Unable to load service index for external feeds (safe to ignore) - `NU1801`: Service index warnings for feedz.io sources (safe to ignore) -- `NU1101`: Missing Elsa.Studio packages (blocks studio app builds) ### Successful Build Indicators - Core modules (Elsa.Workflows.Core, etc.) compile successfully -- Server applications (Elsa.Server.Web) build without the studio UI +- Server applications (Elsa.Server.Web) build - Most modules show "succeeded with X warning(s)" (warnings are acceptable) ## Testing @@ -142,7 +141,7 @@ dotnet test --no-build --no-restore [project-path] ### Code Standards - **Language version**: C# latest -- **Target framework**: .NET 9.0 +- **Target framework**: .NET 10.0 - **Nullable reference types**: Enabled - **Implicit usings**: Enabled - **EditorConfig**: Configured (4-space indentation, CRLF line endings) @@ -154,23 +153,22 @@ dotnet test --no-build --no-restore [project-path] - **Async/await**: Extensive use throughout for scalability ### Common Gotchas -1. **External Dependencies**: Studio-related projects require external packages -2. **NuGet Source Mapping**: Configured in NuGet.Config, restricts where packages can be sourced -3. **Multiple Target Frameworks**: Some projects conditionally target different frameworks -4. **Build Warnings**: Many NU1900/NU1801 warnings are expected and safe +1. **NuGet Source Mapping**: Configured in NuGet.Config, restricts where packages can be sourced +2**Multiple Target Frameworks**: Some projects conditionally target different frameworks +3**Build Warnings**: Many NU1900/NU1801 warnings are expected and safe ## Continuous Integration ### GitHub Actions Workflow - **Trigger**: Pull requests to `main` branch - **Runner**: ubuntu-latest -- **.NET Version**: 9.x (latest) +- **.NET Version**: 10.x (latest) - **Commands**: `./build.cmd Compile Test Pack` - **File**: `.github/workflows/pr.yml` (auto-generated by NUKE) ### CI Pipeline Steps 1. Checkout code -2. Setup .NET 9.x SDK +2. Setup .NET 10.x SDK 3. Execute: Compile → Test → Pack 4. Expected warnings for external feed access 5. Studio apps may be excluded from CI builds @@ -216,15 +214,68 @@ Multiple Docker configurations available: dotnet build --no-restore ``` +## Running the Applications + +### Development Workflow Server + +To run the workflow server for development: + +```bash +cd src/apps/Elsa.Server.Web +dotnet restore --ignore-failed-sources +dotnet run +``` + +The server will start on the configured ports (check `appsettings.json` or environment variables). + +## Troubleshooting + +### Common Issues + +1. **Missing External Packages**: If you encounter `NU1101` errors for Elsa.Studio packages: + - This is expected for studio-related apps + - Focus development on core workflow modules instead + - Or use Docker images that have pre-built studio components + +2. **Build Fails on Server Apps**: If `Elsa.Server.Web` fails due to WebhooksCore: + - This is a known issue with external package feeds + - Try building individual core modules instead + - Use `--ignore-failed-sources` flag consistently + +3. **Test Failures**: If many tests fail to run: + - External package dependencies may be unavailable + - Run tests for specific core modules individually + - Focus on tests that don't require studio packages + +4. **Slow Initial Build**: First restore and compile can take 5-10+ minutes: + - This is normal for a large solution with 100+ projects + - Subsequent builds are much faster (incremental) + - Consider building specific projects/modules when iterating + +## Additional Resources + +### Documentation +- **Official Documentation**: [https://docs.elsaworkflows.io/](https://docs.elsaworkflows.io/) +- **README**: See [README.md](../README.md) for quick start and features overview +- **Contributing Guide**: See [CONTRIBUTING.md](../CONTRIBUTING.md) for contribution guidelines + +### Community Support +- **GitHub Issues**: [Report bugs and request features](https://github.com/elsa-workflows/elsa-core/issues) +- **GitHub Discussions**: [Ask questions and discuss](https://github.com/elsa-workflows/elsa-core/discussions) +- **Discord**: [Join the community chat](https://discord.gg/hhChk5H472) +- **Stack Overflow**: [Tag: elsa-workflows](http://stackoverflow.com/questions/tagged/elsa-workflows) + +### Enterprise Support +- **ELSA-X**: [Professional support and enterprise solutions](https://elsa-x.io) + ## Important Notes for Coding Agents 1. **Always use `--ignore-failed-sources`** when restoring packages -2. **Focus on core workflow functionality** rather than studio UI components -3. **Studio apps require external packages** that may not be accessible -4. **Build warnings are normal** - don't try to fix NU1900/NU1801 warnings -5. **Test individual modules** rather than solution-wide tests when external deps fail -6. **Use direct dotnet commands** for building specific components when NUKE fails -7. **Check project references** before attempting builds - some projects have conditional references -8. **Start with core modules** like `Elsa.Workflows.Core`, `Elsa.Workflows.Runtime` which are more likely to build successfully +2. **Focus on core workflow functionality** +3. **Build warnings are normal** - don't try to fix NU1900/NU1801 warnings +4. **Test individual modules** rather than solution-wide tests when external deps fail +5. **Use direct dotnet commands** for building specific components when NUKE fails +6. **Check project references** before attempting builds - some projects have conditional references +7. **Start with core modules** like `Elsa.Workflows.Core`, `Elsa.Workflows.Runtime` which are more likely to build successfully Trust these instructions for build and development workflows. Only search for additional information if these instructions are incomplete or found to be incorrect. \ No newline at end of file diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index e5e0f9205..8d96b036c 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -13,10 +13,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up .NET 9 SDK + - name: Set up .NET 10 SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Build, test, and pack run: ./build.cmd Compile Test Pack diff --git a/.github/workflows/docker-ca.yml b/.github/workflows/docker-ca.yml index e8a737d48..0a35f9c30 100644 --- a/.github/workflows/docker-ca.yml +++ b/.github/workflows/docker-ca.yml @@ -48,7 +48,7 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Publish TLS smoke test app run: dotnet publish test/TlsSmoke/TlsSmoke.csproj -c Release -o artifacts/tls-smoke diff --git a/.github/workflows/elsa-server-and-studio.yml b/.github/workflows/elsa-server-and-studio.yml deleted file mode 100644 index d3fef0f61..000000000 --- a/.github/workflows/elsa-server-and-studio.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Elsa Server + Studio Docker image -on: - workflow_dispatch: - push: - branches: - - develop/3.6.0 - -jobs: - push_to_registry: - name: Push Docker image to Docker Hub - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - with: - platforms: all - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v2 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v3 - with: - # list of Docker images to use as base name for tags - images: | - elsaworkflows/elsa-server-and-studio-v3-7-0-preview - flavor: | - latest=true - # generate Docker tags based on the following events/attributes - tags: | - type=sha - - - name: Login to DockerHub - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_PASS }} - - - name: Build and push - id: docker_build - uses: docker/build-push-action@v3 - with: - builder: ${{ steps.buildx.outputs.name }} - context: . - file: ./docker/ElsaServerAndStudio.Dockerfile - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/elsa-studio.yml b/.github/workflows/elsa-studio.yml deleted file mode 100644 index e102ad122..000000000 --- a/.github/workflows/elsa-studio.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Elsa Studio Docker image -on: - workflow_dispatch: - push: - branches: - - develop/3.6.0 - -jobs: - push_to_registry: - name: Push Docker image to Docker Hub - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - with: - platforms: all - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v2 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v3 - with: - # list of Docker images to use as base name for tags - images: | - elsaworkflows/elsa-studio-v3-7-0-preview - flavor: | - latest=true - # generate Docker tags based on the following events/attributes - tags: | - type=sha - - - name: Login to DockerHub - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_PASS }} - - - name: Build and push - id: docker_build - uses: docker/build-push-action@v3 - with: - builder: ${{ steps.buildx.outputs.name }} - context: . - file: ./docker/ElsaStudio.Dockerfile - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index fa93c6e21..673cb5778 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -13,17 +13,6 @@ on: - 'rc/*' - 'develop/*' - 'codex/*' - pull_request: - branches: - - 'main' - - 'bug/*' - - 'perf/*' - - 'patch/*' - - 'feat/*' - - 'enh/*' - - 'rc/*' - - 'develop/*' - - 'codex/*' release: types: [prereleased, published] @@ -47,6 +36,7 @@ jobs: dotnet-version: | 8.0.x 9.0.x + 10.0.x - name: Cache NuGet packages uses: actions/cache@v4 @@ -80,6 +70,17 @@ jobs: dotnet test "$project" --configuration Release --no-build --logger "GitHubActions;report-warnings=false" /p:CollectCoverage=true done + - name: Install ReportGenerator + run: dotnet tool install -g dotnet-reportgenerator-globaltool + + - name: Generate HTML coverage report + run: | + reportgenerator \ + "-reports:./artifacts/coverage/**/coverage.cobertura.xml" \ + "-targetdir:./artifacts/coverage-report" \ + "-reporttypes:Html;Cobertura;TextSummary" \ + "-verbosity:Info" + - name: Upload coverage reports if: always() uses: actions/upload-artifact@v4 @@ -87,6 +88,12 @@ jobs: name: coverage-reports path: artifacts/coverage + - name: Upload Pages artifact + if: always() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop/3.6.0') + uses: actions/upload-pages-artifact@v3 + with: + path: './artifacts/coverage-report' + build: name: Build packages needs: test @@ -178,3 +185,19 @@ jobs: - name: Publish to nuget.org run: dotnet nuget push *.nupkg -k ${{ secrets.NUGET_API_KEY }} -s ${{ env.nuget_feed_source }} --skip-duplicate + + deploy_coverage: + name: Deploy coverage to GitHub Pages + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop/3.6.0' + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0b038eb07..c3c434b40 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -14,12 +14,14 @@ # # ------------------------------------------------------------------------------ -name: PR +name: pr on: pull_request: branches: - main + - 'patch/*' + - 'develop/*' paths: - '**/*' @@ -35,7 +37,7 @@ jobs: - uses: actions/setup-dotnet@v4 with: dotnet-version: | - 9.x + 10.x - uses: actions/checkout@v4 - - name: 'Run: Compile, Test, Pack' - run: ./build.cmd Compile Test Pack + - name: 'Run: Compile, Test' + run: ./build.cmd Compile Test diff --git a/.gitignore b/.gitignore index 68a650b2a..34040f1bf 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,7 @@ unlist.sh /docker/data/ /docker/azurite-data/ docker/docker-compose-datadog.yml + +# Test results +TestResults/ +artifacts/ diff --git a/Directory.Build.props b/Directory.Build.props index 171bc74c7..969fcc3f5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -33,7 +33,9 @@ $(NoWarn);CS0162;CS1591 + + $(NoWarn);CS0618 - $(NoWarn);IL2026;IL2046;IL2057;IL2067;IL2070;IL2072;IL2075;IL2087;IL2091 + $(NoWarn);IL2026;IL2046;IL2057;IL2067;IL2070;IL2072;IL2075;IL2087;IL2091;IL2111 \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 94364485b..baf28d767 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,10 +3,26 @@ true false - - 9.0.10 - 9.10.0 - + + + + + + + + + + + + + + + + + + + + @@ -15,168 +31,145 @@ - - - - - - + + + + + + - - + + - - - + - - + + - - - - - - - - - + + + + + - - - - - + + + + + - - - + + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - + + - - - + + + - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - + - - + + - + - - + + - - + + - - - - - - + + + + + + - + \ No newline at end of file diff --git a/Elsa.sln b/Elsa.sln index 3ef9f27c8..f8c579aff 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -94,17 +94,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Workflows.Core.UnitTes EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Tenants", "src\modules\Elsa.Tenants\Elsa.Tenants.csproj", "{29638A67-E79F-44FE-AC05-DA499EBA929E}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{B789B743-0FBE-4BBC-9F45-84C20C8D4814}" - ProjectSection(SolutionItems) = preProject - .github\workflows\bounty.yml = .github\workflows\bounty.yml - .github\workflows\elsa-server-and-studio.yml = .github\workflows\elsa-server-and-studio.yml - .github\workflows\elsa-server.yml = .github\workflows\elsa-server.yml - .github\workflows\elsa-studio.yml = .github\workflows\elsa-studio.yml - .github\workflows\packages.yml = .github\workflows\packages.yml - .github\workflows\pr.yml = .github\workflows\pr.yml - .github\workflows\stale.yml = .github\workflows\stale.yml - EndProjectSection -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "build\_build.csproj", "{99F2B1DA-2F69-4D70-A2A3-AC985AD91EC4}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{C80C8231-D35C-4ACC-9ED6-9F3DB221535E}" @@ -311,6 +300,43 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.UnitTests" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Scheduling.UnitTests", "test\unit\Elsa.Scheduling.UnitTests\Elsa.Scheduling.UnitTests.csproj", "{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Common.IntegrationTests", "test\integration\Elsa.Common.IntegrationTests\Elsa.Common.IntegrationTests.csproj", "{6C451CC5-280E-475E-B95E-23ADCF0CCBFD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dsl.ElsaScript", "src\modules\Elsa.Dsl.ElsaScript\Elsa.Dsl.ElsaScript.csproj", "{874F5A44-DB06-47AB-A18C-2D13942E0147}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dsl.ElsaScript.IntegrationTests", "test\integration\Elsa.Dsl.ElsaScript.IntegrationTests\Elsa.Dsl.ElsaScript.IntegrationTests.csproj", "{4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.WorkflowProviders.BlobStorage.ElsaScript", "src\modules\Elsa.WorkflowProviders.BlobStorage.ElsaScript\Elsa.WorkflowProviders.BlobStorage.ElsaScript.csproj", "{2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github", "github", "{06DC6553-AAEF-4FC8-B16F-039F292E86A3}" + ProjectSection(SolutionItems) = preProject + .github\copilot-instructions.md = .github\copilot-instructions.md + .github\dependabot.yml = .github\dependabot.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{2158FC82-6253-47F2-9216-B266710D50F1}" + ProjectSection(SolutionItems) = preProject + .github\workflows\stale.yml = .github\workflows\stale.yml + .github\workflows\pr.yml = .github\workflows\pr.yml + .github\workflows\packages.yml = .github\workflows\packages.yml + .github\workflows\elsa-server.yml = .github\workflows\elsa-server.yml + .github\workflows\docker-ca.yml = .github\workflows\docker-ca.yml + .github\workflows\bounty.yml = .github\workflows\bounty.yml + .github\workflows\copilot-setup-steps.yml = .github\workflows\copilot-setup-steps.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "issue_templates", "issue_templates", "{36556068-64BF-4D4B-827E-EC8CDC23D516}" + ProjectSection(SolutionItems) = preProject + .github\ISSUE_TEMPLATE\bug_report.md = .github\ISSUE_TEMPLATE\bug_report.md + .github\ISSUE_TEMPLATE\feature_request.md = .github\ISSUE_TEMPLATE\feature_request.md + .github\ISSUE_TEMPLATE\improvement.md = .github\ISSUE_TEMPLATE\improvement.md + .github\ISSUE_TEMPLATE\performance.md = .github\ISSUE_TEMPLATE\performance.md + .github\ISSUE_TEMPLATE\task.md = .github\ISSUE_TEMPLATE\task.md + .github\ISSUE_TEMPLATE\documentation.md = .github\ISSUE_TEMPLATE\documentation.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dsl", "dsl", "{477C2416-312D-46AE-BCD6-8FA1FAB43624}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -551,6 +577,22 @@ Global {9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}.Debug|Any CPU.Build.0 = Debug|Any CPU {9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}.Release|Any CPU.ActiveCfg = Release|Any CPU {9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}.Release|Any CPU.Build.0 = Release|Any CPU + {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Release|Any CPU.Build.0 = Release|Any CPU + {874F5A44-DB06-47AB-A18C-2D13942E0147}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {874F5A44-DB06-47AB-A18C-2D13942E0147}.Debug|Any CPU.Build.0 = Debug|Any CPU + {874F5A44-DB06-47AB-A18C-2D13942E0147}.Release|Any CPU.ActiveCfg = Release|Any CPU + {874F5A44-DB06-47AB-A18C-2D13942E0147}.Release|Any CPU.Build.0 = Release|Any CPU + {4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5}.Release|Any CPU.Build.0 = Release|Any CPU + {2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -646,6 +688,13 @@ Global {20C75446-CAE0-48D1-85D9-459541C4D8B6} = {18453B51-25EB-4317-A4B3-B10518252E92} {67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B} = {18453B51-25EB-4317-A4B3-B10518252E92} {9AEE941A-3F23-4E4B-9B59-1A2F57FC762D} = {18453B51-25EB-4317-A4B3-B10518252E92} + {6C451CC5-280E-475E-B95E-23ADCF0CCBFD} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {2158FC82-6253-47F2-9216-B266710D50F1} = {06DC6553-AAEF-4FC8-B16F-039F292E86A3} + {36556068-64BF-4D4B-827E-EC8CDC23D516} = {06DC6553-AAEF-4FC8-B16F-039F292E86A3} + {4B5A9D32-58F2-47A9-8A1B-8A9E2D76C3F5} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {2B7FB49D-E4B6-4AD5-981B-3D85B94F6F48} = {B08B4E00-C2AB-48F3-8389-449F42AEF179} + {477C2416-312D-46AE-BCD6-8FA1FAB43624} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {874F5A44-DB06-47AB-A18C-2D13942E0147} = {477C2416-312D-46AE-BCD6-8FA1FAB43624} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/agent-logs/7019/2025-11-20_configuration-binding-issue_enumerable-type-converter.md b/agent-logs/7019/2025-11-20_configuration-binding-issue_enumerable-type-converter.md new file mode 100644 index 000000000..550dcbee4 --- /dev/null +++ b/agent-logs/7019/2025-11-20_configuration-binding-issue_enumerable-type-converter.md @@ -0,0 +1,153 @@ +# Configuration Binding Issue: SigningKey Not Bound from appsettings.json + +**Date**: 2025-11-20 +**Issue**: IdentityTokenOptions.SigningKey property remaining null despite being present in appsettings.json +**Related GitHub Issue**: #7019 (Array serialization) + +--- + +## Symptoms + +- `IdentityTokenOptions.SigningKey` property was not being bound from configuration +- Other properties in the same section (`AccessTokenLifetime`, `RefreshTokenLifetime`) were binding correctly +- Manual debugging showed: + - ✅ `identityTokenSection["SigningKey"]` returned the correct value + - ✅ `configuration.GetSection("Identity:Tokens:SigningKey").Value` returned the correct value + - ❌ `identityTokenSection.GetValue("SigningKey")` returned `null` + - ❌ `identityTokenSection.Bind(options)` did not populate `SigningKey` +- Creating a fresh instance worked: `new IdentityTokenOptions()` could be bound successfully + +## Initial False Leads + +During troubleshooting, several potential causes were investigated but ruled out: + +1. **Property initializers** - Initially suspected that default values like `= "http://elsa.api"` prevented binding, but this was disproven when fresh instances with the same defaults worked +2. **Configuration section retrieval** - The section was correctly retrieved and contained the value +3. **Nullable vs non-nullable** - Making `SigningKey` nullable didn't resolve the issue +4. **JSON encoding issues** - No BOM or hidden characters found in appsettings.json +5. **Configuration overrides** - No environment variables or other sources overriding the value +6. **Lambda capture issues** - The configuration section was correctly captured and accessible + +## Root Cause + +The actual root cause was **global TypeConverter registration interfering with the configuration binder**. + +In `DefaultFormattersFeature.cs`, the following registration was made: + +```csharp +TypeDescriptor.AddAttributes(typeof(IEnumerable), + new TypeConverterAttribute(typeof(EnumerableTypeConverter))); +``` + +This caused a cascade of issues: + +1. **String binding failure**: Since `string` implements `IEnumerable`, the configuration binder attempted to use `EnumerableTypeConverter` for string properties +2. **Silent failure**: Even though `EnumerableTypeConverter.CanConvertTo()` correctly returned `false` for strings, the global registration interfered with the fallback mechanism +3. **Partial success**: TimeSpan properties worked because they have built-in conversion support that bypassed the string converter issue + +## Solution + +### Phase 1: Remove Global TypeConverter Registration + +**File**: `src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs` + +**Change**: Removed the global `TypeDescriptor.AddAttributes` call for `IEnumerable` + +**Result**: +- ✅ Configuration binding now works for all string properties +- ❌ Arrays started serializing as `"Int32[] Array"` instead of JSON (exposed GitHub issue #7019) + +### Phase 2: Fix Array Serialization + +**File**: `src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs` + +**Change**: Added explicit JSON serialization for arrays and collections: + +```csharp +// Byte arrays are base64-encoded +if (value is byte[] byteArray) + return Convert.ToBase64String(byteArray); + +// Memory-like types preserved via TypeDescriptor +if (IsMemoryLike(sourceType)) +{ + var sourceTypeConverter = TypeDescriptor.GetConverter(underlyingSourceType); + if (sourceTypeConverter.CanConvertTo(typeof(string))) + return (string?)sourceTypeConverter.ConvertTo(value, typeof(string)); + return value.ToString(); +} + +// Serialize arrays and collections to JSON +if (value is IEnumerable) +{ + return JsonSerializer.Serialize(value); +} +``` + +**Result**: +- ✅ Arrays serialize to JSON: `[1,2,3]` instead of `"Int32[] Array"` +- ✅ Configuration binding still works +- ✅ No TypeConverter interference with .NET type system + +### Phase 3: Consolidate Logic (Cleanup) + +**File Deleted**: `src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs` + +**Rationale**: The `EnumerableTypeConverter` was only used by `ObjectFormatter.Format()`, so consolidating the logic directly into `ObjectFormatter` simplified the codebase and eliminated the need for TypeConverter registration entirely. + +## Key Learnings + +1. **Global TypeConverter registration is dangerous**: It can interfere with .NET's type system, configuration binding, and other framework features +2. **String implements IEnumerable**: This often-forgotten fact can cause unexpected behavior with type converters +3. **Configuration binder behavior is opaque**: When `.GetValue()` fails but indexer `["key"]` works, it indicates type conversion issues +4. **Explicit is better than implicit**: Direct serialization logic in `ObjectFormatter` is clearer and safer than relying on TypeConverter infrastructure +5. **Test fresh instances vs captured instances**: Configuration binding through the Options pattern behaves differently than direct `.Bind()` calls + +## Verification + +### Tests Added/Consolidated +**File**: `test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs` + +All tests consolidated into a single test class with 9 tests: +1. String is preserved as-is +2. Byte array is serialized as base64 string +3. Integer array is serialized as JSON array +4. String array is serialized as JSON array +5. String array with multiple elements is serialized as JSON array +6. Custom class array is serialized as JSON array +7. List of integers is serialized as JSON array +8. List with different values is serialized as JSON array +9. Null returns null + +### Runtime Verification +- ✅ Server starts successfully +- ✅ `IdentityTokenOptions.SigningKey` binds correctly from configuration +- ✅ No `MissingConfigurationException` thrown +- ✅ JWT authentication works +- ✅ Workflow variable arrays serialize to JSON +- ✅ All 9 integration tests pass + +## Files Modified + +1. `src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs` - Removed global TypeConverter registration +2. `src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs` - Added explicit JSON serialization for collections +3. `src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs` - Cleaned up property initializers +4. `test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs` - Consolidated all tests (9 total) + +## Files Deleted + +1. `src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs` - Consolidated into ObjectFormatter +2. `test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs` - Consolidated into ObjectFormatterTests + +--- + +## Future Reference + +**Filename Pattern**: `YYYY-MM-DD_issue-description_root-cause.md` + +When encountering similar configuration binding issues: +1. Check if the value exists using the indexer: `section["key"]` +2. Compare with `GetValue("key")` - if they differ, suspect type conversion issues +3. Look for global TypeConverter registrations that might interfere +4. Test with a fresh instance to isolate Options pattern behavior +5. Check if the property type implements unexpected interfaces (like `string` implementing `IEnumerable`) diff --git a/agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md b/agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md new file mode 100644 index 000000000..4abe30641 --- /dev/null +++ b/agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md @@ -0,0 +1,294 @@ +# Exception Handling Improvements for Trigger Deletion and Scheduled Workflows + +**Date**: November 20, 2025 +**Issue**: #7077 +**Branch**: bug/7077 +**Type**: Bug Fix + Integration Tests + +## Problem + +When deleting triggers, the `TriggerIndexer.DeleteTriggersAsync` method would fail completely if any workflow failed to load due to a missing materializer or other exceptions. This resulted in the error: + +``` +System.Exception: Provider not found + at Elsa.Workflows.Management.Services.WorkflowDefinitionService.MaterializeWorkflowAsync +``` + +The method would stop processing remaining workflows, leaving triggers in an inconsistent state. + +## Solution + +### 1. Exception Handling (`src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs`) + +Added a try-catch block in the `DeleteTriggersAsync` method (lines 67-79): + +```csharp +foreach (var workflowDefinitionVersionId in workflowDefinitionVersionIds) +{ + try + { + var workflowGraph = await _workflowDefinitionService.FindWorkflowGraphAsync( + workflowDefinitionVersionId, cancellationToken); + + if (workflowGraph == null) + continue; + + await DeleteTriggersAsync(workflowGraph.Workflow, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to load workflow graph for workflow definition version {WorkflowDefinitionVersionId}. " + + "Skipping trigger deletion for this workflow.", + workflowDefinitionVersionId); + } +} +``` + +**Benefits**: +- Logs warning with full exception details for debugging +- Continues processing remaining workflows instead of failing completely +- Maintains system stability when individual workflows have issues + +### 2. Integration Tests (`test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/`) + +Created comprehensive integration tests in a new `TriggerIndexing` scenario folder with `Tests.cs`: + +#### Test 1: All Workflows Fail to Load +```csharp +[Fact(DisplayName = "DeleteTriggersAsync should continue deleting triggers even when workflow fails to load")] +public async Task DeleteTriggersAsync_WorkflowFailsToLoad_ContinuesWithOtherTriggers() +``` + +- Verifies the method completes without throwing when all workflows fail +- Tests multiple failure types (JSON serialization errors, provider not found) +- Confirms all triggers remain when workflows can't be loaded + +#### Test 2: Mixed Success and Failure +```csharp +[Fact(DisplayName = "DeleteTriggersAsync processes all workflows even when one fails")] +public async Task DeleteTriggersAsync_OneWorkflowFails_ProcessesAllWorkflows() +``` + +- Tests scenario where one workflow fails (workflow2 with "Provider not found") +- Two other workflows load successfully (workflow1 and workflow3) +- Verifies triggers for successful workflows are deleted +- Confirms failed workflow's trigger remains +- **This is the key test** - proves exception handling allows processing to continue + +#### Test Infrastructure + +**Custom Materializers**: +- `FailingMaterializer`: Throws "Provider not found" exception to simulate the reported issue +- `WorkingMaterializer`: Successfully materializes workflows from a registry for valid test cases + +**Helper Methods** (DRY principle): +- `CreateWorkflowDefinition()`: Factory method for workflow definitions +- `CreateTrigger()`: Factory method for triggers +- `SaveWorkflowsAndTriggersAsync()`: Common save and verification logic + +**Modern C# Features**: +- `file` scoped classes for helper materializers (limits visibility) +- Primary constructors for `WorkingMaterializer` +- Expression-bodied members for simple methods + +## Code Quality Improvements + +### Test Refactoring +- Renamed from `TriggerIndexerTests.cs` to `Tests.cs` (project convention) +- Reduced test methods from ~90 lines to ~30 lines each +- Eliminated duplicate workflow/trigger creation code +- Improved readability with array-based test data +- Clear Arrange-Act-Assert structure + +### Benefits +- **More maintainable**: Changes only need to be made in one place +- **More readable**: Test intent is clearer with less boilerplate +- **More consistent**: Follows project naming conventions +- **Equally functional**: All tests pass with same behavior + +## Test Results + +Both tests pass successfully: + +``` +Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 304 ms + +✓ DeleteTriggersAsync should continue deleting triggers even when workflow fails to load [153 ms] +✓ DeleteTriggersAsync processes all workflows even when one fails [49 ms] +``` + +## 3. Resume Workflow Task Exception Handling (`src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs`) + +### Problem + +When a scheduled workflow instance is deleted before its scheduled resume time, the `ResumeWorkflowTask` would throw an unhandled exception: + +``` +Elsa.Workflows.Runtime.Exceptions.WorkflowInstanceNotFoundException: Workflow instance not found. + at Elsa.Workflows.Runtime.LocalWorkflowClient.GetWorkflowInstanceAsync +``` + +This would cause the scheduled task to fail, even though the workflow instance no longer exists and should simply be skipped. + +### Solution + +Added exception handling to gracefully handle missing workflow instances: + +```csharp +public async ValueTask ExecuteAsync(TaskExecutionContext context) +{ + var cancellationToken = context.CancellationToken; + var workflowRuntime = context.ServiceProvider.GetRequiredService(); + var logger = context.ServiceProvider.GetRequiredService>(); + + try + { + var workflowClient = await workflowRuntime.CreateClientAsync(_request.WorkflowInstanceId, cancellationToken); + var request = new RunWorkflowInstanceRequest + { + Input = _request.Input, + Properties = _request.Properties, + ActivityHandle = _request.ActivityHandle, + BookmarkId = _request.BookmarkId + }; + await workflowClient.RunInstanceAsync(request, cancellationToken); + } + catch (WorkflowInstanceNotFoundException ex) + { + logger.LogWarning( + "Scheduled workflow instance {WorkflowInstanceId} no longer exists and was likely deleted. Skipping execution.", + ex.InstanceId); + } +} +``` + +**Benefits**: +- Scheduled tasks no longer fail when workflow instances are deleted +- Clear logging explains why the task was skipped +- System continues operating normally +- No unnecessary error noise in logs + +## 4. LocalScheduler Thread Safety (`src/modules/Elsa.Scheduling/Services/LocalScheduler.cs`) + +### Problem + +The `LocalScheduler` was experiencing race conditions during concurrent access, particularly during application startup when multiple workflows are being scheduled simultaneously: + +``` +System.IndexOutOfRangeException: Index was outside the bounds of the array. + at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior) + at System.Collections.Generic.Dictionary`2.set_Item(TKey key, TValue value) + at Elsa.Scheduling.Services.LocalScheduler.RegisterScheduledTask +``` + +This exception occurs when multiple threads modify the internal `Dictionary` instances concurrently without synchronization. + +### Root Cause + +Two dictionaries were being accessed concurrently without any thread safety: +- `_scheduledTasks`: Maps task names to scheduled tasks +- `_scheduledTaskKeys`: Maps scheduled tasks to their keys + +Methods like `ScheduleAsync`, `ClearScheduleAsync`, and internal helper methods all modified these dictionaries without locks. + +### Solution + +Added thread synchronization using a lock object: + +```csharp +public class LocalScheduler : IScheduler +{ + private readonly IServiceProvider _serviceProvider; + private readonly IDictionary _scheduledTasks = new Dictionary(); + private readonly IDictionary> _scheduledTaskKeys = new Dictionary>(); + private readonly object _lock = new(); // Added lock object + + public ValueTask ScheduleAsync(string name, ITask task, ISchedule schedule, IEnumerable? keys = null, CancellationToken cancellationToken = default) + { + var scheduleContext = new ScheduleContext(_serviceProvider, task); + var scheduledTask = schedule.Schedule(scheduleContext); + + lock (_lock) // Protected dictionary access + { + RegisterScheduledTask(name, scheduledTask, keys); + } + + return ValueTask.CompletedTask; + } + + public ValueTask ClearScheduleAsync(string name, CancellationToken cancellationToken = default) + { + lock (_lock) // Protected dictionary access + { + RemoveScheduledTask(name); + } + + return ValueTask.CompletedTask; + } + + public ValueTask ClearScheduleAsync(IEnumerable keys, CancellationToken cancellationToken = default) + { + lock (_lock) // Protected dictionary access + { + RemoveScheduledTasks(keys); + } + + return ValueTask.CompletedTask; + } +} +``` + +**Benefits**: +- Eliminates race conditions during concurrent scheduling +- Prevents `IndexOutOfRangeException` during dictionary modifications +- Ensures thread-safe access to internal collections +- Simple and efficient locking strategy + +**Implementation Notes**: +- Lock is only held during dictionary operations (minimal lock duration) +- Schedule creation happens outside the lock to minimize contention +- Private methods (`RegisterScheduledTask`, `RemoveScheduledTask`, `RemoveScheduledTasks`) are called within locks, so they don't need additional synchronization + +**Why `lock` instead of `SemaphoreSlim`?** + +The implementation uses a simple `lock` rather than `SemaphoreSlim` for optimal performance: + +| Aspect | lock | SemaphoreSlim | +|--------|------|---------------| +| **Overhead** | Zero allocation | Heap allocations | +| **Complexity** | Automatic release | Manual try/finally | +| **Use Case** | Perfect for synchronous ops | Better for async ops | +| **Performance** | Compiler optimized | Additional overhead | +| **Async Support** | Cannot cross await | Can use WaitAsync | + +**Decision Rationale**: +1. ✅ **All critical sections are synchronous** - Only dictionary operations (add/remove/lookup) +2. ✅ **Methods return `ValueTask.CompletedTask`** - Not truly async, just implementing interface signature +3. ✅ **No await inside critical sections** - Schedule creation happens outside the lock +4. ✅ **Zero allocation overhead** - `lock` is ideal for fast synchronous operations + +`SemaphoreSlim` would be appropriate if: +- Critical sections contained `await` statements +- Methods were truly asynchronous with I/O operations +- Cancellation support was needed during lock acquisition + +For this use case, `lock` is the optimal choice for simplicity, performance, and correctness. + +## Files Changed + +- `src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs` - Added exception handling for workflow loading failures +- `test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs` - New integration tests +- `src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs` - Added exception handling for missing workflow instances +- `src/modules/Elsa.Scheduling/Services/LocalScheduler.cs` - Added thread synchronization for concurrent access + +## Impact + +- **Stability**: System no longer fails when workflows or instances have issues +- **Thread Safety**: LocalScheduler now handles concurrent scheduling operations safely +- **Observability**: Warnings are logged with clear explanations for workflow and instance issues +- **Reliability**: Other workflows and scheduled tasks continue to be processed correctly +- **Coverage**: Comprehensive tests ensure trigger deletion behavior is maintained +- **Resilience**: + - Scheduled tasks handle deleted workflow instances gracefully + - Scheduler handles concurrent access during startup and runtime diff --git a/agent-logs/7077/2025-11-20_workflow-instance-deletion_runtime-coordination.md b/agent-logs/7077/2025-11-20_workflow-instance-deletion_runtime-coordination.md new file mode 100644 index 000000000..485c6b8c0 --- /dev/null +++ b/agent-logs/7077/2025-11-20_workflow-instance-deletion_runtime-coordination.md @@ -0,0 +1,393 @@ +# Workflow Instance Deletion: Runtime Coordination and Separation of Concerns + +**Date**: 2025-11-20 +**Branch**: bug/7077 +**Commit**: 1f326295a38eee236b84d60e69afdf9e3a1eda1a + +--- + +## Problem Statement + +Workflow instances were not being properly deleted when using the Delete and BulkDelete API endpoints. The deletion operations were failing silently or inconsistently, particularly for running workflow instances. + +## Root Cause + +The deletion endpoints were directly using `IWorkflowInstanceManager` to delete workflow instances from the database without proper coordination with the workflow runtime. This caused several issues: + +1. **No coordination for running workflows**: Running workflows need to be properly stopped/cancelled before deletion +2. **Race conditions**: In distributed scenarios, concurrent operations could cause database inconsistencies +3. **Missing cleanup**: Runtime state, bookmarks, and other related data weren't being cleaned up properly +4. **Violation of separation of concerns**: API layer was directly manipulating persistence layer without going through the runtime + +## Solution Architecture + +The solution refactors the deletion logic to properly use the workflow runtime infrastructure, ensuring proper coordination and cleanup. + +### Key Design Principles + +1. **Runtime coordination**: All workflow instance operations should go through `IWorkflowRuntime` and `IWorkflowClient` +2. **Proper cancellation**: Running workflows must be cancelled before deletion +3. **Distributed locking**: Use the same distributed locks as execution to prevent concurrent modifications +4. **Separation by status**: Handle running and finished instances differently + +### Implementation Details + +#### 1. Added Delete Method to IWorkflowClient + +**File**: `src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs` + +```csharp +/// +/// Deletes the workflow instance. This method ensures that the instance is properly stopped before deletion. +/// +Task DeleteAsync(CancellationToken cancellationToken = default); +``` + +**Rationale**: The `IWorkflowClient` is the proper abstraction for workflow instance operations. Adding deletion here ensures: +- Consistent API surface +- Proper coordination with other operations +- Support for both local and distributed scenarios + +#### 2. Implemented LocalWorkflowClient.DeleteAsync + +**File**: `src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs` + +**Key logic**: +```csharp +public async Task DeleteAsync(CancellationToken cancellationToken = default) +{ + // Load the workflow instance (single DB call) + var workflowInstance = await TryGetWorkflowInstanceAsync(cancellationToken); + if (workflowInstance == null) + return false; + + // Cancel if running (ensures proper cleanup) + await CancelAsync(workflowInstance, cancellationToken); + + // Delete the workflow instance + var filter = new WorkflowInstanceFilter { Id = workflowInstanceId }; + await workflowInstanceManager.DeleteAsync(filter, cancellationToken); + return true; +} +``` + +**Key features**: +- **Single DB read**: Loads the instance once and reuses it +- **Proper cancellation**: Ensures running workflows are stopped first +- **Graceful handling**: Returns `false` if instance doesn't exist +- **Clean deletion**: Uses the manager's delete method for proper cleanup + +**Supporting refactoring**: +- Extracted `CancelAsync(WorkflowInstance)` private method to avoid loading the instance twice +- Added `TryGetWorkflowInstanceAsync()` helper for non-throwing retrieval + +#### 3. Implemented DistributedWorkflowClient.DeleteAsync + +**File**: `src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs` + +```csharp +public async Task DeleteAsync(CancellationToken cancellationToken = default) +{ + // Use the same distributed lock as for execution to prevent concurrent DB writes + return await WithLockAsync(async () => await _localWorkflowClient.DeleteAsync(cancellationToken)); +} +``` + +**Key feature**: Uses the same distributed lock mechanism (`workflow-instance:{id}`) as execution operations, preventing: +- Concurrent modifications during deletion +- Race conditions between delete and execute operations +- Inconsistent state in distributed scenarios + +#### 4. Refactored Single Instance Delete Endpoint + +**File**: `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs` + +**Before**: +```csharp +internal class Delete(IWorkflowInstanceManager store) : ElsaEndpoint +{ + public override async Task HandleAsync(Request request, CancellationToken cancellationToken) + { + var filter = new WorkflowInstanceFilter { Id = request.Id }; + var deleted = await store.DeleteAsync(filter, cancellationToken); + // ... + } +} +``` + +**After**: +```csharp +internal class Delete(IWorkflowRuntime workflowRuntime) : ElsaEndpoint +{ + public override async Task HandleAsync(Request request, CancellationToken cancellationToken) + { + var client = await workflowRuntime.CreateClientAsync(request.Id, cancellationToken); + var deleted = await client.DeleteAsync(cancellationToken); + // ... + } +} +``` + +**Changes**: +- ✅ Uses `IWorkflowRuntime` instead of `IWorkflowInstanceManager` +- ✅ Creates a workflow client for proper coordination +- ✅ Relies on runtime infrastructure for proper deletion + +#### 5. Refactored Bulk Delete Endpoint + +**File**: `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs` + +**Strategy**: Split deletion into two phases based on workflow status: + +**Phase 1: Delete Running Instances Individually** +```csharp +// Step 1: Delete running instances individually (requires coordination by the workflow runtime). +var runningFilter = new WorkflowInstanceFilter +{ + Ids = baseFilter.Ids, + DefinitionId = baseFilter.DefinitionId, + DefinitionIds = baseFilter.DefinitionIds, + WorkflowStatus = WorkflowStatus.Running +}; + +var runningInstanceIds = await workflowInstanceStore.FindManyIdsAsync(runningFilter, cancellationToken); +var count = 0L; + +foreach (var instanceId in runningInstanceIds) +{ + var client = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken); + var deleted = await client.DeleteAsync(cancellationToken); + if (deleted) + count++; +} +``` + +**Phase 2: Bulk Delete Finished Instances** +```csharp +// Step 2: Bulk delete finished instances (no coordination needed). +// Use IWorkflowInstanceManager to ensure related records are also deleted. +var finishedFilter = new WorkflowInstanceFilter +{ + Ids = baseFilter.Ids, + DefinitionId = baseFilter.DefinitionId, + DefinitionIds = baseFilter.DefinitionIds, + WorkflowStatus = WorkflowStatus.Finished +}; + +var finishedDeletedCount = await workflowInstanceManager.BulkDeleteAsync(finishedFilter, cancellationToken); +count += finishedDeletedCount; +``` + +**Rationale**: +- **Running instances**: Need individual coordination through the runtime (cancellation, locks, cleanup) +- **Finished instances**: Can be safely bulk-deleted as they have no active runtime state +- **IWorkflowInstanceManager vs IWorkflowInstanceStore**: Using `IWorkflowInstanceManager.BulkDeleteAsync()` ensures that related records (execution logs, bookmarks, activity execution records, etc.) are also deleted, not just the workflow instance record +- **Performance**: Avoids unnecessary overhead for finished workflows while ensuring safety for running ones +- **Correctness**: Each running workflow gets proper cancellation and distributed locking + +**Trade-offs**: +- ✅ **Correctness**: Ensures proper cleanup and coordination +- ✅ **Safety**: Prevents race conditions and inconsistencies +- ⚠️ **Performance**: Running instances are deleted one-by-one instead of bulk + - This is acceptable because: + - Running instances are typically a small percentage of total instances + - Proper coordination is more important than bulk performance + - Finished instances still use efficient bulk deletion + +## Benefits + +### 1. Proper Coordination +- Running workflows are properly cancelled before deletion +- Runtime state is cleaned up correctly +- Bookmarks and related data are removed + +### 2. Distributed Safety +- Uses the same distributed locks as execution +- Prevents race conditions in multi-node deployments +- Ensures consistency across the cluster + +### 3. Separation of Concerns +- API layer uses runtime abstractions +- No direct persistence manipulation +- Easier to maintain and extend + +### 4. Consistent Behavior +- Same deletion logic for single and bulk operations +- Predictable behavior across all scenarios +- Proper error handling and return values + +## Testing Verification + +### Automated Component Tests + +Created comprehensive component tests in `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/`: + +**Test Infrastructure:** +1. **Custom Activity**: `LongRunningActivity` + - Simulates a long-running workflow operation (3 seconds) + - Uses `SignalManager` to notify tests when execution starts + - Keeps workflow in memory without hitting a bookmark (crucial for testing the race condition) + +2. **Test Workflows**: + - `SuspendingWorkflow`: Uses `LongRunningActivity` to create running workflow instances + - `CompletingWorkflow`: Completes immediately for testing finished workflow deletion + +3. **Helper Methods** (following DRY principle): + - `StartRunningWorkflowAsync()`: Starts a workflow and waits for it to begin executing + - `AssertWorkflowInstanceDeletedAsync()`: Verifies a workflow instance is deleted + - Property accessors for common services (`WorkflowRuntime`, `SignalManager`, `WorkflowInstanceStore`) + +**Test Cases:** +1. ✅ `DeleteRunningWorkflowInstance_ShouldCancelAndRemove` + - Tests deletion of a workflow actively executing in memory + - Verifies the instance is deleted and not found afterward + +2. ✅ `DeleteFinishedWorkflowInstance_ShouldRemove` + - Tests deletion of a completed workflow instance + - Verifies proper cleanup of finished workflows + +3. ✅ `DeleteNonExistentWorkflowInstance_ShouldThrowException` + - Tests error handling for non-existent instances + - Expects `Refit.ApiException` (404 Not Found) + +4. ✅ `BulkDeleteMixedInstances_ShouldDeleteAll` + - Tests bulk deletion of 2 running + 2 finished instances + - Verifies all instances are properly deleted + +5. ✅ `DeleteWorkflowInstance_ShouldNotBeResurrectedByPersistence` **(Core #7077 test)** + - **Critical test** that validates the distributed locking fix + - Starts a workflow with `LongRunningActivity` (stays in memory) + - Attempts deletion while workflow is still executing + - **Without the fix**: Workflow would be deleted but then resurrected when it completes + - **With the fix**: Deletion blocks until workflow completes (distributed lock), preventing resurrection + - Verifies the instance remains deleted after workflow completion + +**Key Testing Patterns:** +- **SignalManager pattern**: Elegant coordination between workflow execution and test assertions + - Handles race conditions gracefully (signal can fire before or after wait) + - Uses `ConcurrentDictionary` with `TaskCompletionSource` for thread-safe signaling + - No static state to clean up between tests +- **WorkflowEvents pattern**: Used to wait for workflow state commits when needed +- **DRY principle**: Extracted common patterns into reusable helper methods +- **Readable tests**: Focus on "what" not "how" - test intent is clear and concise + +All tests pass successfully, validating the fix works correctly. + +### Manual Testing +- ✅ Delete single running workflow instance +- ✅ Delete single finished workflow instance +- ✅ Delete non-existent workflow instance (returns false) +- ✅ Bulk delete mixed running and finished instances +- ✅ Bulk delete by definition ID +- ✅ Verify proper cancellation before deletion +- ✅ Verify distributed locking in multi-node scenario + +### Edge Cases Handled +- **Non-existent instance**: Returns `false` gracefully +- **Already cancelled/finished**: No-op on cancellation, proceeds to delete +- **Concurrent operations**: Distributed lock prevents conflicts +- **Partial bulk delete**: Counts successful deletions accurately + +## Performance Considerations + +### Single Delete +- **Before**: 1 query (direct delete) +- **After**: 2 queries (load + cancel if needed + delete) +- **Impact**: Minimal - proper coordination is worth the extra query + +### Bulk Delete +- **Running instances**: N queries (where N = number of running instances) + - Acceptable because running instances are typically few + - Proper coordination requires individual handling +- **Finished instances**: 1 query (bulk delete) + - Maintains performance for the common case + - Most instances are finished, not running + +### Optimization Opportunities +- Could add a `BulkCancelAsync` method for running instances +- Could batch running instance deletions with parallel processing +- Current approach prioritizes correctness over optimization + +## Migration Notes + +### Breaking Changes +None - this is a bug fix that doesn't change the API surface. + +### Behavioral Changes +1. **Running workflows are now cancelled before deletion** + - Previous behavior: Direct delete (potentially inconsistent state) + - New behavior: Cancel then delete (proper cleanup) + +2. **Bulk delete now handles running vs finished instances differently** + - Previous behavior: Attempted bulk delete of all instances + - New behavior: Individual delete for running, bulk for finished + +### Upgrade Path +No action required - changes are fully backward compatible. + +## Files Modified + +### Core Implementation + +1. `src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs` + - Added `DeleteAsync()` method + +2. `src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs` + - Implemented `DeleteAsync()` with proper cancellation + - Refactored `CancelAsync()` to avoid duplicate loading + - Added `TryGetWorkflowInstanceAsync()` helper + +3. `src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs` + - Implemented `DeleteAsync()` with distributed locking + +4. `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs` + - Changed to use `IWorkflowRuntime` instead of `IWorkflowInstanceManager` + - Uses workflow client for deletion + +5. `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs` + - Refactored to handle running and finished instances separately + - Changed to use `IWorkflowRuntime` for running instances + - Changed to use `IWorkflowInstanceManager.BulkDeleteAsync()` for finished instances (ensures related records are deleted) + +### Test Files + +6. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Activities/LongRunningActivity.cs` **(NEW)** + - Custom activity that simulates long-running operation with 3-second delay + - Uses `SignalManager` to notify tests when execution starts + - Keeps workflow in memory for testing race conditions + +7. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/SuspendingWorkflow.cs` **(NEW)** + - Test workflow using `LongRunningActivity` + - Used to create running workflow instances for testing + +8. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/CompletingWorkflow.cs` **(NEW)** + - Test workflow that completes immediately + - Used for testing finished workflow deletion + +9. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/WorkflowInstanceDeletionTests.cs` **(NEW)** + - 5 comprehensive component tests covering all deletion scenarios + - Tests the core #7077 fix (workflow resurrection prevention) + - Uses DRY principles with helper methods for maintainability + +## Related Issues + +- **Bug #7077**: Workflow instance not being deleted +- Related to proper runtime coordination and distributed locking + +## Future Enhancements + +1. **Batch cancellation**: Add `BulkCancelAsync` for running instances +2. **Parallel deletion**: Process running instances in parallel with configurable concurrency +3. **Soft delete**: Add option for soft delete with retention period +4. **Delete callbacks**: Add hooks for cleanup of related resources +5. **Metrics**: Track deletion performance and success rates + +--- + +## Key Takeaways + +1. **Always use runtime abstractions**: Don't bypass `IWorkflowClient` to directly manipulate persistence +2. **Coordinate running workflows**: Active workflows need proper cancellation before deletion +3. **Use distributed locks**: Prevent race conditions in multi-node deployments +4. **Separate concerns by status**: Running and finished workflows have different coordination needs +5. **Performance vs correctness**: Prioritize correctness, optimize performance within those constraints diff --git a/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs index 1d2847d54..62d8d1ba2 100644 --- a/build/Build.CI.GitHubActions.cs +++ b/build/Build.CI.GitHubActions.cs @@ -8,10 +8,10 @@ using Nuke.Components; [CustomGitHubActions( "pr", GitHubActionsImage.UbuntuLatest, - OnPullRequestBranches = ["main"], + OnPullRequestBranches = ["main", "patch/*", "develop/*"], OnPullRequestIncludePaths = ["**/*"], PublishArtifacts = false, - InvokedTargets = [nameof(ICompile.Compile), nameof(ITest.Test), nameof(IPack.Pack)], + InvokedTargets = [nameof(ICompile.Compile), nameof(ITest.Test)], CacheKeyFiles = [], ConcurrencyCancelInProgress = true ) diff --git a/build/_build.csproj b/build/_build.csproj index be8285ad2..dac12855f 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 CS0649;CS0169;CA1050;CA1822;CA2211;IDE1006 .. @@ -19,12 +19,7 @@ - - - - - - + diff --git a/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md b/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md index b1143eca0..f6f279a50 100644 --- a/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md +++ b/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md @@ -24,27 +24,38 @@ We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBas ```csharp namespace Elsa.Workflows.Activities.Flowchart.Models; + /// + /// Specifies the strategy for handling multiple inbound execution paths in a workflow. + /// Uses flow-based terminology to describe merge behavior. + /// public enum MergeMode { /// - /// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths. - /// Suitable for flexible, unstructured merges where optional branches shouldn't block. - /// - None, - - /// - /// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths. - /// Use for required synchronization points. - /// - Converge, - - /// - /// Schedule on each arriving token, allowing multiple executions if supported. + /// Flows freely when possible, ignoring dead/untaken paths. + /// Opportunistic execution based on upstream completion. /// Stream, /// - /// Schedule on the first arriving token, block or cancel others. + /// Merges only the activated/flowing inbound branches. + /// Waits for all branches that received tokens, ignoring unactivated ones. + /// + Merge, + + /// + /// Converges all inbound paths, requiring every connection to execute. + /// Strictest mode - will block on dead/untaken paths. + /// + Converge, + + /// + /// Cascades execution for each arriving token independently. + /// Allows multiple concurrent executions (one per arriving token). + /// + Cascade, + + /// + /// Races inbound branches, executing on first arrival and blocking others. /// Race } @@ -62,14 +73,14 @@ We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBas ```csharp switch (mergeMode) { - case MergeMode.Stream: + case MergeMode.Cascade: case MergeMode.Race: - // Existing logic: Schedule on arrival, block others for Race. + // Schedule on arrival; for Race, block/cancel others. // ... break; - case MergeMode.Converge: - // Strict check: Wait for all forward inbounds if >1; else schedule immediately. + case MergeMode.Merge: + // Wait for tokens from all forward inbound connections (activated branches only). var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity); if (inboundConnections.Count > 1) { @@ -82,9 +93,23 @@ We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBas } break; - case MergeMode.None: + case MergeMode.Converge: + // Strictest mode: Wait for tokens from ALL inbound connections (forward + backward). + var allInboundConnections = flowGraph.GetInboundConnections(targetActivity); + if (allInboundConnections.Count > 1) + { + var hasAllTokens = allInboundConnections.All(inbound => /* token check */); + if (hasAllTokens) await flowContext.ScheduleActivityAsync(...); + } + else + { + await flowContext.ScheduleActivityAsync(...); + } + break; + + case MergeMode.Stream: default: - // Approximation: Schedule if no unconsumed tokens to inbound sources. + // Flows freely - approximation that proceeds when upstream completes. var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity); var hasUnconsumed = inboundConnections.Any(inbound => /* source token check */); if (!hasUnconsumed) await flowContext.ScheduleActivityAsync(...); @@ -104,10 +129,10 @@ Flowchart execution starts with scheduling the root/start activity. As activitie This ensures acyclic forward flow with support for backward loops, using tokens to track control without global state beyond the list. ### Merge Modes Explained -Each mode defines how tokens from multiple inbounds are synchronized: +Each mode defines how tokens from multiple inbounds are synchronized using flow-based terminology: -- **None (Default/Flexible Merge)**: - - **Behavior**: Schedules if there are no unconsumed tokens *to the sources* of inbounds (i.e., all upstream activities have completed, treating dead paths as "done"). +- **Stream (Flexible/Opportunistic Flow)**: + - **Behavior**: Flows freely when possible, ignoring dead/untaken paths. Schedules if there are no unconsumed tokens *to the sources* of inbounds (i.e., all upstream activities have completed, treating dead paths as "done"). - **When to Use**: Flexible merges in unstructured flows; optional/exclusive branches (e.g., switch defaults) shouldn't block. - **Scenarios**: - **Forks with Untaken Paths**: Proceeds after active branches (e.g., in complex switch with dangling default). @@ -115,18 +140,26 @@ Each mode defines how tokens from multiple inbounds are synchronized: - **Dead Paths**: Ignores untaken outcomes; no blocking. - **Example**: In a switch with MatchAny, untaken default doesn't hang the merge. -- **Converge (Strict Synchronization)**: - - **Behavior**: Requires unconsumed, non-blocked tokens from *all* forward inbounds. For <=1 forward, schedules immediately (loop/sequential friendly). - - **When to Use**: Required "all must happen" joins; block if any branch untaken. +- **Merge (Activated Branches Synchronization)**: + - **Behavior**: Merges only activated/flowing inbound branches. Requires unconsumed, non-blocked tokens from *all* forward inbounds that received tokens. For <=1 forward, schedules immediately (loop/sequential friendly). + - **When to Use**: Synchronization points where only activated paths matter; block if any activated branch hasn't completed. - **Scenarios**: - - **Conditional Forks**: Blocks downstream if e.g., decision returns false and subsequent activities are connected to the true branch. + - **Conditional Forks**: Blocks downstream if decision returns false and subsequent activities are connected to the true branch, but only waits for activated branches. - **Loops**: Works if forward inbounds <=1; reschedules on backward tokens. - - **Dead Paths**: Blocks (desired for safety). - - **Example**: Converge after parallel approvals—only proceed if all complete. + - **Dead Paths**: Ignores untaken branches; waits only for activated ones. + - **Example**: Merge after parallel approvals—only proceed if all activated approval branches complete. -- **Stream (Per-Token Execution)**: - - **Behavior**: Schedules on each arriving token; may allow multiple concurrent executions of the target. - - **When to Use**: Streaming merges where each branch triggers independently (e.g., event streams). +- **Converge (Strictest - All Paths Required)**: + - **Behavior**: Converges ALL inbound paths, requiring every connection to execute (forward AND backward). Most strict mode. + - **When to Use**: When every single inbound path must execute before proceeding, regardless of activation status. + - **Scenarios**: + - **Strict Barriers**: Forces all possible paths to complete before proceeding. + - **Dead Paths**: Blocks on dead/untaken paths (desired for maximum safety). + - **Example**: Critical synchronization point requiring absolute completion of all defined paths. + +- **Cascade (Per-Token Execution)**: + - **Behavior**: Cascades execution for each arriving token independently; may allow multiple concurrent executions of the target. + - **When to Use**: Streaming scenarios where each branch should trigger separate processing (e.g., event streams). - **Scenarios**: - **Forks**: Executes target per branch. - **Loops**: Executes per iteration. @@ -134,8 +167,8 @@ Each mode defines how tokens from multiple inbounds are synchronized: - **Example**: Logging each branch outcome separately. - **Race (First-Wins)**: - - **Behavior**: Schedules on first token; blocks/cancels others (e.g., via blocked tokens and ancestor cancellation). - - **When to Use**: Racing conditions (e.g., first response wins). + - **Behavior**: Races inbound branches; schedules on first token, blocks/cancels others (e.g., via blocked tokens and ancestor cancellation). + - **When to Use**: Racing conditions where first result wins (e.g., first response). - **Scenarios**: - **Forks**: Only first branch proceeds. - **Loops**: May race iterations if concurrent. @@ -143,12 +176,12 @@ Each mode defines how tokens from multiple inbounds are synchronized: - **Example**: Waiting for fastest API response; cancel slower ones. ### Handling Common Scenarios - + - **Simple Sequential**: Any mode schedules on token arrival (single inbound). -- **Fork-Join with Condition**: Converge blocks on false; None proceeds. -- **Looping Construct**: All modes work; Converge uses count check to avoid strictness. -- **Switch with Dangling Branches**: None ignores untaken; Converge blocks if required. -- **BPMN Alignment**: None ≈ XOR/OR-join (flexible); Converge ≈ AND-join (strict); Race ≈ Event-based; Stream ≈ partial OR. +- **Fork-Join with Condition**: Merge waits for activated branches; Converge blocks on all paths; Stream proceeds opportunistically. +- **Looping Construct**: All modes work; Merge and Converge use count check to avoid strictness on single inbound. +- **Switch with Dangling Branches**: Stream ignores untaken; Merge waits for activated; Converge blocks on all. +- **BPMN Alignment**: Stream ≈ XOR/OR-join (flexible); Merge ≈ AND-join for active paths; Converge ≈ strict AND-join; Race ≈ Event-based; Cascade ≈ parallel multi-instance. ## Consequences diff --git a/run-dsl-tests.sh b/run-dsl-tests.sh new file mode 100644 index 000000000..99e4b3b6d --- /dev/null +++ b/run-dsl-tests.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Run from the repository root, or adjust the path below as needed. +dotnet test test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/Elsa.Dsl.ElsaScript.IntegrationTests.csproj --verbosity normal diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 84212213e..6330b1711 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ - net8.0;net9.0 + net8.0;net9.0;net10.0 diff --git a/src/apps/Directory.Build.props b/src/apps/Directory.Build.props index 50f33a49d..0d8817302 100644 --- a/src/apps/Directory.Build.props +++ b/src/apps/Directory.Build.props @@ -1,6 +1,6 @@ - net9.0 + net10.0 latest enable enable diff --git a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj index 6fa3939e8..206866f3d 100644 --- a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj +++ b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj @@ -3,9 +3,12 @@ + + + @@ -24,4 +27,19 @@ + + + Always + + + Always + + + Always + + + Always + + + diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index 742a8820a..b056f5010 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -4,13 +4,13 @@ using Elsa.Common.RecurringTasks; using Elsa.Expressions.Helpers; using Elsa.Extensions; using Elsa.Features.Services; -using Elsa.Identity.Multitenancy; using Elsa.Persistence.EFCore.Extensions; using Elsa.Persistence.EFCore.Modules.Management; using Elsa.Persistence.EFCore.Modules.Runtime; using Elsa.Server.Web.Filters; using Elsa.Tenants.AspNetCore; using Elsa.Tenants.Extensions; +using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Extensions; using Elsa.Workflows; using Elsa.Workflows.Api; using Elsa.Workflows.CommitStates.Strategies; @@ -46,7 +46,7 @@ services .AddWorkflowsFrom() .UseIdentity(identity => { - identity.TokenOptions = options => identityTokenSection.Bind(options); + identity.TokenOptions += options => identityTokenSection.Bind(options); identity.UseConfigurationBasedUserProvider(options => identitySection.Bind(options)); identity.UseConfigurationBasedApplicationProvider(options => identitySection.Bind(options)); identity.UseConfigurationBasedRoleProvider(options => identitySection.Bind(options)); @@ -74,6 +74,8 @@ services runtime.UseDistributedRuntime(); }) .UseWorkflowsApi() + .UseFluentStorageProvider() + .UseElsaScriptBlobStorage() .UseScheduling() .UseCSharp(options => { @@ -136,6 +138,7 @@ services.AddCors(cors => cors.AddDefaultPolicy(policy => policy.AllowAnyHeader() // Build the web application. var app = builder.Build(); + // Configure the pipeline. if (app.Environment.IsDevelopment()) app.UseDeveloperExceptionPage(); diff --git a/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa b/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa new file mode 100644 index 000000000..39439a8da --- /dev/null +++ b/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa @@ -0,0 +1,16 @@ +use Elsa.Activities.Console; + +workflow FlowchartTest { + flowchart { + var counter = 0; + + entry Start; + + Start: WriteLine("Starting flowchart"); + CheckCondition: WriteLine("Checking condition"); + EndNode: WriteLine("Done!"); + + Start -> CheckCondition; + CheckCondition.Done -> EndNode; + } +} diff --git a/src/apps/Elsa.Server.Web/Workflows/for-loop.elsa b/src/apps/Elsa.Server.Web/Workflows/for-loop.elsa new file mode 100644 index 000000000..cad2bb7df --- /dev/null +++ b/src/apps/Elsa.Server.Web/Workflows/for-loop.elsa @@ -0,0 +1,18 @@ +use expressions js; + +workflow ForLoopTest +{ + WriteLine("First loop") + + for (var i = 0 through 10 step 1) + { + WriteLine(js => `Step: ${variables.i}`) + } + + WriteLine("Second loop") + + for (var i = 0 to 10 step 2) + WriteLine(cs => return $"Step: {Variables.Get("i")}";) + + WriteLine("Done!") +} \ No newline at end of file diff --git a/src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa b/src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa new file mode 100644 index 000000000..f4bb0db7d --- /dev/null +++ b/src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa @@ -0,0 +1,8 @@ +use expressions js; + +workflow HelloWorldHttpDsl +{ + var message = "Hello World from DSL via Expressions!"; + listen HttpEndpoint("/hello-world-dsl"); + WriteHttpResponse(js => `Message: ${variables.message}`); +} \ No newline at end of file diff --git a/src/apps/Elsa.Server.Web/Workflows/hello-world.elsa b/src/apps/Elsa.Server.Web/Workflows/hello-world.elsa new file mode 100644 index 000000000..37a88a681 --- /dev/null +++ b/src/apps/Elsa.Server.Web/Workflows/hello-world.elsa @@ -0,0 +1,6 @@ +use expressions js; + +workflow HelloWorldDsl +{ + WriteLine("Hello World from Elsa DSL!"); +} \ No newline at end of file diff --git a/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa b/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa new file mode 100644 index 000000000..05998558e --- /dev/null +++ b/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa @@ -0,0 +1,29 @@ +use Elsa.Activities.Console; + +workflow HelloWorldDsl( + DisplayName: "Hello World DSL", + Description: "Demonstrates a simple ElsaScript workflow with metadata.", + DefinitionId: "hello-world-dsl", + DefinitionVersionId: "hello-world-dsl-v1", + Version: 1, + UsableAsActivity: true +) +{ + use expressions js; + + WriteLine("Hello World from Elsa DSL!"); +} + +workflow HelloWorldAsActivity( + DisplayName: "Hello World as Activity", + Description: "Can be embedded as an activity in other workflows.", + DefinitionId: "hello-world-activity", + DefinitionVersionId: "hello-world-activity-v1", + Version: 1, + UsableAsActivity: true +) +{ + use expressions js; + + WriteLine(`Inner hello at ${Date.now()}`); +} diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index f7456f2df..3bdf4969d 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -3,7 +3,8 @@ "LogLevel": { "Default": "Warning", "Microsoft.Hosting.Lifetime": "Information", - "Elsa": "Debug" + "Elsa": "Warning", + "Elsa.Workflows.ActivityRegistry": "Error" } }, "HostBuilder": { diff --git a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs index 2ec34f8b6..0b4dbe0c2 100644 --- a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs @@ -121,11 +121,16 @@ public static class ActivityExtensions public static MergeMode GetMergeMode(this JsonObject activity) { - return activity.GetProperty("customProperties", "mergeMode") ?? MergeMode.None; + var value = activity.GetProperty("customProperties", "mergeMode"); + // Treat MergeMode.None as equivalent to null (no merge mode set), defaulting to Stream + return value == null || value == MergeMode.None ? MergeMode.Stream : value.Value; } public static void SetMergeMode(this JsonObject activity, MergeMode? value) { + // Treat MergeMode.None as equivalent to null (no merge mode set) + if (value == MergeMode.None) + value = null; activity.SetProperty(JsonValue.Create(value), "customProperties", "mergeMode"); } diff --git a/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs index 3653519b9..5b4ad7e9c 100644 --- a/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs @@ -160,7 +160,7 @@ public static class JsonObjectExtensions { return model.GetProperty(path); } - catch (Exception e) + catch (Exception) { return default; } diff --git a/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs b/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs index f76518386..4cfcf48a1 100644 --- a/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs +++ b/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs @@ -9,5 +9,5 @@ public class RetryAttemptRecord : Entity public string WorkflowInstanceId { get; set; } = null!; public int AttemptNumber { get; set; } public TimeSpan RetryDelay { get; set; } - public IDictionary Details { get; set; } + public IDictionary Details { get; set; } = null!; } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs b/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs index 835893b38..eb1b7241c 100644 --- a/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs +++ b/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs @@ -5,4 +5,6 @@ namespace Elsa.Api.Client.Resources.StorageDrivers.Models; /// /// The type name of the storage driver. /// The display name of the storage driver. +/// The priority of the storage driver. +/// Indicates whether the storage driver is deprecated. public record StorageDriverDescriptor(string TypeName, string DisplayName, double Priority = 0, bool Deprecated = false); \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs b/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs index c0a8944ab..20ca54aeb 100644 --- a/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs +++ b/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs @@ -2,28 +2,51 @@ namespace Elsa.Api.Client.Shared.Enums; /// /// Specifies the strategy for handling multiple inbound execution paths in a workflow. +/// Uses flow-based terminology to describe merge behavior. /// public enum MergeMode { /// - /// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths. - /// Suitable for flexible, unstructured merges where optional branches shouldn't block. + /// No merge mode set. Treated as if merge mode was not specified (null). + /// Provides backward compatibility for existing workflows. + /// Defaults to Stream behavior at runtime. /// None, /// - /// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths. - /// Use for required synchronization points. - /// - Converge, - - /// - /// Schedule on each arriving token, allowing multiple executions if supported. + /// Flows freely when possible, ignoring dead/untaken paths. + /// Opportunistic execution based on upstream completion. + /// Uses approximation that proceeds after all upstream sources complete. + /// Suitable for flexible, unstructured merges where optional branches shouldn't block. /// Stream, /// + /// Merges only the activated/flowing inbound branches. + /// Waits for all branches that received tokens, ignoring unactivated ones. + /// Use for synchronization points where only taken paths matter (e.g., fork-joins with conditions). + /// + Merge, + + /// + /// Converges all inbound paths, requiring every connection to execute. + /// Blocks until all branches complete, including unactivated ones. + /// Strictest mode - will block on dead/untaken paths. + /// Use when every single inbound path must execute before proceeding. + /// + Converge, + + /// + /// Cascades execution for each arriving token independently. + /// Allows multiple concurrent executions (one per arriving token). + /// Use for streaming scenarios where each branch should trigger separate processing. + /// + Cascade, + + /// + /// Races inbound branches, executing on first arrival and blocking others. /// Schedule on the first arriving token, block or cancel others. + /// Use for competitive scenarios where only the first result matters. /// Race } \ No newline at end of file diff --git a/src/common/Elsa.Mediator/Contracts/ICommandSender.cs b/src/common/Elsa.Mediator/Contracts/ICommandSender.cs index 5373afb3b..02d32ca7a 100644 --- a/src/common/Elsa.Mediator/Contracts/ICommandSender.cs +++ b/src/common/Elsa.Mediator/Contracts/ICommandSender.cs @@ -28,7 +28,7 @@ public interface ICommandSender /// The cancellation token. /// The type of the result. /// The result. - Task SendAsync(ICommand command, ICommandStrategy strategy, CancellationToken cancellationToken = default); + Task SendAsync(ICommand command, ICommandStrategy? strategy, CancellationToken cancellationToken = default); /// /// Sends a command using the specified strategy. @@ -39,7 +39,7 @@ public interface ICommandSender /// The cancellation token. /// The type of the result. /// The result. - Task SendAsync(ICommand command, ICommandStrategy strategy, IDictionary headers, CancellationToken cancellationToken = default); + Task SendAsync(ICommand command, ICommandStrategy? strategy, IDictionary headers, CancellationToken cancellationToken = default); /// /// Sends a command using the default strategy. @@ -54,7 +54,7 @@ public interface ICommandSender /// The command to send. /// The command strategy to use. /// The cancellation token. - Task SendAsync(ICommand command, ICommandStrategy strategy, CancellationToken cancellationToken = default); + Task SendAsync(ICommand command, ICommandStrategy? strategy, CancellationToken cancellationToken = default); /// /// Sends a command using the specified strategy. @@ -63,5 +63,5 @@ public interface ICommandSender /// The command strategy to use. /// Any headers to pass along. /// The cancellation token. - Task SendAsync(ICommand command, ICommandStrategy strategy, IDictionary headers, CancellationToken cancellationToken = default); + Task SendAsync(ICommand command, ICommandStrategy? strategy, IDictionary headers, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/common/Elsa.Mediator/Extensions/HandlerExtensions.cs b/src/common/Elsa.Mediator/Extensions/HandlerExtensions.cs index 5ac381cb0..695268c58 100644 --- a/src/common/Elsa.Mediator/Extensions/HandlerExtensions.cs +++ b/src/common/Elsa.Mediator/Extensions/HandlerExtensions.cs @@ -42,8 +42,7 @@ public static class HandlerExtensions /// /// The handler to invoke. /// The handle method. - /// The notification to handle. - /// The cancellation token. + /// The notification context containing the notification and cancellation token. public static Task InvokeAsync(this INotificationHandler handler, MethodBase handleMethod, NotificationContext notificationContext) { var notification = notificationContext.Notification; diff --git a/src/common/Elsa.Mediator/Extensions/MediatorExtensions.cs b/src/common/Elsa.Mediator/Extensions/MediatorExtensions.cs index e1b307658..69328381c 100644 --- a/src/common/Elsa.Mediator/Extensions/MediatorExtensions.cs +++ b/src/common/Elsa.Mediator/Extensions/MediatorExtensions.cs @@ -39,7 +39,7 @@ public static class MediatorExtensions /// The cancellation token. public static async Task SendAsync(this IMediator mediator, ICommand command, CancellationToken cancellationToken = default) { - await mediator.SendAsync(command, default, cancellationToken); + await mediator.SendAsync(command, null, cancellationToken); } /// @@ -50,6 +50,6 @@ public static class MediatorExtensions /// The cancellation token. public static async Task SendAsync(this ICommandSender commandSender, ICommand command, CancellationToken cancellationToken = default) { - await commandSender.SendAsync(command, default, cancellationToken); + await commandSender.SendAsync(command, null, cancellationToken); } } \ No newline at end of file diff --git a/src/common/Elsa.Mediator/Middleware/Command/Components/CommandHandlerInvokerMiddleware.cs b/src/common/Elsa.Mediator/Middleware/Command/Components/CommandHandlerInvokerMiddleware.cs index 47cceb0d9..645181b95 100644 --- a/src/common/Elsa.Mediator/Middleware/Command/Components/CommandHandlerInvokerMiddleware.cs +++ b/src/common/Elsa.Mediator/Middleware/Command/Components/CommandHandlerInvokerMiddleware.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Elsa.Mediator.Contexts; using Elsa.Mediator.Contracts; using Elsa.Mediator.Middleware.Command.Contracts; @@ -13,6 +14,7 @@ namespace Elsa.Mediator.Middleware.Command.Components; public class CommandHandlerInvokerMiddleware(CommandMiddlewareDelegate next) : ICommandMiddleware { /// + [UnconditionalSuppressMessage("Trimming", "IL2060:Call to MakeGenericMethod can not be statically analyzed", Justification = "The result type is determined at runtime from command types and handlers are registered in DI.")] public async ValueTask InvokeAsync(CommandContext context) { // Find all handlers for the specified command. diff --git a/src/common/Elsa.Mediator/Services/DefaultMediator.cs b/src/common/Elsa.Mediator/Services/DefaultMediator.cs index 40587c3b7..96744ec57 100644 --- a/src/common/Elsa.Mediator/Services/DefaultMediator.cs +++ b/src/common/Elsa.Mediator/Services/DefaultMediator.cs @@ -28,6 +28,7 @@ public class DefaultMediator : IMediator /// The command pipeline. /// The notification pipeline. /// The mediator options. + /// The service provider. public DefaultMediator( IRequestPipeline requestPipeline, ICommandPipeline commandPipeline, @@ -53,9 +54,10 @@ public class DefaultMediator : IMediator return (T)context.Response; } - public async Task SendAsync(ICommand command, ICommandStrategy strategy, IDictionary headers, CancellationToken cancellationToken = default) + public async Task SendAsync(ICommand command, ICommandStrategy? strategy, IDictionary headers, CancellationToken cancellationToken = default) { var resultType = typeof(T); + strategy ??= _defaultCommandStrategy; var context = new CommandContext(command, strategy, resultType, headers, _serviceProvider, cancellationToken); await _commandPipeline.InvokeAsync(context); @@ -91,7 +93,7 @@ public class DefaultMediator : IMediator } /// - public Task SendAsync(ICommand command, ICommandStrategy strategy, CancellationToken cancellationToken = default) + public Task SendAsync(ICommand command, ICommandStrategy? strategy, CancellationToken cancellationToken = default) { return SendAsync(command, strategy, new Dictionary(), cancellationToken); } diff --git a/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs index 578802abd..df3436068 100644 --- a/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs +++ b/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs @@ -63,7 +63,7 @@ public static class DispatchWorkflowExtensions var workflowDispatcher = serviceProvider.GetRequiredService(); var dispatchWorkflowResponse = await workflowDispatcher.DispatchAsync(new DispatchWorkflowDefinitionRequest { - DefinitionVersionId = workflow.DefinitionHandle.DefinitionVersionId, + DefinitionVersionId = workflow.DefinitionHandle.DefinitionVersionId!, InstanceId = instanceId ?? Guid.NewGuid().ToString(), }); dispatchWorkflowResponse.ThrowIfFailed(); diff --git a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs index 429ce6f99..818f7787e 100644 --- a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs +++ b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs @@ -26,11 +26,13 @@ public static class RunWorkflowExtensions /// The services. /// The ID of the workflow definition. /// An optional dictionary of input values. + /// An optional correlation id of the workflow. /// An optional set of options to specify the version of the workflow definition to retrieve. /// The workflow state. public static async Task RunWorkflowUntilEndAsync(this IServiceProvider services, string workflowDefinitionId, IDictionary? input = null, + string? correlationId = null, VersionOptions? versionOptions = null) { var workflowDefinitionService = services.GetRequiredService(); @@ -44,7 +46,8 @@ public static class RunWorkflowExtensions var response = await workflowClient.CreateAndRunInstanceAsync(new() { WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowGraph.Workflow.Identity.Id), - Input = input + Input = input, + CorrelationId = correlationId }); var bookmarkStore = services.GetRequiredService(); @@ -64,7 +67,8 @@ public static class RunWorkflowExtensions { var runRequest = new RunWorkflowInstanceRequest { - BookmarkId = bookmark.Id + BookmarkId = bookmark.Id, + Input = input }; response = await workflowClient.RunInstanceAsync(runRequest); } diff --git a/src/common/Elsa.Testing.Shared/Activities/TestContainer.cs b/src/common/Elsa.Testing.Shared/Activities/TestContainer.cs new file mode 100644 index 000000000..b22ae3755 --- /dev/null +++ b/src/common/Elsa.Testing.Shared/Activities/TestContainer.cs @@ -0,0 +1,38 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Testing.Shared.Activities; + +/// +/// Concrete implementation of Container for testing that schedules children sequentially. +/// +public class TestContainer : Container +{ + private const string CurrentIndexProperty = "CurrentIndex"; + + protected override async ValueTask ScheduleChildrenAsync(ActivityExecutionContext context) + { + await HandleItemAsync(context); + } + + private async ValueTask HandleItemAsync(ActivityExecutionContext context) + { + var currentIndex = context.GetProperty(CurrentIndexProperty); + var childActivities = Activities.ToList(); + + if (currentIndex >= childActivities.Count) + { + await context.CompleteActivityAsync(); + return; + } + + var nextActivity = childActivities.ElementAt(currentIndex); + await context.ScheduleActivityAsync(nextActivity, OnChildCompleted); + context.UpdateProperty(CurrentIndexProperty, x => x + 1); + } + + private async ValueTask OnChildCompleted(ActivityCompletedContext context) + { + await HandleItemAsync(context.TargetContext); + } +} diff --git a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs index 3f9d03d9d..b4d221f85 100644 --- a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs +++ b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs @@ -7,35 +7,36 @@ namespace Elsa.Testing.Shared; /// 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) + extension(ActivityExecutionContext activityExecutionContext) { - return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null; - } + /// + /// Determines whether the specified activity is scheduled in the workflow execution context. + /// + /// The activity to check for scheduling. + /// True if the specified activity is scheduled; otherwise, false. + public bool HasScheduledActivity(IActivity activity) + { + return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null; + } - /// - /// Retrieves the collection of outcomes recorded in the execution context's journal data. - /// - /// The activity execution context from which to retrieve the outcomes. - /// A collection of outcome strings, or an empty collection if no outcomes are present. - public static IEnumerable GetOutcomes(this ActivityExecutionContext activityExecutionContext) - { - return activityExecutionContext.JournalData.TryGetValue("Outcomes", out var outcomes) && outcomes is string[] arr ? arr : []; - } + /// + /// Retrieves the collection of outcomes recorded in the execution context's journal data. + /// + /// A collection of outcome strings, or an empty collection if no outcomes are present. + public IEnumerable GetOutcomes() + { + return activityExecutionContext.JournalData.TryGetValue("Outcomes", out var outcomes) && outcomes is string[] arr ? arr : []; + } - /// - /// Determines whether the specified outcome exists in the current activity execution context. - /// - /// The activity execution context to check for the outcome. - /// The outcome to verify within the execution context. - /// True if the specified outcome exists; otherwise, false. - public static bool HasOutcome(this ActivityExecutionContext activityExecutionContext, string outcome) - { - return activityExecutionContext.GetOutcomes().Contains(outcome); + /// + /// Determines whether the specified outcome exists in the current activity execution context. + /// + /// The outcome to verify within the execution context. + /// True if the specified outcome exists; otherwise, false. + public bool HasOutcome(string outcome) + { + return activityExecutionContext.GetOutcomes().Contains(outcome); + } } } \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs index 7a163e36e..bd0399b5e 100644 --- a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs +++ b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs @@ -79,7 +79,17 @@ public class ActivityTestFixture public async Task ExecuteAsync() { var context = await BuildAsync(); - + return await ExecuteAsync(context); + } + + /// + /// Executes the activity using a pre-built . + /// Useful when you need to customize the context before execution, such as setting initial workflow state or overriding correlation IDs. + /// + /// The pre-built context to execute + /// The after execution + public async Task ExecuteAsync(ActivityExecutionContext context) + { // Set up variables and inputs, then execute the activity await SetupExistingVariablesAsync(Activity, context); await context.EvaluateInputPropertiesAsync(); @@ -153,7 +163,6 @@ public class ActivityTestFixture services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); diff --git a/src/common/Elsa.Testing.Shared/XunitLogger.cs b/src/common/Elsa.Testing.Shared/XunitLogger.cs index 08c947157..e02d37fb5 100644 --- a/src/common/Elsa.Testing.Shared/XunitLogger.cs +++ b/src/common/Elsa.Testing.Shared/XunitLogger.cs @@ -3,33 +3,24 @@ using Xunit.Abstractions; namespace Elsa.Testing.Shared; -public class XunitLogger : ILogger +public class XunitLogger(ITestOutputHelper testOutputHelper, string categoryName) : ILogger { - private readonly ITestOutputHelper _testOutputHelper; - private readonly string _categoryName; - - public XunitLogger(ITestOutputHelper testOutputHelper, string categoryName) - { - _testOutputHelper = testOutputHelper; - _categoryName = categoryName; - } - - public IDisposable BeginScope(TState state) => NoopDisposable.Instance; + public IDisposable BeginScope(TState state) where TState : notnull => NoopDisposable.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { - _testOutputHelper.WriteLine($"{_categoryName} [{eventId}] {formatter(state, exception)}"); + testOutputHelper.WriteLine($"{categoryName} [{eventId}] {formatter(state, exception)}"); if (exception != null) - _testOutputHelper.WriteLine(exception.ToString()); + testOutputHelper.WriteLine(exception.ToString()); } private class NoopDisposable : IDisposable { - public static readonly NoopDisposable Instance = new NoopDisposable(); + public static readonly NoopDisposable Instance = new(); public void Dispose() { diff --git a/src/modules/Elsa.Alterations.Core/Models/NewAlterationJob.cs b/src/modules/Elsa.Alterations.Core/Models/NewAlterationJob.cs index 8b994b647..0338c82d6 100644 --- a/src/modules/Elsa.Alterations.Core/Models/NewAlterationJob.cs +++ b/src/modules/Elsa.Alterations.Core/Models/NewAlterationJob.cs @@ -2,6 +2,6 @@ namespace Elsa.Alterations.Core.Models; public class NewAlterationJob { - public string PlanId { get; set; } - public string WorkflowInstanceId { get; set; } + public string PlanId { get; set; } = null!; + public string WorkflowInstanceId { get; set; } = null!; } \ No newline at end of file diff --git a/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Response.cs b/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Response.cs index f888d378f..7738820fa 100644 --- a/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Response.cs +++ b/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Response.cs @@ -1,6 +1,6 @@ namespace Elsa.Alterations.Endpoints.Alterations.Submit; /// -/// The response from the endpoint. +/// The response from the Submit endpoint. /// public record Response(string PlanId); \ No newline at end of file diff --git a/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs b/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs index bfb1a786a..e37ee6074 100644 --- a/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs +++ b/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs @@ -3,7 +3,7 @@ using JetBrains.Annotations; namespace Elsa.Common.DistributedHosting; /// -/// Provides options related to distributed locking, which is used by . +/// Provides options related to distributed locking, which is used by the workflow runtime. /// [UsedImplicitly] public class DistributedLockingOptions diff --git a/src/modules/Elsa.Common/Extensions/StringExtensions.cs b/src/modules/Elsa.Common/Extensions/StringExtensions.cs index d22cb866a..fc1b190b6 100644 --- a/src/modules/Elsa.Common/Extensions/StringExtensions.cs +++ b/src/modules/Elsa.Common/Extensions/StringExtensions.cs @@ -6,4 +6,5 @@ public static class StringExtensions public static string WithDefault(this string? value, string defaultValue) => !string.IsNullOrWhiteSpace(value) ? value : defaultValue; public static string EmptyIfNull(this string? value) => value ?? ""; public static string? NullIfEmpty(this string? value) => value == "" ? null : value; + public static string? NullIfWhiteSpace(this string? value) => string.IsNullOrWhiteSpace(value) ? null : value; } \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ActivityInvocationNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ActivityInvocationNode.cs new file mode 100644 index 000000000..81e80c393 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ActivityInvocationNode.cs @@ -0,0 +1,22 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents an activity invocation. +/// +public class ActivityInvocationNode : StatementNode +{ + /// + /// The activity name (e.g., "WriteLine", "SendEmail"). + /// + public string ActivityName { get; set; } = string.Empty; + + /// + /// The arguments passed to the activity. + /// + public List Arguments { get; set; } = []; + + /// + /// Optional variable to capture the result. + /// + public string? ResultVariable { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ArgumentNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ArgumentNode.cs new file mode 100644 index 000000000..327763e8a --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ArgumentNode.cs @@ -0,0 +1,17 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents an argument passed to an activity. +/// +public class ArgumentNode : AstNode +{ + /// + /// The parameter name (null for positional arguments). + /// + public string? Name { get; set; } + + /// + /// The argument value expression. + /// + public ExpressionNode Value { get; set; } = null!; +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/AstNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/AstNode.cs new file mode 100644 index 000000000..e9f210d61 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/AstNode.cs @@ -0,0 +1,17 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Base class for all AST nodes in ElsaScript. +/// +public abstract class AstNode +{ + /// + /// The line number in the source where this node starts. + /// + public int Line { get; set; } + + /// + /// The column number in the source where this node starts. + /// + public int Column { get; set; } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/BlockNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/BlockNode.cs new file mode 100644 index 000000000..5ca38de9d --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/BlockNode.cs @@ -0,0 +1,12 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a block of statements (maps to Sequence). +/// +public class BlockNode : StatementNode +{ + /// + /// The statements in the block. + /// + public List Statements { get; set; } = []; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ConnectionNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ConnectionNode.cs new file mode 100644 index 000000000..88dcc50c8 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ConnectionNode.cs @@ -0,0 +1,22 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a connection in a flowchart. +/// +public class ConnectionNode : AstNode +{ + /// + /// The source label. + /// + public string Source { get; set; } = string.Empty; + + /// + /// The optional outcome. + /// + public string? Outcome { get; set; } + + /// + /// The target label. + /// + public string Target { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ExpressionNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ExpressionNode.cs new file mode 100644 index 000000000..819e98e0a --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ExpressionNode.cs @@ -0,0 +1,127 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Base class for expression nodes. +/// +public abstract class ExpressionNode : AstNode +{ +} + +/// +/// Represents a literal expression. +/// +public class LiteralNode : ExpressionNode +{ + /// + /// The literal value. + /// + public object? Value { get; set; } +} + +/// +/// Represents an Elsa expression with a specific language. +/// +public class ElsaExpressionNode : ExpressionNode +{ + /// + /// The expression language (e.g., "js", "cs", "py", "liquid"). + /// If null, uses the default language from use statements. + /// + public string? Language { get; set; } + + /// + /// The expression code. + /// + public string Expression { get; set; } = string.Empty; +} + +/// +/// Represents a template string expression. +/// +public class TemplateStringNode : ExpressionNode +{ + /// + /// The parts of the template string (alternating between literals and expressions). + /// + public List Parts { get; set; } = []; +} + +/// +/// Represents a part of a template string. +/// +public class TemplatePartNode : AstNode +{ + /// + /// Whether this is a literal or expression part. + /// + public bool IsExpression { get; set; } + + /// + /// The content (literal text or expression code). + /// + public string Content { get; set; } = string.Empty; +} + +/// +/// Represents an identifier (variable reference). +/// +public class IdentifierNode : ExpressionNode +{ + /// + /// The identifier name. + /// + public string Name { get; set; } = string.Empty; +} + +/// +/// Represents an array literal. +/// +public class ArrayLiteralNode : ExpressionNode +{ + /// + /// The elements of the array. + /// + public List Elements { get; set; } = []; +} + +/// +/// Represents a binary expression (e.g., a + b). +/// +public class BinaryExpressionNode : ExpressionNode +{ + /// + /// The left operand. + /// + public ExpressionNode Left { get; set; } = null!; + + /// + /// The operator. + /// + public string Operator { get; set; } = string.Empty; + + /// + /// The right operand. + /// + public ExpressionNode Right { get; set; } = null!; +} + +/// +/// Represents a unary expression (e.g., ++i, i++). +/// +public class UnaryExpressionNode : ExpressionNode +{ + /// + /// The operator. + /// + public string Operator { get; set; } = string.Empty; + + /// + /// The operand. + /// + public ExpressionNode Operand { get; set; } = null!; + + /// + /// Whether the operator is prefix (true) or postfix (false). + /// + public bool IsPrefix { get; set; } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/FlowchartNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/FlowchartNode.cs new file mode 100644 index 000000000..cfbbc10e0 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/FlowchartNode.cs @@ -0,0 +1,27 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a flowchart definition. +/// +public class FlowchartNode : StatementNode +{ + /// + /// Variable declarations scoped to the flowchart. + /// + public List Variables { get; set; } = []; + + /// + /// The labeled activities (nodes) in the flowchart. + /// + public List Activities { get; set; } = []; + + /// + /// The connections (edges) between activities. + /// + public List Connections { get; set; } = []; + + /// + /// The optional entry point label. + /// + public string? EntryPoint { get; set; } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ForEachNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ForEachNode.cs new file mode 100644 index 000000000..99fe0d6dc --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ForEachNode.cs @@ -0,0 +1,27 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a foreach statement (e.g., foreach (var item in collection)). +/// +public class ForEachNode : StatementNode +{ + /// + /// Whether this foreach loop declares a new variable (var present in header). + /// + public bool DeclaresVariable { get; set; } + + /// + /// The loop variable name. + /// + public string VariableName { get; set; } = null!; + + /// + /// The collection expression to iterate over. + /// + public ExpressionNode Collection { get; set; } = null!; + + /// + /// The body statement. + /// + public StatementNode Body { get; set; } = null!; +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ForNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ForNode.cs new file mode 100644 index 000000000..953920208 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ForNode.cs @@ -0,0 +1,42 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a range-based for statement (e.g., for (var i = 0 to 10 step 1)). +/// +public class ForNode : StatementNode +{ + /// + /// Whether this for loop declares a new variable (var present in header). + /// + public bool DeclaresVariable { get; set; } + + /// + /// The loop variable name. + /// + public string VariableName { get; set; } = null!; + + /// + /// The start value expression. + /// + public ExpressionNode Start { get; set; } = null!; + + /// + /// The end value expression. + /// + public ExpressionNode End { get; set; } = null!; + + /// + /// The step value expression. + /// + public ExpressionNode Step { get; set; } = null!; + + /// + /// Whether the end value is inclusive (through) or exclusive (to). + /// + public bool IsInclusive { get; set; } + + /// + /// The body statement. + /// + public StatementNode Body { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/IfNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/IfNode.cs new file mode 100644 index 000000000..d3fbaf8f7 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/IfNode.cs @@ -0,0 +1,22 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents an if statement. +/// +public class IfNode : StatementNode +{ + /// + /// The condition expression. + /// + public ExpressionNode Condition { get; set; } = null!; + + /// + /// The then branch. + /// + public StatementNode Then { get; set; } = null!; + + /// + /// The optional else branch. + /// + public StatementNode? Else { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/LabeledActivityNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/LabeledActivityNode.cs new file mode 100644 index 000000000..a29b1f36e --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/LabeledActivityNode.cs @@ -0,0 +1,20 @@ +using JetBrains.Annotations; + +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a labeled activity in a flowchart (node declaration). +/// +[UsedImplicitly] +public class LabeledActivityNode : AstNode +{ + /// + /// The label (node name). + /// + public string Label { get; set; } = string.Empty; + + /// + /// The activity statement (can be ActivityInvocationNode, BlockNode, etc.). + /// + public StatementNode Activity { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ListenNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ListenNode.cs new file mode 100644 index 000000000..0d2e355bd --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ListenNode.cs @@ -0,0 +1,12 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a listen statement (for triggers). +/// +public class ListenNode : StatementNode +{ + /// + /// The activity invocation for the trigger. + /// + public ActivityInvocationNode Activity { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/ProgramNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/ProgramNode.cs new file mode 100644 index 000000000..8c0190eea --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/ProgramNode.cs @@ -0,0 +1,19 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents the root of an ElsaScript program, which can contain: +/// - Global use statements +/// - One or more workflow declarations +/// +public class ProgramNode : AstNode +{ + /// + /// Global use statements (imports and expression language settings) that apply to all workflows. + /// + public List GlobalUseStatements { get; set; } = []; + + /// + /// Workflow declarations in this program. + /// + public List Workflows { get; set; } = []; +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/StatementNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/StatementNode.cs new file mode 100644 index 000000000..3f35976d5 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/StatementNode.cs @@ -0,0 +1,8 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Base class for statement nodes. +/// +public abstract class StatementNode : AstNode +{ +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchCaseNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchCaseNode.cs new file mode 100644 index 000000000..ca44d7afc --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchCaseNode.cs @@ -0,0 +1,17 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a switch case. +/// +public class SwitchCaseNode : AstNode +{ + /// + /// The case value. + /// + public ExpressionNode Value { get; set; } = null!; + + /// + /// The case body. + /// + public StatementNode Body { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchNode.cs new file mode 100644 index 000000000..efcf5f831 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchNode.cs @@ -0,0 +1,22 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a switch statement. +/// +public class SwitchNode : StatementNode +{ + /// + /// The switch expression. + /// + public ExpressionNode Expression { get; set; } = null!; + + /// + /// The cases. + /// + public List Cases { get; set; } = []; + + /// + /// The optional default case. + /// + public StatementNode? Default { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/UseNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/UseNode.cs new file mode 100644 index 000000000..517393b85 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/UseNode.cs @@ -0,0 +1,33 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a use statement (import or expression language configuration). +/// +public class UseNode : AstNode +{ + /// + /// The type of use statement (e.g., "expressions", namespace import). + /// + public UseType Type { get; set; } + + /// + /// The value (e.g., "js", "cs", or a namespace). + /// + public string Value { get; set; } = string.Empty; +} + +/// +/// The type of use statement. +/// +public enum UseType +{ + /// + /// Expression language configuration (e.g., "use expressions js;"). + /// + Expressions, + + /// + /// Namespace import (e.g., "use Elsa.Activities.Console;"). + /// + Namespace +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableDeclarationNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableDeclarationNode.cs new file mode 100644 index 000000000..2c81d3e39 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableDeclarationNode.cs @@ -0,0 +1,22 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a variable declaration. +/// +public class VariableDeclarationNode : StatementNode +{ + /// + /// The variable kind (var, let, const). + /// + public VariableKind Kind { get; set; } + + /// + /// The variable name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// The initial value expression. + /// + public ExpressionNode? Value { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableKind.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableKind.cs new file mode 100644 index 000000000..87119334b --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/VariableKind.cs @@ -0,0 +1,10 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// The kind of variable declaration. +/// +public enum VariableKind +{ + Var, + Const +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/WhileNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/WhileNode.cs new file mode 100644 index 000000000..4005a5cd5 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/WhileNode.cs @@ -0,0 +1,17 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a while statement. +/// +public class WhileNode : StatementNode +{ + /// + /// The condition expression. + /// + public ExpressionNode Condition { get; set; } = null!; + + /// + /// The body statement. + /// + public StatementNode Body { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Ast/WorkflowNode.cs b/src/modules/Elsa.Dsl.ElsaScript/Ast/WorkflowNode.cs new file mode 100644 index 000000000..7019d904d --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Ast/WorkflowNode.cs @@ -0,0 +1,29 @@ +namespace Elsa.Dsl.ElsaScript.Ast; + +/// +/// Represents a workflow definition in ElsaScript. +/// +public class WorkflowNode : AstNode +{ + /// + /// The workflow identifier used in the DSL (the string after "workflow" keyword). + /// This also serves as the default DefinitionId if not explicitly specified in metadata. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Workflow metadata properties (DisplayName, Description, DefinitionId, Version, etc.) + /// These are specified in the optional parenthesized argument list after the workflow id. + /// + public Dictionary Metadata { get; set; } = new(); + + /// + /// The use statements (imports and expression language settings) at workflow scope. + /// + public List UseStatements { get; set; } = []; + + /// + /// The body of the workflow (a sequence of statements). + /// + public List Body { get; set; } = []; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Compiler/ElsaScriptCompiler.cs b/src/modules/Elsa.Dsl.ElsaScript/Compiler/ElsaScriptCompiler.cs new file mode 100644 index 000000000..8cb52e32f --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Compiler/ElsaScriptCompiler.cs @@ -0,0 +1,600 @@ +using Elsa.Dsl.ElsaScript.Ast; +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Dsl.ElsaScript.Helpers; +using Elsa.Expressions.Models; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Activities.Flowchart.Models; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; + +namespace Elsa.Dsl.ElsaScript.Compiler; + +/// +/// Compiles an ElsaScript AST into Elsa workflows. +/// +public class ElsaScriptCompiler(IActivityRegistryLookupService activityRegistryLookupService, IElsaScriptParser parser) : IElsaScriptCompiler +{ + private static readonly Dictionary LanguageMappings = new(StringComparer.OrdinalIgnoreCase) + { + ["js"] = "JavaScript", + ["cs"] = "CSharp", + ["py"] = "Python", + ["liquid"] = "Liquid" + }; + + private string _defaultExpressionLanguage = "JavaScript"; + private readonly Dictionary _variables = new(); + + /// + public async Task CompileAsync(string source, CancellationToken cancellationToken = default) + { + var programNode = parser.Parse(source); + return await CompileAsync(programNode, cancellationToken); + } + + /// + public async Task CompileAsync(ProgramNode programNode, CancellationToken cancellationToken = default) + { + // Get the single workflow (enforced by parser) + var workflowNode = programNode.Workflows.First(); + + // Merge global use statements with workflow-level ones + // Create a temporary workflow node with merged use statements + var mergedWorkflowNode = new WorkflowNode + { + Id = workflowNode.Id, + Metadata = workflowNode.Metadata, + UseStatements = [..programNode.GlobalUseStatements, ..workflowNode.UseStatements], + Body = workflowNode.Body + }; + + return await CompileWorkflowNodeAsync(mergedWorkflowNode, cancellationToken); + } + + private async Task CompileWorkflowNodeAsync(WorkflowNode workflowNode, CancellationToken cancellationToken = default) + { + _variables.Clear(); + _defaultExpressionLanguage = "JavaScript"; + + // Process use statements (workflow-level overrides global) + foreach (var useNode in workflowNode.UseStatements) + { + if (useNode.Type == UseType.Expressions) + { + _defaultExpressionLanguage = MapLanguageName(useNode.Value); + } + } + + // Compile body statements + var activities = new List(); + foreach (var statement in workflowNode.Body) + { + var activity = await CompileStatementAsync(statement, cancellationToken); + if (activity != null) + { + activities.Add(activity); + } + } + + // Create the root activity (Sequence containing all statements) + var root = activities.Count == 1 + ? activities[0] + : new Sequence + { + Activities = activities + }; + + // Extract metadata with defaults + var definitionId = GetMetadataValue(workflowNode.Metadata, "DefinitionId") ?? workflowNode.Id; + var displayName = GetMetadataValue(workflowNode.Metadata, "DisplayName") ?? workflowNode.Id; + var description = GetMetadataValue(workflowNode.Metadata, "Description") ?? string.Empty; + var definitionVersionId = GetMetadataValue(workflowNode.Metadata, "DefinitionVersionId") ?? $"{definitionId}-v1"; + var version = GetMetadataValueOrDefault(workflowNode.Metadata, "Version", 1); + var usableAsActivity = GetMetadataValue(workflowNode.Metadata, "UsableAsActivity"); + + // Create the workflow + var workflow = new Workflow + { + Name = displayName, + Identity = new WorkflowIdentity(definitionId, version, definitionVersionId, null), + WorkflowMetadata = new(displayName, description, ToolVersion: new("3.6.0")), + Root = root, + Variables = _variables.Values.ToList(), + Options = new() + { + UsableAsActivity = usableAsActivity + } + }; + + return workflow; + } + + private T? GetMetadataValue(Dictionary metadata, string key) => + metadata.TryGetValue(key, out var value) ? ConvertValue(value, default) : default; + + private T GetMetadataValueOrDefault(Dictionary metadata, string key, T defaultValue) => + metadata.TryGetValue(key, out var value) ? ConvertValue(value, defaultValue) : defaultValue; + + private static T? ConvertValue(object value, T? defaultValue) + { + // Handle direct type match + if (value is T typedValue) + return typedValue; + + // Try to convert + try + { + var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + return (T)Convert.ChangeType(value, targetType); + } + catch (Exception) + { + // Return default value if conversion fails + return defaultValue; + } + } + + private async Task CompileStatementAsync(StatementNode statement, CancellationToken cancellationToken = default) + { + return statement switch + { + VariableDeclarationNode varDecl => CompileVariableDeclaration(varDecl), + ActivityInvocationNode actInv => await CompileActivityInvocationAsync(actInv, cancellationToken), + BlockNode block => await CompileBlockAsync(block, cancellationToken), + IfNode ifNode => await CompileIfAsync(ifNode, cancellationToken), + ForEachNode forEach => await CompileForEachAsync(forEach, cancellationToken), + ForNode forNode => await CompileForAsync(forNode, cancellationToken), + WhileNode whileNode => await CompileWhileAsync(whileNode, cancellationToken), + SwitchNode switchNode => await CompileSwitchAsync(switchNode, cancellationToken), + FlowchartNode flowchart => await CompileFlowchartAsync(flowchart, cancellationToken), + ListenNode listen => await CompileListenAsync(listen, cancellationToken), + _ => throw new NotSupportedException($"Statement type {statement.GetType().Name} is not supported") + }; + } + + private IActivity? CompileVariableDeclaration(VariableDeclarationNode varDecl) + { + // Create and register the variable + var initialValue = varDecl.Value != null ? EvaluateConstantExpression(varDecl.Value) : null; + var variable = new Variable(varDecl.Name, initialValue); + _variables[varDecl.Name] = variable; + + // Variable declarations don't produce activities themselves + return null; + } + + private async Task CompileActivityInvocationAsync(ActivityInvocationNode actInv, CancellationToken cancellationToken = default) + { + // Try to find the activity type by name - try several strategies + var activityDescriptor = await activityRegistryLookupService.FindAsync(actInv.ActivityName); + + // If not found, try with "Elsa." prefix + if (activityDescriptor == null) + { + activityDescriptor = await activityRegistryLookupService.FindAsync($"Elsa.{actInv.ActivityName}"); + } + + // If still not found, search by descriptor name + if (activityDescriptor == null) + { + activityDescriptor = await activityRegistryLookupService.FindAsync(d => d.Name == actInv.ActivityName); + } + + if (activityDescriptor == null) + { + throw new InvalidOperationException($"Activity '{actInv.ActivityName}' not found in registry"); + } + + var activityType = activityDescriptor.ClrType; + + // Separate named and positional arguments + var namedArgs = actInv.Arguments.Where(a => a.Name != null).ToList(); + var positionalArgs = actInv.Arguments.Where(a => a.Name == null).ToList(); + + IActivity activity; + + // If we have positional arguments, try to find a matching constructor + if (positionalArgs.Any()) + { + activity = InstantiateActivityUsingConstructor(activityType, positionalArgs); + } + else + { + // No positional arguments, use default constructor + var activityConstructorContext = new ActivityConstructorContext(activityDescriptor, ActivityActivator.Create); + activity = activityDescriptor.Constructor(activityConstructorContext); + } + + // Set named argument properties + foreach (var arg in namedArgs) + { + var property = activityType.GetProperty(arg.Name!); + + if (property != null) + { + var value = CompileExpression(arg.Value, property.PropertyType); + property.SetValue(activity, value); + } + else + { + throw new InvalidOperationException($"Property '{arg.Name}' not found on activity type '{activityType.Name}'"); + } + } + + return activity; + } + + private async Task CompileBlockAsync(BlockNode block, CancellationToken cancellationToken = default) + { + var activities = new List(); + + foreach (var statement in block.Statements) + { + var activity = await CompileStatementAsync(statement, cancellationToken); + if (activity != null) + { + activities.Add(activity); + } + } + + return new Sequence + { + Activities = activities + }; + } + + private async Task CompileIfAsync(IfNode ifNode, CancellationToken cancellationToken = default) + { + var condition = CompileExpressionAsInput(ifNode.Condition); + var thenActivity = await CompileStatementAsync(ifNode.Then, cancellationToken); + var elseActivity = ifNode.Else != null ? await CompileStatementAsync(ifNode.Else, cancellationToken) : null; + + return new If(condition) + { + Then = thenActivity, + Else = elseActivity + }; + } + + private async Task CompileForEachAsync(ForEachNode forEach, CancellationToken cancellationToken = default) + { + Variable loopVariable; + + if (forEach.DeclaresVariable) + { + // Create a new loop variable + loopVariable = new Variable(forEach.VariableName, null!); + _variables[forEach.VariableName] = loopVariable; + } + else + { + // Reuse existing variable + if (!_variables.TryGetValue(forEach.VariableName, out loopVariable!)) + { + throw new InvalidOperationException($"Variable '{forEach.VariableName}' is not declared. Use 'var {forEach.VariableName}' to declare a new variable in the foreach loop."); + } + } + + var items = CompileExpressionAsInput>(forEach.Collection); + var body = await CompileStatementAsync(forEach.Body, cancellationToken); + + var forEachActivity = new ForEach(items) + { + CurrentValue = new(loopVariable), + Body = body + }; + + return forEachActivity; + } + + private async Task CompileForAsync(ForNode forNode, CancellationToken cancellationToken = default) + { + Variable loopVariable; + + if (forNode.DeclaresVariable) + { + // Create a new loop variable + loopVariable = new Variable(forNode.VariableName, 0); + _variables[forNode.VariableName] = loopVariable; + } + else + { + // Reuse existing variable + if (!_variables.TryGetValue(forNode.VariableName, out loopVariable!)) + { + throw new InvalidOperationException($"Variable '{forNode.VariableName}' is not declared. Use 'var {forNode.VariableName}' to declare a new variable in the for loop."); + } + } + + var start = CompileExpressionAsInput(forNode.Start); + var end = CompileExpressionAsInput(forNode.End); + var step = CompileExpressionAsInput(forNode.Step); + var body = await CompileStatementAsync(forNode.Body, cancellationToken); + + var forActivity = new For + { + Start = start, + End = end, + Step = step, + OuterBoundInclusive = new Input(forNode.IsInclusive), + CurrentValue = new Output(loopVariable), + Body = body + }; + + return forActivity; + } + + private async Task CompileWhileAsync(WhileNode whileNode, CancellationToken cancellationToken = default) + { + var condition = CompileExpressionAsInput(whileNode.Condition); + var body = await CompileStatementAsync(whileNode.Body, cancellationToken); + + return new While(condition) + { + Body = body + }; + } + + private async Task CompileSwitchAsync(SwitchNode switchNode, CancellationToken cancellationToken = default) + { + var cases = new List(); + + foreach (var caseNode in switchNode.Cases) + { + var caseExpression = CompileExpressionAsExpression(caseNode.Value); + var caseBody = await CompileStatementAsync(caseNode.Body, cancellationToken); + cases.Add(new("Case", caseExpression, caseBody!)); + } + + var defaultActivity = switchNode.Default != null ? await CompileStatementAsync(switchNode.Default, cancellationToken) : null; + + return new Switch + { + Cases = cases, + Default = defaultActivity + }; + } + + private async Task CompileFlowchartAsync(FlowchartNode flowchart, CancellationToken cancellationToken = default) + { + // Register flowchart-scoped variables + foreach (var varDecl in flowchart.Variables) + { + CompileVariableDeclaration(varDecl); + } + + // Compile all labeled activities and build a label-to-activity map + var labelToActivity = new Dictionary(); + foreach (var labeledNode in flowchart.Activities) + { + var activity = await CompileStatementAsync(labeledNode.Activity, cancellationToken); + if (activity != null) + { + labelToActivity[labeledNode.Label] = activity; + } + } + + // Create connections + var connections = new List(); + foreach (var connNode in flowchart.Connections) + { + if (!labelToActivity.TryGetValue(connNode.Source, out var sourceActivity)) + throw new InvalidOperationException($"Source label '{connNode.Source}' not found in flowchart"); + + if (!labelToActivity.TryGetValue(connNode.Target, out var targetActivity)) + throw new InvalidOperationException($"Target label '{connNode.Target}' not found in flowchart"); + + var source = new Endpoint(sourceActivity, connNode.Outcome); + var target = new Endpoint(targetActivity); + connections.Add(new Connection(source, target)); + } + + // Create flowchart activity + var flowchartActivity = new Workflows.Activities.Flowchart.Activities.Flowchart + { + Activities = labelToActivity.Values.ToList(), + Connections = connections + }; + + // Set entry point if specified + if (!string.IsNullOrEmpty(flowchart.EntryPoint)) + { + if (!labelToActivity.TryGetValue(flowchart.EntryPoint, out var startActivity)) + throw new InvalidOperationException($"Entry point label '{flowchart.EntryPoint}' not found in flowchart"); + + flowchartActivity.Start = startActivity; + } + + return flowchartActivity; + } + + private async Task CompileListenAsync(ListenNode listen, CancellationToken cancellationToken = default) + { + // Listen is just a regular activity invocation that can start a workflow + var activity = await CompileActivityInvocationAsync(listen.Activity, cancellationToken); + + // Try to set CanStartWorkflow if the activity supports it + var canStartWorkflowProp = activity.GetType().GetProperty("CanStartWorkflow"); + if (canStartWorkflowProp != null && canStartWorkflowProp.PropertyType == typeof(bool)) + { + canStartWorkflowProp.SetValue(activity, true); + } + + return activity; + } + + private Input CompileExpressionAsInput(ExpressionNode exprNode) + { + if (exprNode is LiteralNode literal) + { + return new(new Literal(literal.Value!)); + } + + if (exprNode is IdentifierNode identifier) + { + // Reference to a variable + if (_variables.TryGetValue(identifier.Name, out var variable)) + { + return new(variable); + } + + // If not found, treat as a literal + return new(new Literal(default!)); + } + + if (exprNode is ElsaExpressionNode elsaExpr) + { + var language = elsaExpr.Language != null ? MapLanguageName(elsaExpr.Language) : _defaultExpressionLanguage; + var expression = new Expression(language, elsaExpr.Expression); + return new(expression); + } + + if (exprNode is ArrayLiteralNode arrayLiteral) + { + // For array literals, evaluate to a constant array if all elements are literals + var elements = arrayLiteral.Elements.Select(EvaluateConstantExpression).ToArray(); + return new((T)(object)elements); + } + + throw new NotSupportedException($"Expression type {exprNode.GetType().Name} is not supported"); + } + + private Expression CompileExpressionAsExpression(ExpressionNode exprNode) + { + if (exprNode is LiteralNode literal) + { + return Expression.LiteralExpression(literal.Value); + } + + if (exprNode is ElsaExpressionNode elsaExpr) + { + var language = elsaExpr.Language != null ? MapLanguageName(elsaExpr.Language) : _defaultExpressionLanguage; + return new(language, elsaExpr.Expression); + } + + throw new NotSupportedException($"Expression type {exprNode.GetType().Name} is not supported as Expression"); + } + + private object CompileExpression(ExpressionNode exprNode, Type targetType) + { + // Check if targetType is already Input + Type innerType; + if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(Input<>)) + { + // Extract the T from Input + innerType = targetType.GetGenericArguments()[0]; + } + else + { + innerType = targetType; + } + + // Use reflection to call CompileExpressionAsInput + var method = GetType().GetMethod(nameof(CompileExpressionAsInput), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var genericMethod = method!.MakeGenericMethod(innerType); + + return genericMethod.Invoke(this, [exprNode])!; + } + + private object? EvaluateConstantExpression(ExpressionNode exprNode) + { + if (exprNode is LiteralNode literal) + { + return literal.Value; + } + + if (exprNode is ArrayLiteralNode arrayLiteral) + { + return arrayLiteral.Elements.Select(EvaluateConstantExpression).ToArray(); + } + + // For non-constant expressions, return null + return null; + } + + private IActivity InstantiateActivityUsingConstructor(Type activityType, List positionalArgs) + { + // Get all public constructors + var constructors = activityType.GetConstructors(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + + // Filter constructors that: + // 1. Have the same number of required Input parameters as positional arguments (excluding optional params) + // 2. All non-optional parameters are Input types + var matchingConstructors = new List<(System.Reflection.ConstructorInfo ctor, System.Reflection.ParameterInfo[] inputParams)>(); + + foreach (var ctor in constructors) + { + var parameters = ctor.GetParameters(); + + // Filter to only Input parameters that are not optional (don't have default values or CallerMemberName attributes) + var inputParams = parameters.Where(p => + p.ParameterType.IsGenericType && + p.ParameterType.GetGenericTypeDefinition() == typeof(Input<>) && + !p.IsOptional && + !p.GetCustomAttributes(typeof(System.Runtime.CompilerServices.CallerFilePathAttribute), false).Any() && + !p.GetCustomAttributes(typeof(System.Runtime.CompilerServices.CallerLineNumberAttribute), false).Any() && + !p.GetCustomAttributes(typeof(System.Runtime.CompilerServices.CallerMemberNameAttribute), false).Any() + ).ToArray(); + + // Check if the number of required Input params matches our positional args + if (inputParams.Length == positionalArgs.Count) + { + matchingConstructors.Add((ctor, inputParams)); + } + } + + if (!matchingConstructors.Any()) + { + throw new InvalidOperationException( + $"No matching constructor found for activity type '{activityType.Name}' with {positionalArgs.Count} positional argument(s). " + + $"Constructors must have Input parameters matching the number of positional arguments."); + } + + if (matchingConstructors.Count > 1) + { + throw new InvalidOperationException( + $"Multiple matching constructors found for activity type '{activityType.Name}' with {positionalArgs.Count} positional argument(s). " + + $"Please use named arguments to disambiguate."); + } + + var (selectedCtor, selectedInputParams) = matchingConstructors[0]; + + // Build the constructor arguments + var ctorArgs = new List(); + var allParams = selectedCtor.GetParameters(); + + foreach (var param in allParams) + { + // Check if this is one of our Input parameters + var inputParamIndex = Array.IndexOf(selectedInputParams, param); + + if (inputParamIndex >= 0) + { + // This is an Input parameter - compile the corresponding positional argument + var arg = positionalArgs[inputParamIndex]; + var value = CompileExpression(arg.Value, param.ParameterType); + ctorArgs.Add(value); + } + else if (param.IsOptional) + { + // This is an optional parameter (like CallerFilePath) - use its default value + ctorArgs.Add(param.DefaultValue); + } + else + { + // This shouldn't happen if our filtering is correct + throw new InvalidOperationException( + $"Unexpected non-optional, non-Input parameter '{param.Name}' in constructor for '{activityType.Name}'"); + } + } + + // Instantiate the activity using the constructor + var activity = (IActivity)selectedCtor.Invoke(ctorArgs.ToArray()); + return activity; + } + + private static string MapLanguageName(string dslLanguage) => + LanguageMappings.TryGetValue(dslLanguage, out var mapped) ? mapped : dslLanguage; +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptCompiler.cs b/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptCompiler.cs new file mode 100644 index 000000000..46b4f2165 --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptCompiler.cs @@ -0,0 +1,26 @@ +using Elsa.Dsl.ElsaScript.Ast; +using Elsa.Workflows.Activities; + +namespace Elsa.Dsl.ElsaScript.Contracts; + +/// +/// Compiles an ElsaScript AST into Elsa workflows. +/// +public interface IElsaScriptCompiler +{ + /// + /// Parses and compiles the given ElsaScript source code into a workflow. + /// + /// The ElsaScript source code. + /// The cancellation token. + /// The compiled workflow. + Task CompileAsync(string source, CancellationToken cancellationToken = default); + + /// + /// Compiles the given program AST into a workflow. + /// + /// The program AST. + /// The cancellation token. + /// The compiled workflow. + Task CompileAsync(ProgramNode programNode, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptParser.cs b/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptParser.cs new file mode 100644 index 000000000..8b349fbee --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Contracts/IElsaScriptParser.cs @@ -0,0 +1,16 @@ +using Elsa.Dsl.ElsaScript.Ast; + +namespace Elsa.Dsl.ElsaScript.Contracts; + +/// +/// Parses ElsaScript source code into an AST. +/// +public interface IElsaScriptParser +{ + /// + /// Parses the given source code into a program AST that can contain multiple workflows. + /// + /// The ElsaScript source code. + /// The parsed program AST containing all workflows. + ProgramNode Parse(string source); +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Elsa.Dsl.ElsaScript.csproj b/src/modules/Elsa.Dsl.ElsaScript/Elsa.Dsl.ElsaScript.csproj new file mode 100644 index 000000000..61d6aae9d --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Elsa.Dsl.ElsaScript.csproj @@ -0,0 +1,26 @@ + + + + Elsa.Dsl.ElsaScript + + + + + Provides a JavaScript-inspired textual DSL (ElsaScript) for authoring Elsa 3 workflows. + + elsa module dsl elsascript workflows + + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.Dsl.ElsaScript/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Dsl.ElsaScript/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..53e9e435d --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Extensions/ModuleExtensions.cs @@ -0,0 +1,19 @@ +using Elsa.Dsl.ElsaScript.Features; +using Elsa.Features.Services; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Extension methods for . +/// +public static class ModuleExtensions +{ + /// + /// Adds the ElsaScript DSL feature. + /// + public static IModule UseElsaScript(this IModule module, Action? configure = null) + { + return module.Use(configure); + } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Features/ElsaScriptFeature.cs b/src/modules/Elsa.Dsl.ElsaScript/Features/ElsaScriptFeature.cs new file mode 100644 index 000000000..969f4fe5e --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Features/ElsaScriptFeature.cs @@ -0,0 +1,35 @@ +using Elsa.Dsl.ElsaScript.Compiler; +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Dsl.ElsaScript.Materializers; +using Elsa.Dsl.ElsaScript.Parser; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.Workflows.Management; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Dsl.ElsaScript.Features; + +/// +/// Feature for ElsaScript DSL support. +/// +public class ElsaScriptFeature : FeatureBase +{ + /// + public ElsaScriptFeature(IModule module) : base(module) + { + } + + /// + public override void Configure() + { + // No additional configuration needed + } + + /// + public override void Apply() + { + Services.AddSingleton(); + Services.AddScoped(); + Services.AddScoped(); + } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/FodyWeavers.xml b/src/modules/Elsa.Dsl.ElsaScript/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Helpers/ActivityActivator.cs b/src/modules/Elsa.Dsl.ElsaScript/Helpers/ActivityActivator.cs new file mode 100644 index 000000000..1e793718d --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Helpers/ActivityActivator.cs @@ -0,0 +1,43 @@ +using Elsa.Workflows; + +namespace Elsa.Dsl.ElsaScript.Helpers; + +internal static class ActivityActivator +{ + public static IActivity Create(Type type) + { + // Try parameterless ctor first (fast path) + var parameterless = type.GetConstructor(Type.EmptyTypes); + if (parameterless != null) + return (IActivity)parameterless.Invoke([]); + + // Fall back: pick a public ctor and provide default values for args + var ctor = type + .GetConstructors() + .OrderBy(c => c.GetParameters().Length) // shortest first + .FirstOrDefault() + ?? throw new InvalidOperationException( + $"Type {type} has no public constructors and cannot be instantiated."); + + var parameters = ctor.GetParameters(); + var args = new object?[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var p = parameters[i]; + + if (p.HasDefaultValue) + { + args[i] = p.DefaultValue; + } + else + { + args[i] = p.ParameterType.IsValueType + ? Activator.CreateInstance(p.ParameterType) + : null; + } + } + + return (IActivity)ctor.Invoke(args); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Dsl.ElsaScript/Materializers/ElsaScriptWorkflowMaterializer.cs b/src/modules/Elsa.Dsl.ElsaScript/Materializers/ElsaScriptWorkflowMaterializer.cs new file mode 100644 index 000000000..fab65c9cd --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Materializers/ElsaScriptWorkflowMaterializer.cs @@ -0,0 +1,37 @@ +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; + +namespace Elsa.Dsl.ElsaScript.Materializers; + +/// +/// Materializes a from a 's ElsaScript source code. +/// +public class ElsaScriptWorkflowMaterializer(IElsaScriptCompiler compiler) : IWorkflowMaterializer +{ + /// + /// The name of the materializer. + /// + public const string MaterializerName = "ElsaScript"; + + /// + public string Name => MaterializerName; + + /// + public async ValueTask MaterializeAsync(WorkflowDefinition definition, CancellationToken cancellationToken) + { + var source = definition.OriginalSource ?? string.Empty; + var workflow = await compiler.CompileAsync(source, cancellationToken); + + // Preserve the workflow definition ID and version + workflow.Identity = new( + definition.DefinitionId, + definition.Version, + definition.Id, + definition.TenantId + ); + + return workflow; + } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/Parser/ElsaScriptParser.cs b/src/modules/Elsa.Dsl.ElsaScript/Parser/ElsaScriptParser.cs new file mode 100644 index 000000000..76bf394af --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/Parser/ElsaScriptParser.cs @@ -0,0 +1,630 @@ +using Elsa.Dsl.ElsaScript.Ast; +using Elsa.Dsl.ElsaScript.Contracts; +using Parlot; +using Parlot.Fluent; + +namespace Elsa.Dsl.ElsaScript.Parser; + +/// +/// ElsaScript parser using Parlot for robust parsing. +/// +public class ElsaScriptParser : IElsaScriptParser +{ + private static readonly Parser ProgramParser; + + static ElsaScriptParser() + { + // Keywords + var useKeyword = Terms.Text("use"); + var workflowKeyword = Terms.Text("workflow"); + var expressionsKeyword = Terms.Text("expressions"); + var listenKeyword = Terms.Text("listen"); + var varKeyword = Terms.Text("var"); + var constKeyword = Terms.Text("const"); + var forKeyword = Terms.Text("for"); + var foreachKeyword = Terms.Text("foreach"); + var inKeyword = Terms.Text("in"); + var toKeyword = Terms.Text("to"); + var throughKeyword = Terms.Text("through"); + var stepKeyword = Terms.Text("step"); + var flowchartKeyword = Terms.Text("flowchart"); + var entryKeyword = Terms.Text("entry"); + + // Basic tokens + var identifier = Terms.Identifier(); + var stringLiteral = Terms.String(); + var integerLiteral = Terms.Integer(); + var decimalLiteral = Terms.Decimal(); + + // Punctuation + var semicolon = Terms.Char(';'); + var comma = Terms.Char(','); + var colon = Terms.Char(':'); + var leftParen = Terms.Char('('); + var rightParen = Terms.Char(')'); + var leftBrace = Terms.Char('{'); + var rightBrace = Terms.Char('}'); + var leftBracket = Terms.Char('['); + var rightBracket = Terms.Char(']'); + var dot = Terms.Char('.'); + var arrow = Terms.Text("=>"); + var rightArrow = Terms.Text("->"); + var equals = Terms.Char('='); + + // Deferred parsers for recursive structures + var expression = Deferred(); + var statement = Deferred(); + + // Expression parsers + var booleanLiteral = Terms.Text("true").Or(Terms.Text("false")) + .Then(x => new LiteralNode { Value = x.ToString() == "true" }); + + var numberLiteral = decimalLiteral + .Then(x => new LiteralNode { Value = x }); + + var intLiteral = integerLiteral + .Then(x => new LiteralNode { Value = (long)x }); + + var stringExpr = stringLiteral + .Then(x => new LiteralNode { Value = x.ToString() }); + + var identifierExpr = identifier + .Then(x => new IdentifierNode { Name = x.ToString() }); + + // Array literal: [expr, expr, ...] + var commaSeparatedExpression = expression.And(ZeroOrOne(comma)).Then(x => x.Item1); + var arrayLiteral = Between(leftBracket, ZeroOrMany(commaSeparatedExpression), rightBracket) + .Then(elements => new ArrayLiteralNode { Elements = elements.ToList() }); + + // Elsa expression: lang => + // We need to capture raw text after => up to the closing parenthesis + // This supports nested parentheses by counting depth + // Use a custom scanner-based parser wrapped in RawExpressionParser + var rawExpressionText = new RawExpressionParser(); + + var elsaExpressionWithLang = identifier + .And(arrow) + .And(rawExpressionText) + .Then(x => new ElsaExpressionNode + { + Language = x.Item1.ToString(), + Expression = x.Item3.ToString().Trim() + }); + + var elsaExpressionWithoutLang = arrow + .And(rawExpressionText) + .Then(x => new ElsaExpressionNode + { + Language = null, + Expression = x.Item2.ToString().Trim() + }); + + var elsaExpression = elsaExpressionWithLang.Or(elsaExpressionWithoutLang); + + // Expression priority: try most specific first + expression.Parser = elsaExpression + .Or(arrayLiteral) + .Or(booleanLiteral) + .Or(numberLiteral) + .Or(intLiteral) + .Or(stringExpr) + .Or(identifierExpr); + + // Argument parser: name: value or just value + var namedArgument = identifier + .And(colon) + .And(expression) + .Then(x => new ArgumentNode { Name = x.Item1.ToString(), Value = x.Item3 }); + + var positionalArgument = expression + .Then(x => new ArgumentNode { Value = x }); + + var argument = namedArgument.Or(positionalArgument); + + var commaSeparatedArgument = argument.And(ZeroOrOne(comma)).Then(x => x.Item1); + var arguments = ZeroOrMany(commaSeparatedArgument); + + // Activity invocation: ActivityName(args) - with or without arguments + var activityInvocationWithArgs = identifier + .And(leftParen) + .And(arguments) + .And(rightParen) + .Then(x => new ActivityInvocationNode + { + ActivityName = x.Item1.ToString(), + Arguments = x.Item3.ToList() + }); + + var activityInvocationNoArgs = identifier + .And(leftParen) + .And(rightParen) + .Then(x => new ActivityInvocationNode + { + ActivityName = x.Item1.ToString(), + Arguments = [] + }); + + var activityInvocation = activityInvocationWithArgs.Or(activityInvocationNoArgs); + + // Variable declaration: var/const name = expr + var variableKindParser = varKeyword.Or(constKeyword); + + var variableDeclaration = variableKindParser + .And(identifier) + .And(equals) + .And(expression) + .Then(x => + { + // x is a flat tuple (kind, identifier, equals, expression) + var kind = x.Item1.ToString() switch + { + "var" => VariableKind.Var, + "const" => VariableKind.Const, + _ => VariableKind.Var + }; + + return new VariableDeclarationNode + { + Kind = kind, + Name = x.Item2.ToString(), + Value = x.Item4 + }; + }); + + // Listen statement: listen ActivityName(args) + var listenStatement = listenKeyword + .And(activityInvocation) + .Then(x => new ListenNode { Activity = x.Item2 }); + + // Statement: variable declaration, listen, or activity invocation + var activityStatement = activityInvocation + .Then(x => x); + + // Declare deferred for loop, foreach, and flowchart parsers + var forStatement = Deferred(); + var foreachStatement = Deferred(); + var flowchartStatement = Deferred(); + + statement.Parser = variableDeclaration + .Or(listenStatement) + .Or(forStatement) + .Or(foreachStatement) + .Or(flowchartStatement) + .Or(activityStatement); + + // Statement with optional semicolon + var statementWithSemicolon = statement.And(ZeroOrOne(semicolon)).Then(x => x.Item1); + + // For loop statement: for (var i = 0 to 10 step 1) { body } or for (i = 0 to 10) statement + // Must be defined after statementWithSemicolon + var rangeOperator = toKeyword.Or(throughKeyword); + + // For body can be either a block or a single statement + var forBlockBody = Between(leftBrace, ZeroOrMany(statementWithSemicolon), rightBrace) + .Then(statements => (StatementNode)(statements.Count == 1 + ? statements.First() + : new BlockNode { Statements = statements.ToList() })); + var forSingleStatementBody = statement; + var forBody = forBlockBody.Or(forSingleStatementBody); + + // For header with optional var: (var i = start to/through end step stepValue) + // or (i = start to/through end step stepValue) + // Step clause is optional + var optionalVarKeyword = ZeroOrOne(varKeyword); + var optionalStepClause = ZeroOrOne(stepKeyword.And(expression).Then(x => x.Item2)); + + var forHeader = Between(leftParen, + optionalVarKeyword + .And(identifier) + .And(equals) + .And(expression) + .And(rangeOperator) + .And(expression) + .And(optionalStepClause) + .Then(x => ( + HasVar: x.Item1 != null, + VarName: x.Item2.ToString(), + Start: x.Item4, + RangeOp: x.Item5.ToString(), + End: x.Item6, + Step: x.Item7 + )), + rightParen); + + var forStatementParser = forKeyword + .And(forHeader) + .And(forBody) + .Then(result => + { + var header = result.Item2; + var body = result.Item3; + + // Default step to 1 if not specified + var stepExpr = header.Step ?? new LiteralNode { Value = 1 }; + + return new ForNode + { + DeclaresVariable = header.HasVar, + VariableName = header.VarName, + Start = header.Start, + End = header.End, + Step = stepExpr, + IsInclusive = header.RangeOp == "through", + Body = body + }; + }); + + forStatement.Parser = forStatementParser; + + // ForEach statement: foreach (var item in collection) { body } or foreach (item in collection) statement + // Must be defined after statementWithSemicolon + // ForEach body can be either a block or a single statement + var foreachBlockBody = Between(leftBrace, ZeroOrMany(statementWithSemicolon), rightBrace) + .Then(statements => (StatementNode)(statements.Count == 1 + ? statements.First() + : new BlockNode { Statements = statements.ToList() })); + var foreachSingleStatementBody = statement; + var foreachBody = foreachBlockBody.Or(foreachSingleStatementBody); + + // ForEach header with optional var: (var item in collection) or (item in collection) + var foreachOptionalVarKeyword = ZeroOrOne(varKeyword); + + var foreachHeader = Between(leftParen, + foreachOptionalVarKeyword + .And(identifier) + .And(inKeyword) + .And(expression) + .Then(x => ( + HasVar: x.Item1 != null, + VarName: x.Item2.ToString(), + Collection: x.Item4 + )), + rightParen); + + var foreachStatementParser = foreachKeyword + .And(foreachHeader) + .And(foreachBody) + .Then(result => + { + var header = result.Item2; + var body = result.Item3; + + return new ForEachNode + { + DeclaresVariable = header.HasVar, + VariableName = header.VarName, + Collection = header.Collection, + Body = body + }; + }); + + foreachStatement.Parser = foreachStatementParser; + + // Flowchart statement: flowchart { [variables] [nodes] [connections] [entry] } + // Node declaration: label: statement; + // Entry declaration: entry label; + // Connection declaration: source -> target; or source.Outcome -> target; + + // Flowchart body element can be: + // 1. Variable declaration + // 2. Node declaration (label: statement) + // 3. Entry declaration (entry label) + // 4. Connection declaration (source -> target or source.Outcome -> target) + + // Node declaration: label: activityInvocation; or label: { block } + // Note: We use activityInvocation directly (not statement) to avoid circular dependency + // since statement includes flowchart which would include node declarations + var nodeBlock = Between(leftBrace, ZeroOrMany(statementWithSemicolon), rightBrace) + .Then(statements => statements.Count == 1 + ? statements.First() + : new BlockNode { Statements = statements.ToList() }); + + var nodeActivityStatement = activityInvocation.Then(s => s); + + var nodeDeclaration = identifier + .And(colon) + .And(nodeBlock.Or(nodeActivityStatement)) + .And(ZeroOrOne(semicolon)) + .Then(x => new LabeledActivityNode + { + Label = x.Item1.ToString(), + Activity = x.Item3 + }); + + // Entry declaration: entry label; + var entryDeclaration = entryKeyword + .And(identifier) + .And(ZeroOrOne(semicolon)) + .Then(x => x.Item2.ToString()); + + // Connection declaration: source -> target; or source.Outcome -> target; + // Source can be: identifier or identifier.identifier (with outcome) + var optionalOutcome = ZeroOrOne(dot.And(identifier).Then(x => x.Item2.ToString())); + + var connectionSource = identifier + .And(optionalOutcome) + .Then(x => ( + SourceLabel: x.Item1.ToString(), + Outcome: x.Item2 + )); + + var connectionTarget = identifier; + + var connectionDeclaration = connectionSource + .And(rightArrow) + .And(connectionTarget) + .And(ZeroOrOne(semicolon)) + .Then(x => new ConnectionNode + { + Source = x.Item1.SourceLabel, + Outcome = x.Item1.Outcome, + Target = x.Item3.ToString() + }); + + // Flowchart body element type - try each parser in order + var flowchartBodyElement = variableDeclaration.Then(v => v) + .Or(entryDeclaration.Then(e => e)) + .Or(nodeDeclaration.Then(n => n)) + .Or(connectionDeclaration.Then(c => c)); + + var flowchartBody = Between(leftBrace, ZeroOrMany(flowchartBodyElement), rightBrace); + + var flowchartStatementParser = flowchartKeyword + .And(flowchartBody) + .Then(result => + { + var bodyElements = result.Item2; + + var variables = new List(); + var nodes = new List(); + var connections = new List(); + string? entryPoint = null; + + foreach (var element in bodyElements) + { + if (element is VariableDeclarationNode varDecl) + variables.Add(varDecl); + else if (element is LabeledActivityNode node) + nodes.Add(node); + else if (element is ConnectionNode conn) + connections.Add(conn); + else if (element is string entry) + entryPoint = entry; + } + + return new FlowchartNode + { + Variables = variables, + Activities = nodes, + Connections = connections, + EntryPoint = entryPoint + }; + }); + + flowchartStatement.Parser = flowchartStatementParser; + + // Use statement: use Namespace; or use expressions lang; + var namespaceUse = identifier + .And(ZeroOrMany(dot.And(identifier))) + .Then(x => + { + var ns = x.Item1.ToString(); + foreach (var part in x.Item2) + { + ns += "." + part.Item2.ToString(); + } + return new UseNode { Type = UseType.Namespace, Value = ns }; + }); + + var expressionUse = expressionsKeyword + .And(identifier) + .Then(x => new UseNode { Type = UseType.Expressions, Value = x.Item2.ToString() }); + + var useStatement = useKeyword + .And(expressionUse.Or(namespaceUse)) + .And(ZeroOrOne(semicolon)) + .Then(x => x.Item2); + + // Workflow metadata: name: value + var metadataEntry = identifier + .And(colon) + .And(expression) + .Then(x => (Name: x.Item1.ToString(), Value: EvaluateConstantExpressionStatic(x.Item3))); + + var commaSeparatedMetadata = metadataEntry.And(ZeroOrOne(comma)).Then(x => x.Item1); + var metadataList = ZeroOrMany(commaSeparatedMetadata); + + // Workflow declaration: workflow Identifier [(metadata)] { [use statements] [statements] } + var workflowMetadata = Between(leftParen, metadataList, rightParen); + + // Workflow body can contain use statements and regular statements + var workflowUseStatement = useStatement; + + var workflowBodyElement = Deferred(); + workflowBodyElement.Parser = workflowUseStatement + .Then(u => u) + .Or(statementWithSemicolon.Then(s => s)); + + var workflowBody = Between(leftBrace, ZeroOrMany(workflowBodyElement), rightBrace); + + var workflowWithMetadata = workflowKeyword + .And(identifier) + .And(workflowMetadata) + .Then(x => (WorkflowId: x.Item2.ToString(), Metadata: x.Item3)); + + var workflowWithoutMetadata = workflowKeyword + .And(identifier) + .Then(x => (WorkflowId: x.Item2.ToString(), Metadata: (IReadOnlyList<(string Name, object Value)>?)null)); + + var workflowHeader = workflowWithMetadata.Or(workflowWithoutMetadata); + + var workflowDeclaration = workflowHeader + .And(workflowBody) + .Then(x => + { + var header = x.Item1; + var bodyElements = x.Item2; + + var metadataDict = new Dictionary(); + if (header.Metadata != null) + { + foreach (var entry in header.Metadata) + { + metadataDict[entry.Name] = entry.Value; + } + } + + // Separate use statements from regular statements in body + var workflowUses = new List(); + var statements = new List(); + + foreach (var element in bodyElements) + { + if (element is UseNode useNode) + workflowUses.Add(useNode); + else if (element is StatementNode stmt) + statements.Add(stmt); + } + + return new WorkflowNode + { + Id = header.WorkflowId, + Metadata = metadataDict, + UseStatements = workflowUses, + Body = statements + }; + }); + + // Program with single workflow: [global use statements] [workflow declaration] + var programWithWorkflow = ZeroOrMany(useStatement) + .And(workflowDeclaration) + .Then(x => + { + var globalUses = x.Item1.Select(u => (UseNode)u).ToList(); + var workflow = x.Item2; + + return new ProgramNode + { + GlobalUseStatements = globalUses, + Workflows = new List { workflow } + }; + }); + + // Fallback: raw statements without workflow keyword (backward compatibility) + // Only match if there are actual statements (OneOrMany) + var programWithStatements = ZeroOrMany(useStatement) + .And(OneOrMany(statementWithSemicolon)) + .Then(x => + { + var globalUses = x.Item1.Select(u => (UseNode)u).ToList(); + var statements = x.Item2.ToList(); + + return new ProgramNode + { + GlobalUseStatements = globalUses, + Workflows = new List + { + new WorkflowNode + { + Id = "DefaultWorkflow", + UseStatements = new List(), + Body = statements + } + } + }; + }); + + var programParser = programWithWorkflow.Or(programWithStatements); + + ProgramParser = programParser; + } + + /// + public ProgramNode Parse(string source) + { + if (!ProgramParser.TryParse(source, out var result, out var error)) + { + var errorMessage = error != null + ? $"{error.Message} at {error.Position}" + : "Unknown parse error"; + throw new ParseException($"Failed to parse ElsaScript: {errorMessage}"); + } + return result; + } + + /// + /// Static helper to evaluate constant expressions during parsing. + /// + private static object EvaluateConstantExpressionStatic(ExpressionNode exprNode) + { + return exprNode switch + { + LiteralNode literal => literal.Value ?? string.Empty, + IdentifierNode identifier => identifier.Name, + _ => string.Empty + }; + } +} + +/// +/// Exception thrown when parsing fails. +/// +public class ParseException : Exception +{ + public ParseException(string message) : base(message) + { + } +} + +/// +/// Custom parser that captures raw text after => until the matching closing parenthesis. +/// Supports nested parentheses. +/// +internal sealed class RawExpressionParser : Parser +{ + public override bool Parse(ParseContext context, ref ParseResult result) + { + context.EnterParser(this); + + var scanner = context.Scanner; + var start = scanner.Cursor.Offset; + var depth = 0; + + while (!scanner.Cursor.Eof) + { + var ch = scanner.Cursor.Current; + + if (ch == '(') + { + depth++; + scanner.Cursor.Advance(); + } + else if (ch == ')') + { + if (depth == 0) + { + // This is the closing paren for the activity invocation + break; + } + depth--; + scanner.Cursor.Advance(); + } + else + { + scanner.Cursor.Advance(); + } + } + + var length = scanner.Cursor.Offset - start; + if (length == 0) + { + context.ExitParser(this); + return false; + } + + var text = new TextSpan(scanner.Buffer, start, length); + result.Set(start, scanner.Cursor.Offset, text); + context.ExitParser(this); + return true; + } +} diff --git a/src/modules/Elsa.Dsl.ElsaScript/README.md b/src/modules/Elsa.Dsl.ElsaScript/README.md new file mode 100644 index 000000000..1e8b6cd6f --- /dev/null +++ b/src/modules/Elsa.Dsl.ElsaScript/README.md @@ -0,0 +1,185 @@ +# ElsaScript DSL Implementation + +This document provides an overview of the ElsaScript DSL implementation for Elsa Workflows. + +## Overview + +ElsaScript is a JavaScript-inspired textual DSL for authoring Elsa 3 workflows. It provides a concise, code-centric alternative to C# WorkflowBuilder APIs and JSON workflow definitions. + +## Features Implemented + +### Parser (`ElsaScriptParser`) + +The parser uses a simplified regex-based approach to parse ElsaScript source code into an Abstract Syntax Tree (AST). + +**Supported Syntax:** +- `use` statements for namespaces and expression language configuration +- `workflow "Name" { ... }` declarations +- Variable declarations: `var`, `let`, `const` +- Activity invocations with positional and named arguments +- Expression literals (strings, numbers, booleans, arrays) +- Expression language prefixes (`=>`, `js =>`, `cs =>`, `py =>`, `liquid =>`) +- `listen` keyword for workflow triggers (partial support) + +### AST Nodes + +Complete set of AST node types: +- `WorkflowNode` - Root workflow definition +- `StatementNode` base class with implementations: + - `VariableDeclarationNode` + - `ActivityInvocationNode` + - `BlockNode` (for sequences) + - `IfNode`, `ForEachNode`, `ForNode`, `WhileNode`, `SwitchNode` + - `FlowchartNode`, `ListenNode` +- `ExpressionNode` base class with implementations: + - `LiteralNode`, `IdentifierNode`, `ArrayLiteralNode` + - `ElsaExpressionNode` (with language support) + - `TemplateStringNode`, `BinaryExpressionNode`, `UnaryExpressionNode` +- `ArgumentNode` for activity parameters +- `UseNode` for import/configuration statements + +### Compiler (`ElsaScriptCompiler`) + +Compiles AST to Elsa workflow activities: +- Traverses AST and constructs Elsa activity graphs +- Maps DSL constructs to Elsa activities: + - Blocks → `Sequence` + - Variables → `Variable` instances + - Activity invocations → Elsa activity instances + - Control flow nodes → corresponding Elsa activities +- Resolves activities via `IActivityRegistry` +- Binds expressions to Elsa expression system with language support +- Maps expression languages (js, cs, py, liquid) to Elsa providers + +### Dependency Injection + +Integration with Elsa's module system: +- `ElsaScriptFeature` for service registration +- `ModuleExtensions.UseElsaScript()` for easy setup +- Registers `IElsaScriptParser` and `IElsaScriptCompiler` services + +## Example Usage + +```elsa +use Elsa.Activities.Console; +use expressions js; + +workflow "HelloWorld" { + var greeting = "Hello"; + WriteLine(=> greeting + " World"); + WriteLine("Great to meet you!"); +} +``` + +## Integration Tests + +Six integration tests demonstrate the implementation: + +1. **Parser can parse a simple workflow definition** - Verifies basic workflow parsing +2. **Parser can parse variable declarations** - Tests var/let/const parsing +3. **Parser can parse activity invocations with named arguments** - Tests argument parsing +4. **Compiler service is registered and available** - Verifies DI setup +5. **Compiler can parse and analyze a workflow AST** - Tests AST analysis +6. **Compiler recognizes variable declarations in AST** - Tests variable compilation + +## Known Limitations + +This is a foundation implementation with several areas for future enhancement: + +### Parser Limitations +1. Uses regex instead of Parlot for parsing (simpler but less robust) +2. Control flow parsing (if/foreach/while/for/switch) needs completion +3. Block/sequence parsing needs enhancement +4. Flowchart syntax not yet implemented +5. Template string parsing incomplete + +### Compiler Limitations +1. Dynamic activity instantiation needs refinement +2. Control flow compilation stubs need full implementation +3. For loop compilation not implemented +4. Flowchart compilation not implemented +5. Error handling and diagnostics minimal + +### Runtime Limitations +1. End-to-end workflow execution not fully working +2. Activity property setting needs enhancement +3. Complex expression evaluation untested + +## Future Enhancements + +### Short Term +1. Complete parser using Parlot for robust parsing +2. Implement full control flow parsing and compilation +3. Fix dynamic activity instantiation for runtime execution +4. Add comprehensive error messages and diagnostics + +### Medium Term +1. Implement flowchart parsing and compilation +2. Support template strings with interpolation +3. Add output capture syntax (`let result = Activity()`) +4. Implement switch fallthrough options +5. Add try/catch/finally support + +### Long Term +1. IDE support (syntax highlighting, IntelliSense) +2. Debugging support +3. Performance optimizations +4. Extended DSL features (custom operators, macros, etc.) + +## Testing Strategy + +Current tests focus on: +- Parser correctness for basic constructs +- AST structure validation +- Compiler service availability +- Variable and activity node recognition + +Additional testing needed for: +- Control flow execution +- Expression evaluation +- Error handling +- Edge cases and invalid syntax + +## Architecture Decisions + +### Why Regex-Based Parser? +The simplified regex-based parser was chosen to deliver a working proof-of-concept quickly. While less robust than a full Parlot implementation, it demonstrates the DSL concept and can be replaced with a more sophisticated parser later. + +### Why Separate AST and Compiler? +The separation allows for: +- Multiple compilation targets (if needed) +- AST analysis and transformation +- Better error reporting +- Easier testing of each component + +### Why Reuse Elsa's Expression System? +Rather than creating a new expression language, ElsaScript wraps Elsa's existing expression providers (JavaScript, C#, Python, Liquid). This provides: +- Consistency with existing Elsa workflows +- Proven expression evaluation +- No additional dependencies +- Familiar syntax for Elsa users + +## Files Added + +### Source Files +- `src/modules/Elsa.Dsl.ElsaScript/` + - `Ast/` - AST node definitions + - `Compiler/` - AST to Elsa workflow compiler + - `Contracts/` - Service interfaces + - `Extensions/` - DI extension methods + - `Features/` - Elsa feature module + - `Parser/` - ElsaScript parser + - `Elsa.Dsl.ElsaScript.csproj` - Project file + +### Test Files +- `test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/` + - `ParserTests.cs` - Parser integration tests + - `CompilerTests.cs` - Compiler integration tests + - `Elsa.Dsl.ElsaScript.IntegrationTests.csproj` - Test project + +### Configuration +- `Directory.Packages.props` - Added Parlot package reference + +## Summary + +The ElsaScript DSL provides a solid foundation for text-based workflow authoring in Elsa 3. While this initial implementation has limitations, it demonstrates the core concepts and provides a framework for future enhancements. The modular architecture integrates cleanly with Elsa's existing systems and can be extended incrementally. diff --git a/src/modules/Elsa.Expressions.JavaScript/Elsa.Expressions.JavaScript.csproj b/src/modules/Elsa.Expressions.JavaScript/Elsa.Expressions.JavaScript.csproj index 7bf175399..782df8f86 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Elsa.Expressions.JavaScript.csproj +++ b/src/modules/Elsa.Expressions.JavaScript/Elsa.Expressions.JavaScript.csproj @@ -9,6 +9,10 @@ + + + + diff --git a/src/modules/Elsa.Expressions.JavaScript/Extensions/TypeAliasRegistryExtensions.cs b/src/modules/Elsa.Expressions.JavaScript/Extensions/TypeAliasRegistryExtensions.cs index 0d717c0f0..6491ac9a0 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Extensions/TypeAliasRegistryExtensions.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Extensions/TypeAliasRegistryExtensions.cs @@ -9,8 +9,9 @@ namespace Elsa.Expressions.JavaScript.Extensions; public static class TypeAliasRegistryExtensions { /// - /// Register type with the specified alias. + /// Register type with the specified alias. /// + /// The type to register. public static void RegisterType(this ITypeAliasRegistry registry, string alias) => registry.RegisterType(typeof(T), alias); /// diff --git a/src/modules/Elsa.Expressions.Liquid/Helpers/ConfigurationSectionWrapper.cs b/src/modules/Elsa.Expressions.Liquid/Helpers/ConfigurationSectionWrapper.cs index 894e3d85e..3533f98d3 100644 --- a/src/modules/Elsa.Expressions.Liquid/Helpers/ConfigurationSectionWrapper.cs +++ b/src/modules/Elsa.Expressions.Liquid/Helpers/ConfigurationSectionWrapper.cs @@ -2,16 +2,9 @@ namespace Elsa.Expressions.Liquid.Helpers; -public class ConfigurationSectionWrapper +public class ConfigurationSectionWrapper(IConfigurationSection section) { - private readonly IConfigurationSection _section; + public override string ToString() => section.Value!; - public ConfigurationSectionWrapper(IConfigurationSection section) - { - _section = section; - } - - public override string ToString() => _section.Value; - - public ConfigurationSectionWrapper GetSection(string name) => new(_section.GetSection(name)); + public ConfigurationSectionWrapper GetSection(string name) => new(section.GetSection(name)); } \ No newline at end of file diff --git a/src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs b/src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs index 3cfce6bf3..9f1bef0cd 100644 --- a/src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs +++ b/src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs @@ -1,4 +1,6 @@ +using System.Collections; using System.ComponentModel; +using System.Text.Json; namespace Elsa.Expressions.Helpers; @@ -26,11 +28,41 @@ public static class ObjectFormatter if (underlyingSourceType == typeof(string)) return value as string; - var sourceTypeConverter = TypeDescriptor.GetConverter(underlyingSourceType); + // Byte arrays are base64-encoded for serialization + if (value is byte[] byteArray) + return Convert.ToBase64String(byteArray); - if (sourceTypeConverter.CanConvertTo(typeof(string))) - return (string?)sourceTypeConverter.ConvertTo(value, typeof(string)); + // Memory-like types are preserved via TypeDescriptor + if (IsMemoryLike(sourceType)) + { + var sourceTypeConverter = TypeDescriptor.GetConverter(underlyingSourceType); + if (sourceTypeConverter.CanConvertTo(typeof(string))) + return (string?)sourceTypeConverter.ConvertTo(value, typeof(string)); + + return value.ToString(); + } + + // Serialize arrays and collections to JSON instead of "T[] Array" or "(Collection)". + // Fixes GitHub issue #7019. + if (value is IEnumerable) + { + return JsonSerializer.Serialize(value); + } + + var converter = TypeDescriptor.GetConverter(underlyingSourceType); + + if (converter.CanConvertTo(typeof(string))) + return (string?)converter.ConvertTo(value, typeof(string)); return value.ToString(); } + + private static bool IsMemoryLike(Type type) + { + if (!type.IsGenericType) + return false; + + var genericTypeDef = type.GetGenericTypeDefinition(); + return genericTypeDef == typeof(Memory<>) || genericTypeDef == typeof(ReadOnlyMemory<>); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs index 6b6590aac..7b550d6fb 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs @@ -29,6 +29,19 @@ public class HttpEndpoint : Trigger public HttpEndpoint([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) { } + + /// + public HttpEndpoint(Input path, Input method, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + Path = path; + SupportedMethods = new(ObjectLiteral.From(new[] { method })); + } + + /// + public HttpEndpoint(Input path, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + Path = path; + } /// /// The path to associate with the workflow. @@ -461,7 +474,7 @@ public class HttpEndpoint : Trigger var contentType = httpRequest.ContentType!; var headers = httpRequest.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray()); - return await context.ParseContentAsync(contentStream, contentType, targetType, headers, cancellationToken); + return await context.ParseContentAsync(contentStream, contentType, targetType, headers!, cancellationToken); } private static bool HasContent(HttpRequest httpRequest) => httpRequest.Headers.ContentLength > 0; diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs index 34e90abc7..bb8930ab5 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs @@ -240,8 +240,8 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul _ => typeof(string) }; - var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.Cast().ToArray(), StringComparer.OrdinalIgnoreCase); - var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.Cast().ToArray(), StringComparer.OrdinalIgnoreCase); + var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.OrdinalIgnoreCase); + var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.OrdinalIgnoreCase); var headersDictionary = contentHeadersDictionary.Concat(responseHeadersDictionary).ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); return await context.ParseContentAsync(contentStream, contentType, targetType, headersDictionary, cancellationToken); } diff --git a/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs b/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs index 7b21e3353..75277ad67 100644 --- a/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs +++ b/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs @@ -24,6 +24,19 @@ public class WriteHttpResponse : Activity public WriteHttpResponse([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) { } + + /// + public WriteHttpResponse(Input content, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + Content = content; + } + + /// + public WriteHttpResponse(Input content, Input contentType, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + Content = content; + ContentType = contentType; + } /// /// The status code to return. @@ -101,7 +114,7 @@ public class WriteHttpResponse : Activity // Add headers. var headers = context.GetHeaders(ResponseHeaders); foreach (var header in headers) - response.Headers.Add(header.Key, header.Value); + response.Headers[header.Key] = header.Value; // Get content and content type. var content = context.Get(Content); diff --git a/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentFactory.cs b/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentFactory.cs index 5536181ea..ae4fc8539 100644 --- a/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentFactory.cs +++ b/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentFactory.cs @@ -20,7 +20,7 @@ public class FormUrlEncodedHttpContentFactory : IHttpContentFactory return dictionary.ToDictionary(x => x.Key, x => x.Value.ToString() ?? string.Empty); if (content is string or JsonObject) - return JsonSerializer.Deserialize>(JsonSerializer.Serialize(content)); + return JsonSerializer.Deserialize>(JsonSerializer.Serialize(content))!; var jsonElement = JsonSerializer.SerializeToElement(content); return jsonElement.EnumerateObject().ToDictionary(x => x.Name, x => x.Value.ToString()); diff --git a/src/modules/Elsa.Http/Contexts/DownloadableContext.cs b/src/modules/Elsa.Http/Contexts/DownloadableContext.cs index cfe7b7c89..daa889c2d 100644 --- a/src/modules/Elsa.Http/Contexts/DownloadableContext.cs +++ b/src/modules/Elsa.Http/Contexts/DownloadableContext.cs @@ -7,10 +7,10 @@ namespace Elsa.Http.Contexts; /// /// The manager. /// The content to get downloadables from. -/// An optional ETag. +/// The downloadable options. /// The cancellation token. public record DownloadableContext( - IDownloadableManager Manager, - object Content, + IDownloadableManager Manager, + object Content, DownloadableOptions Options, CancellationToken CancellationToken); \ No newline at end of file diff --git a/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs b/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs index 3538ace13..85d45ff5e 100644 --- a/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs @@ -8,7 +8,7 @@ namespace Elsa.Extensions; internal static class HttpActivityExecutionContextExtensions { - public static async Task ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, Dictionary headers, CancellationToken cancellationToken) + public static async Task ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, Dictionary headers, CancellationToken cancellationToken) { var parsers = context.GetServices().OrderByDescending(x => x.Priority).ToList(); var httpResponseParserContext = new HttpResponseParserContext(content, contentType, returnType, headers, cancellationToken); diff --git a/src/modules/Elsa.Http/Handlers/UpdateRouteTable.cs b/src/modules/Elsa.Http/Handlers/UpdateRouteTable.cs index 4d3571006..0ade01163 100644 --- a/src/modules/Elsa.Http/Handlers/UpdateRouteTable.cs +++ b/src/modules/Elsa.Http/Handlers/UpdateRouteTable.cs @@ -1,8 +1,6 @@ -using Elsa.Http.Options; -using Elsa.Mediator.Contracts; +using Elsa.Mediator.Contracts; using Elsa.Workflows.Runtime.Notifications; using JetBrains.Annotations; -using Microsoft.Extensions.Options; namespace Elsa.Http.Handlers; @@ -10,7 +8,7 @@ namespace Elsa.Http.Handlers; /// A handler that updates the route table when workflow triggers and bookmarks are indexed. /// [UsedImplicitly] -public class UpdateRouteTable(IRouteTableUpdater routeTableUpdater, IOptions options) : +public class UpdateRouteTable(IRouteTableUpdater routeTableUpdater) : INotificationHandler, INotificationHandler { diff --git a/src/modules/Elsa.Http/UIHints/HttpContentTypeOptionsProvider.cs b/src/modules/Elsa.Http/UIHints/HttpContentTypeOptionsProvider.cs index fee5eb3ab..24c3f7146 100644 --- a/src/modules/Elsa.Http/UIHints/HttpContentTypeOptionsProvider.cs +++ b/src/modules/Elsa.Http/UIHints/HttpContentTypeOptionsProvider.cs @@ -5,7 +5,7 @@ using Elsa.Workflows.UIHints.Dropdown; namespace Elsa.Http.UIHints; /// -/// Provides options for the activity's property. +/// Provides options for the activity's property. /// public class HttpContentTypeOptionsProvider : DropDownOptionsProviderBase { diff --git a/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs index a0badc6b2..de6762a28 100644 --- a/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs @@ -10,7 +10,7 @@ namespace Elsa.Extensions; public static class ModuleExtensions { /// - /// Installs & configures the feature. + /// Installs and configures the feature. /// public static IModule UseIdentity(this IModule module, Action? configure = null) { @@ -19,7 +19,7 @@ public static class ModuleExtensions } /// - /// Installs & configures the feature. + /// Installs and configures the feature. /// public static IModule UseIdentity(this IModule module, string signingKey, string issuer = "http://elsa.api", string audience = "http://elsa.api", TimeSpan? tokenLifetime = null) { @@ -34,7 +34,7 @@ public static class ModuleExtensions } /// - /// Installs & configures the feature. + /// Installs and configures the feature. /// public static IModule UseDefaultAuthentication(this IModule module, Action? configure = null) { diff --git a/src/modules/Elsa.Persistence.EFCore.Common/Abstractions/DesignTimeDbContextFactoryBase.cs b/src/modules/Elsa.Persistence.EFCore.Common/Abstractions/DesignTimeDbContextFactoryBase.cs index da0ab2a7b..7ea1b7c1d 100644 --- a/src/modules/Elsa.Persistence.EFCore.Common/Abstractions/DesignTimeDbContextFactoryBase.cs +++ b/src/modules/Elsa.Persistence.EFCore.Common/Abstractions/DesignTimeDbContextFactoryBase.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.Parsing; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.DependencyInjection; @@ -16,13 +15,13 @@ public abstract class DesignTimeDbContextFactoryBase : IDesignTimeDb { var builder = new DbContextOptionsBuilder(); var connectionStringOption = new Option("--connectionString", "Specifies the connection string."); - var command = new RootCommand(); + var command = new RootCommand + { + connectionStringOption + }; - command.AddOption(connectionStringOption); - - var parser = new Parser(command); - var parseResult = parser.Parse(args); - var connectionString = parseResult.GetValueForOption(connectionStringOption) ?? "Data Source=local"; + var parseResult = command.Parse(args); + var connectionString = parseResult.GetValue(connectionStringOption) ?? "Data Source=local"; var services = new ServiceCollection(); ConfigureServices(services); diff --git a/src/modules/Elsa.Persistence.EFCore.Common/DbSchemaAwareMigrationAssembly.cs b/src/modules/Elsa.Persistence.EFCore.Common/DbSchemaAwareMigrationAssembly.cs index 5b9b8127a..bdcccf587 100644 --- a/src/modules/Elsa.Persistence.EFCore.Common/DbSchemaAwareMigrationAssembly.cs +++ b/src/modules/Elsa.Persistence.EFCore.Common/DbSchemaAwareMigrationAssembly.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -10,6 +11,7 @@ namespace Elsa.Persistence.EFCore; /// /// Class That enable Schema change for Migration /// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required to customize migration assembly behavior for schema-aware migrations.")] public class DbSchemaAwareMigrationAssembly( ICurrentDbContext currentContext, IDbContextOptions options, diff --git a/src/modules/Elsa.Persistence.EFCore.Common/Elsa.Persistence.EFCore.Common.csproj b/src/modules/Elsa.Persistence.EFCore.Common/Elsa.Persistence.EFCore.Common.csproj index f6c2ff912..4306e5c1f 100644 --- a/src/modules/Elsa.Persistence.EFCore.Common/Elsa.Persistence.EFCore.Common.csproj +++ b/src/modules/Elsa.Persistence.EFCore.Common/Elsa.Persistence.EFCore.Common.csproj @@ -5,6 +5,7 @@ Provides common Entity Framework Core types for implementing EF Core persistence of varipus ELsa modules. elsa extension module persistence efcore + Elsa.Persistence.EFCore diff --git a/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs b/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs index 9f24898ea..fadf509a7 100644 --- a/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs +++ b/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs @@ -113,7 +113,7 @@ public static class BulkUpsertExtensions var paramName = $"{{{parameterCount++}}}"; // If it's a shadow property, retrieve value via Entry(..).Property(..) - object? value = property.IsShadowProperty() + var value = property.IsShadowProperty() ? dbContext.Entry(entity).Property(property.Name).CurrentValue : property.PropertyInfo?.GetValue(entity); @@ -176,7 +176,7 @@ public static class BulkUpsertExtensions { var paramName = $"{{{parameterCount++}}}"; - object? value = property.IsShadowProperty() + var value = property.IsShadowProperty() ? dbContext.Entry(entity).Property(property.Name).CurrentValue : property.PropertyInfo?.GetValue(entity); @@ -185,7 +185,7 @@ public static class BulkUpsertExtensions value = converter.ConvertToProvider(value); placeholders.Add(paramName); - parameters.Add(value); + parameters.Add(value!); } sb.Append($"({string.Join(", ", placeholders)})"); @@ -238,7 +238,7 @@ public static class BulkUpsertExtensions { var paramName = $"{{{parameterCount++}}}"; - object? value = property.IsShadowProperty() + var value = property.IsShadowProperty() ? dbContext.Entry(entity).Property(property.Name).CurrentValue : property.PropertyInfo?.GetValue(entity); @@ -255,7 +255,7 @@ public static class BulkUpsertExtensions else placeholders.Add(paramName); - parameters.Add(value); + parameters.Add(value!); } sb.Append($"({string.Join(", ", placeholders)})"); @@ -308,7 +308,7 @@ public static class BulkUpsertExtensions { var paramName = $"{{{parameterCount++}}}"; - object? value = property.IsShadowProperty() + var value = property.IsShadowProperty() ? dbContext.Entry(entity).Property(property.Name).CurrentValue : property.PropertyInfo?.GetValue(entity); @@ -317,7 +317,7 @@ public static class BulkUpsertExtensions value = converter.ConvertToProvider(value); placeholders.Add(paramName); - parameters.Add(value); + parameters.Add(value!); } sb.Append($"({string.Join(", ", placeholders)})"); @@ -374,7 +374,7 @@ public static class BulkUpsertExtensions { var paramName = $"{{{parameterCount++}}}"; - object? value = property.IsShadowProperty() + var value = property.IsShadowProperty() ? dbContext.Entry(entity).Property(property.Name).CurrentValue : property.PropertyInfo?.GetValue(entity); @@ -382,7 +382,7 @@ public static class BulkUpsertExtensions if (converter != null) value = converter.ConvertToProvider(value); - parameters.Add(value); + parameters.Add(value!); // Oracle aliases must match the column name var alias = property.GetColumnName(storeObject); diff --git a/src/modules/Elsa.Persistence.EFCore.Common/JsonValueComparer.cs b/src/modules/Elsa.Persistence.EFCore.Common/JsonValueComparer.cs index 04fd79a67..db9d92238 100644 --- a/src/modules/Elsa.Persistence.EFCore.Common/JsonValueComparer.cs +++ b/src/modules/Elsa.Persistence.EFCore.Common/JsonValueComparer.cs @@ -13,7 +13,10 @@ namespace Elsa.Persistence.EFCore; /// For plain objects, fall back to deep equality comparison using JSON serialization /// (safe, but inefficient). /// -public class JsonValueComparer : ValueComparer { +public class JsonValueComparer() : ValueComparer((t1, t2) => DoEquals(t1!, t2!), + t => DoGetHashCode(t), + t => DoGetSnapshot(t)) +{ private static string Json(T instance) { return JsonSerializer.Serialize(instance); @@ -24,9 +27,7 @@ public class JsonValueComparer : ValueComparer { if (instance is ICloneable cloneable) return (T)cloneable.Clone(); - var result = (T)JsonSerializer.Deserialize(Json(instance)); - return result; - + return JsonSerializer.Deserialize(Json(instance))!; } private static int DoGetHashCode(T instance) { @@ -43,15 +44,6 @@ public class JsonValueComparer : ValueComparer { if (left is IEquatable equatable) return equatable.Equals(right); - var result = Json(left).Equals(Json(right)); - return result; - + return Json(left).Equals(Json(right)); } - - public JsonValueComparer() : base( - (t1, t2) => DoEquals(t1, t2), - t => DoGetHashCode(t), - t => DoGetSnapshot(t)) { - } - } \ No newline at end of file diff --git a/src/modules/Elsa.Persistence.EFCore.Common/JsonValueConverterHelper.cs b/src/modules/Elsa.Persistence.EFCore.Common/JsonValueConverterHelper.cs index d529731bf..6df4e4608 100644 --- a/src/modules/Elsa.Persistence.EFCore.Common/JsonValueConverterHelper.cs +++ b/src/modules/Elsa.Persistence.EFCore.Common/JsonValueConverterHelper.cs @@ -15,7 +15,7 @@ internal static class JsonValueConverterHelper public static string Serialize(T obj) where T : class { - return (obj == null ? null : JsonSerializer.Serialize(obj, JsonSerializerOptions))!; + return (obj == null! ? null : JsonSerializer.Serialize(obj, JsonSerializerOptions))!; } private static JsonSerializerOptions CreateJsonSerializerOptions() diff --git a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.Designer.cs b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.Designer.cs similarity index 97% rename from src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.Designer.cs rename to src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.Designer.cs index 0416ba122..74fea8669 100644 --- a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.Designer.cs +++ b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Elsa.Persistence.EFCore.MySql.Migrations.Management { [DbContext(typeof(ManagementElsaDbContext))] - [Migration("20250711172853_V3_6")] + [Migration("20251116182606_V3_6")] partial class V3_6 { /// @@ -21,7 +21,7 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -69,6 +69,9 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management b.Property("Name") .HasColumnType("varchar(255)"); + b.Property("OriginalSource") + .HasColumnType("longtext"); + b.Property("ProviderName") .HasColumnType("longtext"); diff --git a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.cs similarity index 77% rename from src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.cs rename to src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.cs index 637f31774..52262b272 100644 --- a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20250711172853_V3_6.cs +++ b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/20251116182606_V3_6.cs @@ -18,6 +18,14 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.AddColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + migrationBuilder.AlterColumn( name: "StringData", schema: _schema.Schema, @@ -45,6 +53,11 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management oldNullable: true) .Annotation("MySql:CharSet", "utf8mb4") .OldAnnotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.DropColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions"); } } } diff --git a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index 6c509e7af..e65ac7926 100644 --- a/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Persistence.EFCore.MySql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -66,6 +66,9 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management b.Property("Name") .HasColumnType("varchar(255)"); + b.Property("OriginalSource") + .HasColumnType("longtext"); + b.Property("ProviderName") .HasColumnType("longtext"); diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Configurations/Management.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Configurations/Management.cs index a5a9beecc..3e72af5e7 100644 --- a/src/modules/Elsa.Persistence.EFCore.Oracle/Configurations/Management.cs +++ b/src/modules/Elsa.Persistence.EFCore.Oracle/Configurations/Management.cs @@ -14,6 +14,7 @@ internal class Management : IEntityTypeConfiguration, IEntit builder.Property("Data").HasColumnType("NCLOB"); builder.Property(x => x.Description).HasColumnType("NCLOB"); builder.Property(x => x.MaterializerContext).HasColumnType("NCLOB"); + builder.Property(x => x.OriginalSource).HasColumnType("NCLOB"); builder.Property(x => x.BinaryData).HasColumnType("BLOB"); } diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.cs deleted file mode 100644 index 13bd85f27..000000000 --- a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management -{ - /// - public partial class V3_6 : Migration - { - private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; - - /// - public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) - { - _schema = schema; - } - - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "Data", - schema: _schema.Schema, - table: "WorkflowInstances", - type: "NVARCHAR2(2000)", - nullable: true, - oldClrType: typeof(string), - oldType: "NCLOB", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "StringData", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NVARCHAR2(2000)", - nullable: true, - oldClrType: typeof(string), - oldType: "NCLOB", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "MaterializerContext", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NVARCHAR2(2000)", - nullable: true, - oldClrType: typeof(string), - oldType: "NCLOB", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Description", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NVARCHAR2(2000)", - nullable: true, - oldClrType: typeof(string), - oldType: "NCLOB", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Data", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NVARCHAR2(2000)", - nullable: true, - oldClrType: typeof(string), - oldType: "NCLOB", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "BinaryData", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "RAW(2000)", - nullable: true, - oldClrType: typeof(byte[]), - oldType: "BLOB", - oldNullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "Data", - schema: _schema.Schema, - table: "WorkflowInstances", - type: "NCLOB", - nullable: true, - oldClrType: typeof(string), - oldType: "NVARCHAR2(2000)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "StringData", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NCLOB", - nullable: true, - oldClrType: typeof(string), - oldType: "NVARCHAR2(2000)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "MaterializerContext", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NCLOB", - nullable: true, - oldClrType: typeof(string), - oldType: "NVARCHAR2(2000)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Description", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NCLOB", - nullable: true, - oldClrType: typeof(string), - oldType: "NVARCHAR2(2000)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Data", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "NCLOB", - nullable: true, - oldClrType: typeof(string), - oldType: "NVARCHAR2(2000)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "BinaryData", - schema: _schema.Schema, - table: "WorkflowDefinitions", - type: "BLOB", - nullable: true, - oldClrType: typeof(byte[]), - oldType: "RAW(2000)", - oldNullable: true); - } - } -} diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.Designer.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.Designer.cs similarity index 97% rename from src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.Designer.cs rename to src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.Designer.cs index f558c90e9..f08bd8c0d 100644 --- a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20250711191819_V3_6.Designer.cs +++ b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.Designer.cs @@ -12,7 +12,7 @@ using Oracle.EntityFrameworkCore.Metadata; namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management { [DbContext(typeof(ManagementElsaDbContext))] - [Migration("20250711191819_V3_6")] + [Migration("20251116182825_V3_6")] partial class V3_6 { /// @@ -21,7 +21,7 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 128); OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -69,6 +69,9 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management b.Property("Name") .HasColumnType("NVARCHAR2(450)"); + b.Property("OriginalSource") + .HasColumnType("NVARCHAR2(2000)"); + b.Property("ProviderName") .HasColumnType("NVARCHAR2(2000)"); diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.cs new file mode 100644 index 000000000..11bbc4357 --- /dev/null +++ b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/20251116182825_V3_6.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management +{ + /// + public partial class V3_6 : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "NCLOB", + nullable: true); + + migrationBuilder.AlterColumn( + name: "StringData", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "JSON", + nullable: true, + oldClrType: typeof(string), + oldType: "NCLOB", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "StringData", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "NCLOB", + nullable: true, + oldClrType: typeof(string), + oldType: "JSON", + oldNullable: true); + + migrationBuilder.DropColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions"); + } + } +} diff --git a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index 64491cfbe..56c1d4208 100644 --- a/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Persistence.EFCore.Oracle/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 128); OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -29,20 +29,20 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management .HasColumnType("NVARCHAR2(450)"); b.Property("BinaryData") - .HasColumnType("RAW(2000)"); + .HasColumnType("BLOB"); b.Property("CreatedAt") .HasColumnType("TIMESTAMP(7) WITH TIME ZONE"); b.Property("Data") - .HasColumnType("NVARCHAR2(2000)"); + .HasColumnType("NCLOB"); b.Property("DefinitionId") .IsRequired() .HasColumnType("NVARCHAR2(450)"); b.Property("Description") - .HasColumnType("NVARCHAR2(2000)"); + .HasColumnType("NCLOB"); b.Property("IsLatest") .HasColumnType("BOOLEAN"); @@ -57,7 +57,7 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management .HasColumnType("BOOLEAN"); b.Property("MaterializerContext") - .HasColumnType("NVARCHAR2(2000)"); + .HasColumnType("NCLOB"); b.Property("MaterializerName") .IsRequired() @@ -66,11 +66,14 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management b.Property("Name") .HasColumnType("NVARCHAR2(450)"); + b.Property("OriginalSource") + .HasColumnType("NCLOB"); + b.Property("ProviderName") .HasColumnType("NVARCHAR2(2000)"); b.Property("StringData") - .HasColumnType("NVARCHAR2(2000)"); + .HasColumnType("JSON"); b.Property("TenantId") .HasColumnType("NVARCHAR2(450)"); diff --git a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.Designer.cs b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.Designer.cs similarity index 97% rename from src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.Designer.cs rename to src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.Designer.cs index 4af821b59..8ccb89570 100644 --- a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.Designer.cs +++ b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management { [DbContext(typeof(ManagementElsaDbContext))] - [Migration("20250711092046_V3_6")] + [Migration("20251116182753_V3_6")] partial class V3_6 { /// @@ -21,7 +21,7 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -69,6 +69,9 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management b.Property("Name") .HasColumnType("text"); + b.Property("OriginalSource") + .HasColumnType("text"); + b.Property("ProviderName") .HasColumnType("text"); diff --git a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.cs similarity index 71% rename from src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.cs rename to src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.cs index f69d9b921..31ee795a8 100644 --- a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20250711092046_V3_6.cs +++ b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/20251116182753_V3_6.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -18,6 +18,13 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.AddColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "text", + nullable: true); + var schema = _schema.Schema; migrationBuilder.Sql($@" ALTER TABLE ""{schema}"".""WorkflowDefinitions"" @@ -37,6 +44,11 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management TYPE text USING ""StringData""::text; "); + + migrationBuilder.DropColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions"); } } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index fcbcdd064..0113b768b 100644 --- a/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Persistence.EFCore.PostgreSql/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -66,6 +66,9 @@ namespace Elsa.Persistence.EFCore.PostgreSql.Migrations.Management b.Property("Name") .HasColumnType("text"); + b.Property("OriginalSource") + .HasColumnType("text"); + b.Property("ProviderName") .HasColumnType("text"); diff --git a/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.Designer.cs b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.Designer.cs new file mode 100644 index 000000000..e60151c61 --- /dev/null +++ b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.Designer.cs @@ -0,0 +1,235 @@ +// +using System; +using Elsa.Persistence.EFCore.Modules.Management; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management +{ + [DbContext(typeof(ManagementElsaDbContext))] + [Migration("20251116182628_V3_6")] + partial class V3_6 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Elsa") + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowDefinition", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("BinaryData") + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("DefinitionId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsLatest") + .HasColumnType("bit"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("IsReadonly") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("MaterializerContext") + .HasColumnType("nvarchar(max)"); + + b.Property("MaterializerName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("OriginalSource") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderName") + .HasColumnType("nvarchar(max)"); + + b.Property("StringData") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("nvarchar(450)"); + + b.Property("ToolVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("UsableAsActivity") + .HasColumnType("bit"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IsLatest") + .HasDatabaseName("IX_WorkflowDefinition_IsLatest"); + + b.HasIndex("IsPublished") + .HasDatabaseName("IX_WorkflowDefinition_IsPublished"); + + b.HasIndex("IsSystem") + .HasDatabaseName("IX_WorkflowDefinition_IsSystem"); + + b.HasIndex("Name") + .HasDatabaseName("IX_WorkflowDefinition_Name"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_WorkflowDefinition_TenantId"); + + b.HasIndex("UsableAsActivity") + .HasDatabaseName("IX_WorkflowDefinition_UsableAsActivity"); + + b.HasIndex("Version") + .HasDatabaseName("IX_WorkflowDefinition_Version"); + + b.HasIndex("DefinitionId", "Version") + .IsUnique() + .HasDatabaseName("IX_WorkflowDefinition_DefinitionId_Version"); + + b.ToTable("WorkflowDefinitions", "Elsa"); + }); + + modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowInstance", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Data") + .HasColumnType("nvarchar(max)"); + + b.Property("DataCompressionAlgorithm") + .HasColumnType("nvarchar(max)"); + + b.Property("DefinitionId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("DefinitionVersionId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FinishedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IncidentCount") + .HasColumnType("int"); + + b.Property("IsExecuting") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("ParentWorkflowInstanceId") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("SubStatus") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_WorkflowInstance_CorrelationId"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_WorkflowInstance_CreatedAt"); + + b.HasIndex("DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_DefinitionId"); + + b.HasIndex("FinishedAt") + .HasDatabaseName("IX_WorkflowInstance_FinishedAt"); + + b.HasIndex("IsExecuting") + .HasDatabaseName("IX_WorkflowInstance_IsExecuting"); + + b.HasIndex("IsSystem") + .HasDatabaseName("IX_WorkflowInstance_IsSystem"); + + b.HasIndex("Name") + .HasDatabaseName("IX_WorkflowInstance_Name"); + + b.HasIndex("Status") + .HasDatabaseName("IX_WorkflowInstance_Status"); + + b.HasIndex("SubStatus") + .HasDatabaseName("IX_WorkflowInstance_SubStatus"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_WorkflowInstance_TenantId"); + + b.HasIndex("UpdatedAt") + .HasDatabaseName("IX_WorkflowInstance_UpdatedAt"); + + b.HasIndex("Status", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_Status_DefinitionId"); + + b.HasIndex("Status", "SubStatus") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus"); + + b.HasIndex("SubStatus", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_SubStatus_DefinitionId"); + + b.HasIndex("Status", "SubStatus", "DefinitionId", "Version") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus_DefinitionId_Version"); + + b.ToTable("WorkflowInstances", "Elsa"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.cs new file mode 100644 index 000000000..86c20b02c --- /dev/null +++ b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/20251116182628_V3_6.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management +{ + /// + public partial class V3_6 : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions"); + } + } +} diff --git a/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index 620f5a5c2..2f2d1f6f0 100644 --- a/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Persistence.EFCore.SqlServer/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "8.0.12") + .HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -66,6 +66,9 @@ namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management b.Property("Name") .HasColumnType("nvarchar(450)"); + b.Property("OriginalSource") + .HasColumnType("nvarchar(max)"); + b.Property("ProviderName") .HasColumnType("nvarchar(max)"); diff --git a/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.Designer.cs b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.Designer.cs new file mode 100644 index 000000000..bf802a078 --- /dev/null +++ b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.Designer.cs @@ -0,0 +1,231 @@ +// +using System; +using Elsa.Persistence.EFCore.Modules.Management; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Management +{ + [DbContext(typeof(ManagementElsaDbContext))] + [Migration("20251116182711_V3_6")] + partial class V3_6 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Elsa") + .HasAnnotation("ProductVersion", "9.0.10"); + + modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowDefinition", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BinaryData") + .HasColumnType("BLOB"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DefinitionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsLatest") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("IsReadonly") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("MaterializerContext") + .HasColumnType("TEXT"); + + b.Property("MaterializerName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalSource") + .HasColumnType("TEXT"); + + b.Property("ProviderName") + .HasColumnType("TEXT"); + + b.Property("StringData") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("ToolVersion") + .HasColumnType("TEXT"); + + b.Property("UsableAsActivity") + .HasColumnType("INTEGER"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("IsLatest") + .HasDatabaseName("IX_WorkflowDefinition_IsLatest"); + + b.HasIndex("IsPublished") + .HasDatabaseName("IX_WorkflowDefinition_IsPublished"); + + b.HasIndex("IsSystem") + .HasDatabaseName("IX_WorkflowDefinition_IsSystem"); + + b.HasIndex("Name") + .HasDatabaseName("IX_WorkflowDefinition_Name"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_WorkflowDefinition_TenantId"); + + b.HasIndex("UsableAsActivity") + .HasDatabaseName("IX_WorkflowDefinition_UsableAsActivity"); + + b.HasIndex("Version") + .HasDatabaseName("IX_WorkflowDefinition_Version"); + + b.HasIndex("DefinitionId", "Version") + .IsUnique() + .HasDatabaseName("IX_WorkflowDefinition_DefinitionId_Version"); + + b.ToTable("WorkflowDefinitions", "Elsa"); + }); + + modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowInstance", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DataCompressionAlgorithm") + .HasColumnType("TEXT"); + + b.Property("DefinitionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefinitionVersionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("IncidentCount") + .HasColumnType("INTEGER"); + + b.Property("IsExecuting") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ParentWorkflowInstanceId") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubStatus") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_WorkflowInstance_CorrelationId"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_WorkflowInstance_CreatedAt"); + + b.HasIndex("DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_DefinitionId"); + + b.HasIndex("FinishedAt") + .HasDatabaseName("IX_WorkflowInstance_FinishedAt"); + + b.HasIndex("IsExecuting") + .HasDatabaseName("IX_WorkflowInstance_IsExecuting"); + + b.HasIndex("IsSystem") + .HasDatabaseName("IX_WorkflowInstance_IsSystem"); + + b.HasIndex("Name") + .HasDatabaseName("IX_WorkflowInstance_Name"); + + b.HasIndex("Status") + .HasDatabaseName("IX_WorkflowInstance_Status"); + + b.HasIndex("SubStatus") + .HasDatabaseName("IX_WorkflowInstance_SubStatus"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_WorkflowInstance_TenantId"); + + b.HasIndex("UpdatedAt") + .HasDatabaseName("IX_WorkflowInstance_UpdatedAt"); + + b.HasIndex("Status", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_Status_DefinitionId"); + + b.HasIndex("Status", "SubStatus") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus"); + + b.HasIndex("SubStatus", "DefinitionId") + .HasDatabaseName("IX_WorkflowInstance_SubStatus_DefinitionId"); + + b.HasIndex("Status", "SubStatus", "DefinitionId", "Version") + .HasDatabaseName("IX_WorkflowInstance_Status_SubStatus_DefinitionId_Version"); + + b.ToTable("WorkflowInstances", "Elsa"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.cs b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.cs new file mode 100644 index 000000000..6b2c09cda --- /dev/null +++ b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/20251116182711_V3_6.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Management +{ + /// + public partial class V3_6 : Migration + { + private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema; + + /// + public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema) + { + _schema = schema; + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OriginalSource", + schema: _schema.Schema, + table: "WorkflowDefinitions"); + } + } +} diff --git a/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs index 7578f4ad3..0596b6670 100644 --- a/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.Persistence.EFCore.Sqlite/Migrations/Management/ManagementElsaDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Management #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Elsa") - .HasAnnotation("ProductVersion", "8.0.12"); + .HasAnnotation("ProductVersion", "9.0.10"); modelBuilder.Entity("Elsa.Workflows.Management.Entities.WorkflowDefinition", b => { @@ -62,6 +62,9 @@ namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Management b.Property("Name") .HasColumnType("TEXT"); + b.Property("OriginalSource") + .HasColumnType("TEXT"); + b.Property("ProviderName") .HasColumnType("TEXT"); diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs index 203348715..e805caeaa 100644 --- a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs +++ b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs @@ -221,7 +221,9 @@ public class EFCoreWorkflowDefinitionStore(EntityStore x.IsSystem == true) - : queryable.Where(x => x.IsSystem == false || x.IsSystem == null); +#pragma warning disable CS0472 // The result of the expression is always the same since a value of this type is never equal to 'null' + : queryable.Where(x => x.IsSystem == false || x.IsSystem == null!); +#pragma warning restore CS0472 // The result of the expression is always the same since a value of this type is never equal to 'null' if (filter.IsReadonly != null) queryable = queryable.Where(x => x.IsReadonly == filter.IsReadonly); return queryable; diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Runtime/ActivityExecutionLogStore.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Runtime/ActivityExecutionLogStore.cs index f10ded7fd..c345550c0 100644 --- a/src/modules/Elsa.Persistence.EFCore/Modules/Runtime/ActivityExecutionLogStore.cs +++ b/src/modules/Elsa.Persistence.EFCore/Modules/Runtime/ActivityExecutionLogStore.cs @@ -6,7 +6,6 @@ using Elsa.Common.Codecs; using Elsa.Common.Entities; using Elsa.Extensions; using Elsa.Workflows; -using Elsa.Workflows.Management.Options; using Elsa.Workflows.Runtime; using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Extensions; @@ -15,7 +14,6 @@ using Elsa.Workflows.Runtime.OrderDefinitions; using Elsa.Workflows.State; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; using Open.Linq.AsyncExtensions; namespace Elsa.Persistence.EFCore.Modules.Runtime; @@ -26,10 +24,8 @@ namespace Elsa.Persistence.EFCore.Modules.Runtime; [UsedImplicitly] public class EFCoreActivityExecutionStore( EntityStore store, - ISafeSerializer safeSerializer, IPayloadSerializer payloadSerializer, - ICompressionCodecResolver compressionCodecResolver, - IOptions options) : IActivityExecutionStore + ICompressionCodecResolver compressionCodecResolver) : IActivityExecutionStore { /// public async Task SaveAsync(ActivityExecutionRecord record, CancellationToken cancellationToken = default) => await store.SaveAsync(record, OnSaveAsync, cancellationToken); diff --git a/src/modules/Elsa.Persistence.EFCore/efcore-3.6.sh b/src/modules/Elsa.Persistence.EFCore/efcore-3.6.sh index 460644e89..eeec5351d 100755 --- a/src/modules/Elsa.Persistence.EFCore/efcore-3.6.sh +++ b/src/modules/Elsa.Persistence.EFCore/efcore-3.6.sh @@ -4,8 +4,7 @@ mods=("Management") # Define the list of providers -# providers=("MySql" "SqlServer" "Sqlite" "PostgreSql" "Oracle") -providers=("Oracle") +providers=("MySql" "SqlServer" "Sqlite" "PostgreSql" "Oracle") # Loop through each module for module in "${mods[@]}"; do diff --git a/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs b/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs index b2a279fe5..0311b1a88 100644 --- a/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs +++ b/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs @@ -4,7 +4,7 @@ using Elsa.Workflows.Models; namespace Elsa.Resilience.Modifiers; /// -/// Modifies the of the activity. +/// Modifies the for resilient activities. /// public class ResilientActivityDescriptorModifier : IActivityDescriptorModifier { diff --git a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs index 349e6b4a1..5a58647fc 100644 --- a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs +++ b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs @@ -23,6 +23,7 @@ public class ScheduledCronTask : IScheduledTask, IDisposable private readonly SemaphoreSlim _executionSemaphore = new(1, 1); private bool _executing; private bool _cancellationRequested; + private bool _disposed; /// /// Initializes a new instance of . @@ -107,47 +108,93 @@ public class ScheduledCronTask : IScheduledTask, IDisposable _timer?.Dispose(); _timer = null; - using var scope = _scopeFactory.CreateScope(); - var commandSender = scope.ServiceProvider.GetRequiredService(); + // Early exit if disposed to prevent accessing disposed resources + if (_disposed) + return; - var cancellationToken = _cancellationTokenSource.Token; - - if (!cancellationToken.IsCancellationRequested) + IServiceScope? scope = null; + try { - var acquired = false; - try - { - acquired = await _executionSemaphore.WaitAsync(0, cancellationToken); - if (!acquired) return; - - _executing = true; - await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken); - - if (_cancellationRequested) - { - _cancellationRequested = false; - _cancellationTokenSource.Cancel(); - } - } - catch (Exception e) - { - _logger.LogError(e, "Error executing scheduled task"); - } - finally - { - _executing = false; - if (acquired) - _executionSemaphore.Release(); - } + scope = _scopeFactory.CreateScope(); + } + catch (ObjectDisposedException) + { + // Service provider was disposed, exit gracefully + return; } - if (!cancellationToken.IsCancellationRequested) - Schedule(); + using (scope) + { + var commandSender = scope.ServiceProvider.GetRequiredService(); + var cancellationToken = _cancellationTokenSource.Token; + + if (!cancellationToken.IsCancellationRequested) + { + var acquired = false; + try + { + acquired = await _executionSemaphore.WaitAsync(0, cancellationToken); + if (!acquired) return; + + _executing = true; + await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken); + + if (_cancellationRequested) + { + _cancellationRequested = false; + _cancellationTokenSource.Cancel(); + } + } + catch (ObjectDisposedException) + { + // Semaphore was disposed during execution, exit gracefully + return; + } + catch (Exception e) + { + // Only log if not disposed to avoid logging after test context is disposed + if (!_disposed) + { + try + { + _logger.LogError(e, "Error executing scheduled task"); + } + catch + { + // Ignore logging errors (e.g., when test output is no longer available) + } + } + } + finally + { + _executing = false; + if (acquired) + { + try + { + _executionSemaphore.Release(); + } + catch (ObjectDisposedException) + { + // Semaphore was disposed, ignore + } + } + } + } + + // Check again if disposed before scheduling next execution + if (!cancellationToken.IsCancellationRequested && !_disposed) + Schedule(); + } }; } void IDisposable.Dispose() { + if (_disposed) + return; + + _disposed = true; _timer?.Dispose(); _cancellationTokenSource.Dispose(); _executionSemaphore.Dispose(); diff --git a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs index 3cfd8c464..e82e39979 100644 --- a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs +++ b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs @@ -23,6 +23,7 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable private Timer? _timer; private bool _executing; private bool _cancellationRequested; + private bool _disposed; /// /// Initializes a new instance of . @@ -77,7 +78,13 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable private void SetupTimer(TimeSpan delay) { - if (delay < TimeSpan.Zero) delay = TimeSpan.FromSeconds(1); + // Handle edge cases where delay is zero or negative (e.g., due to clock drift, fast execution, or time alignment) + // Instead of silently returning, use a minimum delay to ensure the timer fires and workflow continues scheduling + if (delay <= TimeSpan.Zero) + { + _logger.LogWarning("Calculated delay is {Delay} which is not positive. Using minimum delay of 1ms to ensure timer fires", delay); + delay = TimeSpan.FromMilliseconds(1); + } _timer = new(delay.TotalMilliseconds) { @@ -86,58 +93,59 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable _timer.Elapsed += async (_, _) => { - try + _timer?.Dispose(); + _timer = null; + + // Check if disposed before proceeding + if (_disposed) return; + + _startAt = _systemClock.UtcNow + _interval; + + using var scope = _scopeFactory.CreateScope(); + var commandSender = scope.ServiceProvider.GetRequiredService(); + + // Check disposed again before accessing CancellationTokenSource + if (_disposed) return; + + var cancellationToken = _cancellationTokenSource.Token; + if (!cancellationToken.IsCancellationRequested) { - - _timer?.Dispose(); - _timer = null; - _startAt = _systemClock.UtcNow + _interval; - - using var scope = _scopeFactory.CreateScope(); - var commandSender = scope.ServiceProvider.GetRequiredService(); - var cancellationToken = _cancellationTokenSource.Token; - if (!cancellationToken.IsCancellationRequested) + var acquired = false; + try { - var acquired = false; - try - { - acquired = await _executionSemaphore.WaitAsync(0, cancellationToken); - if (!acquired) return; - _executing = true; - await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken); + acquired = await _executionSemaphore.WaitAsync(0, cancellationToken); + if (!acquired) return; + _executing = true; + await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken); - if (_cancellationRequested) - { - _cancellationRequested = false; - _cancellationTokenSource.Cancel(); - } - } - catch (Exception e) + if (_cancellationRequested) { - _logger.LogError(e, "Error executing scheduled task"); - } - finally - { - _executing = false; - if (acquired) - _executionSemaphore.Release(); + _cancellationRequested = false; + _cancellationTokenSource.Cancel(); } } + catch (Exception e) + { + _logger.LogError(e, "Error executing scheduled task"); + } + finally + { + _executing = false; + if (acquired && !_disposed) + _executionSemaphore.Release(); + } + } - if (!cancellationToken.IsCancellationRequested) - Schedule(); - } - catch (ObjectDisposedException ex) - { - _logger.LogWarning(ex, "Service Provider was disposed."); - } + if (!cancellationToken.IsCancellationRequested && !_disposed) + Schedule(); }; } void IDisposable.Dispose() { - _cancellationTokenSource.Dispose(); + _disposed = true; _timer?.Dispose(); + _cancellationTokenSource.Dispose(); _executionSemaphore.Dispose(); } } \ No newline at end of file diff --git a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledSpecificInstantTask.cs b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledSpecificInstantTask.cs index 8d10f4e1a..71fa87535 100644 --- a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledSpecificInstantTask.cs +++ b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledSpecificInstantTask.cs @@ -22,6 +22,7 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable private Timer? _timer; private bool _executing; private bool _cancellationRequested; + private bool _disposed; /// /// Initializes a new instance of . @@ -57,8 +58,13 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable var now = _systemClock.UtcNow; var delay = _startAt - now; + // Handle edge cases where delay is zero or negative (e.g., due to clock drift, fast execution, or time alignment) + // Instead of silently returning, use a minimum delay to ensure the timer fires and workflow continues scheduling if (delay <= TimeSpan.Zero) + { + _logger.LogWarning("Calculated delay is {Delay} which is not positive. Using minimum delay of 1ms to ensure timer fires", delay); delay = TimeSpan.FromMilliseconds(1); + } _timer = new(delay.TotalMilliseconds) { @@ -70,8 +76,15 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable _timer?.Dispose(); _timer = null; + // Check if disposed before proceeding + if (_disposed) return; + using var scope = _scopeFactory.CreateScope(); var commandSender = scope.ServiceProvider.GetRequiredService(); + + // Check disposed again before accessing CancellationTokenSource + if (_disposed) return; + var cancellationToken = _cancellationTokenSource.Token; if (!cancellationToken.IsCancellationRequested) { @@ -96,7 +109,7 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable finally { _executing = false; - if (acquired) + if (acquired && !_disposed) _executionSemaphore.Release(); } } @@ -105,8 +118,9 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable void IDisposable.Dispose() { - _cancellationTokenSource.Dispose(); + _disposed = true; _timer?.Dispose(); + _cancellationTokenSource.Dispose(); _executionSemaphore.Dispose(); } } \ No newline at end of file diff --git a/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs b/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs index 32f792418..a3cf9cb1f 100644 --- a/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs +++ b/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; +using Microsoft.Extensions.Logging; using Elsa.Extensions; namespace Elsa.Scheduling.Services; @@ -10,15 +11,24 @@ namespace Elsa.Scheduling.Services; public class LocalScheduler : IScheduler { private readonly IServiceProvider _serviceProvider; - private readonly ConcurrentDictionary _scheduledTasks = new ConcurrentDictionary(); - private readonly ConcurrentDictionary> _scheduledTaskKeys = new ConcurrentDictionary>(); + private readonly ILogger _logger; + private readonly ConcurrentDictionary _scheduledTasks = new(); + private readonly ConcurrentDictionary> _scheduledTaskKeys = new(); + + // Note: Using lock instead of SemaphoreSlim because: + // 1. All critical sections are synchronous (dictionary operations only) + // 2. Methods return ValueTask.CompletedTask (not truly async) + // 3. No await inside critical sections + // 4. lock has zero allocation overhead, perfect for fast synchronous operations + private readonly object _lock = new(); /// /// Initializes a new instance of the class. /// - public LocalScheduler(IServiceProvider serviceProvider) + public LocalScheduler(IServiceProvider serviceProvider, ILogger logger) { _serviceProvider = serviceProvider; + _logger = logger; } /// @@ -33,21 +43,32 @@ public class LocalScheduler : IScheduler var scheduleContext = new ScheduleContext(_serviceProvider, task); var scheduledTask = schedule.Schedule(scheduleContext); - RegisterScheduledTask(name, scheduledTask, keys); + lock (_lock) + { + RegisterScheduledTask(name, scheduledTask, keys); + } + return ValueTask.CompletedTask; } /// public ValueTask ClearScheduleAsync(string name, CancellationToken cancellationToken = default) { - RemoveScheduledTask(name); + lock (_lock) + { + RemoveScheduledTask(name); + } + return ValueTask.CompletedTask; } /// public ValueTask ClearScheduleAsync(IEnumerable keys, CancellationToken cancellationToken = default) { - RemoveScheduledTasks(keys); + lock (_lock) + { + RemoveScheduledTasks(keys); + } return ValueTask.CompletedTask; } @@ -61,9 +82,9 @@ public class LocalScheduler : IScheduler updateValueFactory: (_, existingScheduledTask) => { existingScheduledTask.Cancel(); - var removed = _scheduledTaskKeys.TryRemove(existingScheduledTask, out ICollection? _); + var removed = _scheduledTaskKeys.TryRemove(existingScheduledTask, out var _); if (!removed) - System.Diagnostics.Debug.WriteLine($"[LocalScheduler] Warning: Tried to remove scheduled task keys for an existing scheduled task, but it was not present in _scheduledTaskKeys."); + _logger.LogWarning("Tried to remove scheduled task keys for an existing scheduled task, but it was not present in _scheduledTaskKeys"); return scheduledTask; }); @@ -96,7 +117,7 @@ public class LocalScheduler : IScheduler { var removed = _scheduledTasks.TryRemove(taskKey, out _); if (!removed) - System.Diagnostics.Debug.WriteLine($"[LocalScheduler] Warning: Failed to remove scheduled task with key '{taskKey}' for '{key}' from _scheduledTasks."); + _logger.LogWarning("Failed to remove scheduled task with key '{TaskKey}' for '{Key}' from _scheduledTasks", taskKey, key); } _scheduledTaskKeys.Remove(scheduledTask, out _); diff --git a/src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs b/src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs index 83e845af1..89959face 100644 --- a/src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs +++ b/src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs @@ -1,38 +1,40 @@ using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Exceptions; using Elsa.Workflows.Runtime.Messages; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Elsa.Scheduling.Tasks; /// /// A task that resumes a workflow. /// -public class ResumeWorkflowTask : ITask +public class ResumeWorkflowTask(ScheduleExistingWorkflowInstanceRequest request) : ITask { - private readonly ScheduleExistingWorkflowInstanceRequest _request; - - /// - /// Initializes a new instance of the class. - /// - /// The dispatch workflow instance request. - public ResumeWorkflowTask(ScheduleExistingWorkflowInstanceRequest request) - { - _request = request; - } - /// public async ValueTask ExecuteAsync(TaskExecutionContext context) { var cancellationToken = context.CancellationToken; var workflowRuntime = context.ServiceProvider.GetRequiredService(); - var workflowClient = await workflowRuntime.CreateClientAsync(_request.WorkflowInstanceId, cancellationToken); - var request = new RunWorkflowInstanceRequest + var logger = context.ServiceProvider.GetRequiredService>(); + + try { - Input = _request.Input, - Properties = _request.Properties, - ActivityHandle = _request.ActivityHandle, - BookmarkId = _request.BookmarkId - }; - await workflowClient.RunInstanceAsync(request, cancellationToken); + var workflowClient = await workflowRuntime.CreateClientAsync(request.WorkflowInstanceId, cancellationToken); + var runWorkflowInstanceRequest = new RunWorkflowInstanceRequest + { + Input = request.Input, + Properties = request.Properties, + ActivityHandle = request.ActivityHandle, + BookmarkId = request.BookmarkId + }; + await workflowClient.RunInstanceAsync(runWorkflowInstanceRequest, cancellationToken); + } + catch (WorkflowInstanceNotFoundException ex) + { + logger.LogWarning( + "Scheduled workflow instance {WorkflowInstanceId} no longer exists and was likely deleted. Skipping execution.", + ex.InstanceId); + } } } \ No newline at end of file diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Elsa.WorkflowProviders.BlobStorage.ElsaScript.csproj b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Elsa.WorkflowProviders.BlobStorage.ElsaScript.csproj new file mode 100644 index 000000000..3f1642ca7 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Elsa.WorkflowProviders.BlobStorage.ElsaScript.csproj @@ -0,0 +1,15 @@ + + + + + Provides ElsaScript support for BlobStorage workflows provider. + + elsa extension module elsascript blob storage + + + + + + + + diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Extensions/ModuleExtensions.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..3f8e06846 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Extensions/ModuleExtensions.cs @@ -0,0 +1,19 @@ +using Elsa.Features.Services; +using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Features; + +namespace Elsa.WorkflowProviders.BlobStorage.ElsaScript.Extensions; + +/// +/// Extensions for . +/// +public static class ModuleExtensions +{ + /// + /// Enables ElsaScript support for BlobStorage workflows. + /// + public static IModule UseElsaScriptBlobStorage(this IModule module, Action? configure = null) + { + module.Configure(configure); + return module; + } +} diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Features/ElsaScriptBlobStorageFeature.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Features/ElsaScriptBlobStorageFeature.cs new file mode 100644 index 000000000..5c18cf948 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Features/ElsaScriptBlobStorageFeature.cs @@ -0,0 +1,26 @@ +using Elsa.Dsl.ElsaScript.Compiler; +using Elsa.Dsl.ElsaScript.Features; +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.WorkflowProviders.BlobStorage.Contracts; +using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Handlers; +using Elsa.WorkflowProviders.BlobStorage.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.WorkflowProviders.BlobStorage.ElsaScript.Features; + +/// +/// A feature that enables ElsaScript support for the BlobStorage workflow provider. +/// +[DependsOn(typeof(BlobStorageFeature))] +[DependsOn(typeof(ElsaScriptFeature))] +public class ElsaScriptBlobStorageFeature(IModule module) : FeatureBase(module) +{ + /// + public override void Apply() + { + // Register the ElsaScript format handler + Services.AddScoped(); + } +} diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/FodyWeavers.xml b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Handlers/ElsaScriptBlobWorkflowFormatHandler.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Handlers/ElsaScriptBlobWorkflowFormatHandler.cs new file mode 100644 index 000000000..874733dd6 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript/Handlers/ElsaScriptBlobWorkflowFormatHandler.cs @@ -0,0 +1,60 @@ +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Dsl.ElsaScript.Materializers; +using Elsa.WorkflowProviders.BlobStorage.Contracts; +using Elsa.Workflows.Runtime; +using FluentStorage.Blobs; +using Microsoft.Extensions.Logging; + +namespace Elsa.WorkflowProviders.BlobStorage.ElsaScript.Handlers; + +/// +/// Handles ElsaScript-formatted workflow definitions from blob storage. +/// +public class ElsaScriptBlobWorkflowFormatHandler(IElsaScriptCompiler compiler, ILogger logger) : IBlobWorkflowFormatHandler +{ + /// + public string Name => "ElsaScript"; + + /// + public IEnumerable SupportedExtensions => ["elsa"]; + + /// + public bool CanHandle(Blob blob, string? contentType) + { + // Extension filtering is already handled by the provider via SupportedExtensions. + // Here we can optionally check content type for additional validation. + if (!string.IsNullOrEmpty(contentType) && contentType.Contains("elsa", StringComparison.OrdinalIgnoreCase)) + return true; + + // If no content type is available, assume we can handle it (since extension was already validated) + return true; + } + + /// + public async ValueTask TryParseAsync(Blob blob, string content, CancellationToken cancellationToken = default) + { + try + { + var workflow = await compiler.CompileAsync(content, cancellationToken); + + return new( + workflow, + ProviderName: "FluentStorage", + MaterializerName: ElsaScriptWorkflowMaterializer.MaterializerName, + MaterializerContext: null, + OriginalSource: content // Preserve the original ElsaScript source + ); + } + catch (Exception ex) + { + // Intentionally catching all exceptions here to gracefully handle any failure during workflow parsing. + // This includes ParseException, IOException, compiler errors, or any unexpected exceptions. + // Since this is a format handler for user-provided files, we want to log and skip invalid files + // rather than crashing the workflow loading process. + logger.LogWarning(ex, "Failed to parse ElsaScript workflow from blob '{BlobPath}'. The file will be skipped.", blob.FullPath); + + // Return null to indicate this handler can't process the content + return null; + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Contracts/IBlobWorkflowFormatHandler.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Contracts/IBlobWorkflowFormatHandler.cs new file mode 100644 index 000000000..0feb4dcb9 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Contracts/IBlobWorkflowFormatHandler.cs @@ -0,0 +1,43 @@ +using Elsa.Workflows.Runtime; +using FluentStorage.Blobs; + +namespace Elsa.WorkflowProviders.BlobStorage.Contracts; + +/// +/// Handles parsing of workflow definitions from blob storage in a specific format. +/// +public interface IBlobWorkflowFormatHandler +{ + /// + /// A short name or ID of the handler, for diagnostics/logging. + /// + string Name { get; } + + /// + /// Gets the file extensions that this handler supports (without the leading dot, e.g., "json", "elsa"). + /// This is used to optimize blob storage browsing by filtering files before reading their contents. + /// Return an empty collection if the handler can process any file type. + /// + IEnumerable SupportedExtensions { get; } + + /// + /// Returns true if this handler can interpret the given blob based on metadata. + /// Note: The provider will already have filtered by SupportedExtensions, so this is only + /// needed for additional validation based on content type or other blob properties. + /// + /// The blob to check. + /// The content type from blob metadata, if available. + bool CanHandle(Blob blob, string? contentType); + + /// + /// Attempts to parse the blob content into a MaterializedWorkflow. + /// Returns null if the content is not valid for this handler. + /// + /// The blob being parsed. + /// The text content of the blob. + /// Cancellation token. + ValueTask TryParseAsync( + Blob blob, + string content, + CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs index 8fc6ce1c8..35a923bbe 100644 --- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Features/BlobStorageFeature.cs @@ -3,6 +3,7 @@ using Elsa.Features.Abstractions; using Elsa.Features.Attributes; using Elsa.Features.Services; using Elsa.WorkflowProviders.BlobStorage.Contracts; +using Elsa.WorkflowProviders.BlobStorage.Handlers; using Elsa.WorkflowProviders.BlobStorage.Providers; using Elsa.Workflows.Management.Features; using FluentStorage; @@ -31,6 +32,10 @@ public class BlobStorageFeature : FeatureBase public override void Apply() { Services.AddScoped(sp => new BlobStorageProvider(BlobStorage(sp))); + + // Register the JSON format handler (built-in support) + Services.AddScoped(); + Services.AddWorkflowsProvider(); } diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Handlers/JsonBlobWorkflowFormatHandler.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Handlers/JsonBlobWorkflowFormatHandler.cs new file mode 100644 index 000000000..029cb1837 --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Handlers/JsonBlobWorkflowFormatHandler.cs @@ -0,0 +1,68 @@ +using Elsa.WorkflowProviders.BlobStorage.Contracts; +using Elsa.Workflows; +using Elsa.Workflows.Management.Mappers; +using Elsa.Workflows.Management.Materializers; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.Runtime; +using FluentStorage.Blobs; +using Microsoft.Extensions.Logging; + +namespace Elsa.WorkflowProviders.BlobStorage.Handlers; + +/// +/// Handles JSON-formatted workflow definitions from blob storage. +/// +public class JsonBlobWorkflowFormatHandler( + IActivitySerializer activitySerializer, + WorkflowDefinitionMapper workflowDefinitionMapper, + ILogger logger) : IBlobWorkflowFormatHandler +{ + /// + public string Name => "Json"; + + /// + public IEnumerable SupportedExtensions => ["json"]; + + /// + public bool CanHandle(Blob blob, string? contentType) + { + // Extension filtering is already handled by the provider via SupportedExtensions. + // Here we can optionally check content type for additional validation. + if (!string.IsNullOrEmpty(contentType) && contentType.Contains("json", StringComparison.OrdinalIgnoreCase)) + return true; + + // If no content type is available, assume we can handle it (since extension was already validated) + return true; + } + + /// + public ValueTask TryParseAsync(Blob blob, string content, CancellationToken cancellationToken = default) + { + try + { + var workflowDefinitionModel = activitySerializer.Deserialize(content); + var workflow = workflowDefinitionMapper.Map(workflowDefinitionModel); + + var materialized = new MaterializedWorkflow( + workflow, + ProviderName: "FluentStorage", + MaterializerName: JsonWorkflowMaterializer.MaterializerName, + MaterializerContext: null, + OriginalSource: content // Preserve full JSON for symmetric round-trip + ); + + return new(materialized); + } + catch (Exception ex) + { + // Intentionally catching all exceptions here to gracefully handle any failure during workflow parsing. + // This includes JsonException, IOException, deserialization errors, or any unexpected exceptions. + // Since this is a format handler for user-provided files, we want to log and skip invalid files + // rather than crashing the workflow loading process. + logger.LogWarning(ex, "Failed to parse JSON workflow from blob '{BlobPath}'. The file will be skipped.", blob.FullPath); + + // Return null to indicate this handler can't process the content + return new((MaterializedWorkflow?)null); + } + } +} diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs index e2bae6c26..bfc28b143 100644 --- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs @@ -1,9 +1,4 @@ -using Elsa.Common; -using Elsa.WorkflowProviders.BlobStorage.Contracts; -using Elsa.Workflows; -using Elsa.Workflows.Management.Mappers; -using Elsa.Workflows.Management.Materializers; -using Elsa.Workflows.Management.Models; +using Elsa.WorkflowProviders.BlobStorage.Contracts; using Elsa.Workflows.Runtime; using FluentStorage.Blobs; using JetBrains.Annotations; @@ -17,29 +12,17 @@ namespace Elsa.WorkflowProviders.BlobStorage.Providers; public class BlobStorageWorkflowsProvider : IWorkflowsProvider { private readonly IBlobStorageProvider _blobStorageProvider; - private readonly IActivitySerializer _activitySerializer; - private readonly ISystemClock _systemClock; - private readonly IHasher _hasher; - private readonly WorkflowDefinitionMapper _workflowDefinitionMapper; - private readonly VariableDefinitionMapper _variableDefinitionMapper; + private readonly IEnumerable _handlers; /// /// Initializes a new instance of the class. /// public BlobStorageWorkflowsProvider( IBlobStorageProvider blobStorageProvider, - IActivitySerializer activitySerializer, - ISystemClock systemClock, - IHasher hasher, - WorkflowDefinitionMapper workflowDefinitionMapper, - VariableDefinitionMapper variableDefinitionMapper) + IEnumerable handlers) { _blobStorageProvider = blobStorageProvider; - _activitySerializer = activitySerializer; - _systemClock = systemClock; - _hasher = hasher; - _workflowDefinitionMapper = workflowDefinitionMapper; - _variableDefinitionMapper = variableDefinitionMapper; + _handlers = handlers; } /// @@ -48,10 +31,26 @@ public class BlobStorageWorkflowsProvider : IWorkflowsProvider /// public async ValueTask> GetWorkflowsAsync(CancellationToken cancellationToken = default) { + // Aggregate supported extensions from all handlers + var supportedExtensions = _handlers + .SelectMany(h => h.SupportedExtensions) + .Where(ext => !string.IsNullOrEmpty(ext)) + .Select(ext => ext.ToLowerInvariant()) + .ToHashSet(); + var options = new ListOptions { Recurse = true, - BrowseFilter = x => x.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || x.Name.EndsWith(".elsa", StringComparison.OrdinalIgnoreCase) + BrowseFilter = blob => + { + // If no handlers declare extensions, accept all files + if (supportedExtensions.Count == 0) + return true; + + // Only accept files with supported extensions + var extension = Path.GetExtension(blob.Name).TrimStart('.').ToLowerInvariant(); + return supportedExtensions.Contains(extension); + } }; var blobStorage = _blobStorageProvider.GetBlobStorage(); @@ -60,30 +59,32 @@ public class BlobStorageWorkflowsProvider : IWorkflowsProvider foreach (var blob in blobs) { - var result = await ReadWorkflowAsync(blob, cancellationToken); - results.Add(result); + var workflow = await TryReadWorkflowAsync(blob, cancellationToken); + if (workflow != null) + results.Add(workflow); } return results; } - private async Task ReadWorkflowAsync(Blob blob, CancellationToken cancellationToken) + private async Task TryReadWorkflowAsync(Blob blob, CancellationToken cancellationToken) { var blobStorage = _blobStorageProvider.GetBlobStorage(); - var fileExtension = blob.FullPath.Split('.').Last(); - var data = await blobStorage.ReadTextAsync(blob.FullPath, cancellationToken: cancellationToken); + var content = await blobStorage.ReadTextAsync(blob.FullPath, cancellationToken: cancellationToken); - if (string.Equals("json", fileExtension, StringComparison.OrdinalIgnoreCase)) - return ReadJsonWorkflowDefinition(data); + var contentType = blob.Properties.TryGetValue("ContentType", out var ct) ? ct?.ToString() : null; - throw new NotSupportedException($"The file extension '{fileExtension}' is not supported."); - } + foreach (var handler in _handlers) + { + if (!handler.CanHandle(blob, contentType)) + continue; - private MaterializedWorkflow ReadJsonWorkflowDefinition(string json) - { - var workflowDefinitionModel = _activitySerializer.Deserialize(json); - var workflow = _workflowDefinitionMapper.Map(workflowDefinitionModel); + var result = await handler.TryParseAsync(blob, content, cancellationToken); + if (result != null) + return result; + } - return new (workflow, Name, JsonWorkflowMaterializer.MaterializerName); + // No handler accepted this blob; ignore it + return null; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityDescriptors/Get/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityDescriptors/Get/Models.cs index bad95b669..94e4e61d9 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityDescriptors/Get/Models.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityDescriptors/Get/Models.cs @@ -2,6 +2,6 @@ namespace Elsa.Workflows.Api.Endpoints.ActivityDescriptors.Get; internal class Request { - public string TypeName { get; set; } + public string TypeName { get; set; } = null!; public int? Version { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs index 4f876a7ce..364696e9c 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs @@ -48,13 +48,14 @@ internal class Export : ElsaEndpoint { if (request.DefinitionId != null) await DownloadSingleWorkflowAsync(request.DefinitionId, request.VersionOptions, cancellationToken); - else + else if (request.Ids != null) await DownloadMultipleWorkflowsAsync(request.Ids, cancellationToken); + else await Send.NoContentAsync(cancellationToken); } private async Task DownloadMultipleWorkflowsAsync(ICollection ids, CancellationToken cancellationToken) { - List definitions = (await _store.FindManyAsync(new WorkflowDefinitionFilter + List definitions = (await _store.FindManyAsync(new() { Ids = ids }, cancellationToken)).ToList(); @@ -88,7 +89,7 @@ internal class Export : ElsaEndpoint private async Task DownloadSingleWorkflowAsync(string definitionId, string? versionOptions, CancellationToken cancellationToken) { var parsedVersionOptions = string.IsNullOrEmpty(versionOptions) ? VersionOptions.Latest : VersionOptions.FromString(versionOptions); - WorkflowDefinition? definition = (await _store.FindManyAsync(new WorkflowDefinitionFilter + WorkflowDefinition? definition = (await _store.FindManyAsync(new() { DefinitionId = definitionId, VersionOptions = parsedVersionOptions diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs index b05f39821..f9d152260 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs @@ -1,12 +1,13 @@ using Elsa.Abstractions; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Runtime; using JetBrains.Annotations; namespace Elsa.Workflows.Api.Endpoints.WorkflowInstances.BulkDelete; [PublicAPI] -internal class BulkDelete(IWorkflowInstanceManager store) : ElsaEndpoint +internal class BulkDelete(IWorkflowInstanceStore workflowInstanceStore, IWorkflowInstanceManager workflowInstanceManager, IWorkflowRuntime workflowRuntime) : ElsaEndpoint { public override void Configure() { @@ -19,14 +20,46 @@ internal class BulkDelete(IWorkflowInstanceManager store) : ElsaEndpoint ExecuteAsync(Request request, CancellationToken cancellationToken) { - var filter = new WorkflowInstanceFilter + var baseFilter = new WorkflowInstanceFilter { Ids = request.Ids, DefinitionId = request.WorkflowDefinitionId, DefinitionIds = request.WorkflowDefinitionIds, }; - var count = await store.BulkDeleteAsync(filter, cancellationToken); - return new(count); + // Step 1: Delete running instances individually (requires coordination by the workflow runtime). + var runningFilter = new WorkflowInstanceFilter + { + Ids = baseFilter.Ids, + DefinitionId = baseFilter.DefinitionId, + DefinitionIds = baseFilter.DefinitionIds, + WorkflowStatus = WorkflowStatus.Running + }; + + var runningInstanceIds = await workflowInstanceStore.FindManyIdsAsync(runningFilter, cancellationToken); + var count = 0L; + + foreach (var instanceId in runningInstanceIds) + { + var client = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken); + var deleted = await client.DeleteAsync(cancellationToken); + if (deleted) + count++; + } + + // Step 2: Bulk delete finished instances (no coordination needed). + // Use IWorkflowInstanceManager to ensure related records are also deleted. + var finishedFilter = new WorkflowInstanceFilter + { + Ids = baseFilter.Ids, + DefinitionId = baseFilter.DefinitionId, + DefinitionIds = baseFilter.DefinitionIds, + WorkflowStatus = WorkflowStatus.Finished + }; + + var finishedDeletedCount = await workflowInstanceManager.BulkDeleteAsync(finishedFilter, cancellationToken); + count += finishedDeletedCount; + + return new(count); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs index 075756859..d8bfdb086 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs @@ -1,12 +1,11 @@ using Elsa.Abstractions; -using Elsa.Workflows.Management; -using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Runtime; using JetBrains.Annotations; namespace Elsa.Workflows.Api.Endpoints.WorkflowInstances.Delete; [PublicAPI] -internal class Delete(IWorkflowInstanceManager store) : ElsaEndpoint +internal class Delete(IWorkflowRuntime workflowRuntime) : ElsaEndpoint { public override void Configure() { @@ -16,8 +15,8 @@ internal class Delete(IWorkflowInstanceManager store) : ElsaEndpoint public override async Task HandleAsync(Request request, CancellationToken cancellationToken) { - var filter = new WorkflowInstanceFilter { Id = request.Id }; - var deleted = await store.DeleteAsync(filter, cancellationToken); + var client = await workflowRuntime.CreateClientAsync(request.Id, cancellationToken); + var deleted = await client.DeleteAsync(cancellationToken); if (deleted) await Send.NoContentAsync(cancellationToken); diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs index 718c0ca99..c8ad5401b 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs @@ -151,7 +151,7 @@ internal class List(IWorkflowInstanceStore store) : ElsaEndpoint { - KeySelector = p => p.Name, + KeySelector = p => p.Name!, Direction = direction }; diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Variables/Post/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Variables/Post/Endpoint.cs index 71115061f..ee01d5b34 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Variables/Post/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Variables/Post/Endpoint.cs @@ -36,7 +36,7 @@ internal class List(IWorkflowInstanceVariableManager workflowInstanceVariableMan internal class Request { - public ICollection Variables { get; set; } + public ICollection Variables { get; set; } = null!; } internal record ResolvedVariableModel(string Id, string Name, object? Value); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Models/ActivityPathSegment.cs b/src/modules/Elsa.Workflows.Api/Models/ActivityPathSegment.cs index 551f7b529..2da6a890e 100644 --- a/src/modules/Elsa.Workflows.Api/Models/ActivityPathSegment.cs +++ b/src/modules/Elsa.Workflows.Api/Models/ActivityPathSegment.cs @@ -7,4 +7,5 @@ namespace Elsa.Workflows.Api.Models; /// The ID of the activity. /// The type of the activity. /// The name of the port. +/// The name of the activity. public record ActivityPathSegment(string ActivityNodeId, string ActivityId, string ActivityType, string PortName, string ActivityName); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Requirements/NotReadOnlyRequirement.cs b/src/modules/Elsa.Workflows.Api/Requirements/NotReadOnlyRequirement.cs index 594896485..f11b00195 100644 --- a/src/modules/Elsa.Workflows.Api/Requirements/NotReadOnlyRequirement.cs +++ b/src/modules/Elsa.Workflows.Api/Requirements/NotReadOnlyRequirement.cs @@ -26,18 +26,19 @@ public class NotReadOnlyRequirementHandler : AuthorizationHandler - protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, NotReadOnlyRequirement requirement, NotReadOnlyResource resource) + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, NotReadOnlyRequirement requirement, NotReadOnlyResource resource) { if (_managementOptions.Value.IsReadOnlyMode) { - context.Fail(new AuthorizationFailureReason(this, "Workflow edit is not allowed when the read-only mode is enabled.")); + context.Fail(new(this, "Workflow edit is not allowed when the read-only mode is enabled.")); } if (resource.WorkflowDefinition != null && (resource.WorkflowDefinition.IsReadonly || resource.WorkflowDefinition.IsSystem)) { - context.Fail(new AuthorizationFailureReason(this, "Workflow edit is not allowed for a readonly or system workflow.")); + context.Fail(new(this, "Workflow edit is not allowed for a readonly or system workflow.")); } context.Succeed(requirement); + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs b/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs index 2af9c2334..303b38aac 100644 --- a/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -32,7 +33,7 @@ public class ArgumentJsonConverter : JsonConverter var typeAlias = _wellKnownTypeRegistry.TryGetAlias(typeName, out var alias) ? alias : null; var isArray = typeName.IsArray; var isCollection = typeName.IsCollectionType(); - var elementTypeName = isArray ? typeName.GetElementType() : isCollection ? typeName.GenericTypeArguments[0] : typeName; + var elementTypeName = isArray ? typeName.GetElementType()! : isCollection ? typeName.GenericTypeArguments[0] : typeName; var elementTypeAlias = _wellKnownTypeRegistry.GetAliasOrDefault(elementTypeName); var isAliasedArray = (isArray || isCollection) && typeAlias != null; var finalTypeAlias = isArray || isCollection ? typeAlias ?? elementTypeAlias : elementTypeAlias; @@ -45,6 +46,7 @@ public class ArgumentJsonConverter : JsonConverter } /// + [UnconditionalSuppressMessage("Trimming", "IL2055:Call to MakeGenericType can not be statically analyzed", Justification = "Types are dynamically resolved from workflow definitions and registered in the well-known type registry.")] public override ArgumentDefinition Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var jsonObject = (JsonObject)JsonNode.Parse(ref reader)!; diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs index 03db3b0d1..7e0194259 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs @@ -63,7 +63,6 @@ public partial class Flowchart /// This method determines whether an activity should be scheduled based on visited connections, /// forward traversal rules, and backward connections. /// - /// The flowchart containing the activities. /// The graph representation of the flowchart. /// Tracks activity and connection visits. /// The execution context of the flowchart. diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Tokens.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Tokens.cs index 69777be55..127fe1e0e 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Tokens.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Tokens.cs @@ -14,12 +14,20 @@ public partial class Flowchart var flowContext = ctx.TargetContext; var completedActivity = ctx.ChildContext.Activity; var flowGraph = flowContext.GetFlowGraph(); + var tokens = GetTokenList(flowContext); + + // If the completed activity is a terminal node, complete the flowchart immediately. + if (completedActivity is ITerminalNode) + { + tokens.Clear(); + await flowContext.CompleteActivityAsync(); + return; + } // Emit tokens for active outcomes. var outcomes = (ctx.Result as Outcomes ?? Outcomes.Default).Names; var outboundConnections = flowGraph.GetOutboundConnections(completedActivity); var activeOutboundConnections = outboundConnections.Where(x => outcomes.Contains(x.Source.Port)).Distinct().ToList(); - var tokens = GetTokenList(flowContext); foreach (var connection in activeOutboundConnections) tokens.Add(Token.Create(connection.Source.Activity, connection.Target.Activity, connection.Source.Port)); @@ -37,7 +45,7 @@ public partial class Flowchart switch (mergeMode) { - case MergeMode.Stream: + case MergeMode.Cascade: case MergeMode.Race: if (mergeMode == MergeMode.Race) await flowContext.CancelInboundAncestorsAsync(targetActivity); @@ -73,13 +81,15 @@ public partial class Flowchart break; - case MergeMode.Converge: - // Strict WaitAll for multiple forwards; schedule on arrival for <=1 (e.g., loops). - var inboundConnectionsConverge = flowGraph.GetForwardInboundConnections(targetActivity); + case MergeMode.Merge: + // Wait for tokens from all forward inbound connections. + // Unlike Converge, this ignores backward connections (loops). + // Schedule on arrival for <=1 forward inbound (e.g., loops, sequential). + var inboundConnectionsMerge = flowGraph.GetForwardInboundConnections(targetActivity); - if (inboundConnectionsConverge.Count > 1) + if (inboundConnectionsMerge.Count > 1) { - var hasAllTokens = inboundConnectionsConverge.All(inbound => + var hasAllTokens = inboundConnectionsMerge.All(inbound => tokens.Any(t => t is { Consumed: false, Blocked: false } && t.FromActivityId == inbound.Source.Activity.Id && @@ -98,11 +108,37 @@ public partial class Flowchart break; - case MergeMode.None: + case MergeMode.Converge: + // Strictest mode: Wait for tokens from ALL inbound connections (forward + backward). + // Requires every possible inbound path to execute before proceeding. + var allInboundConnectionsConverge = flowGraph.GetInboundConnections(targetActivity); + + if (allInboundConnectionsConverge.Count > 1) + { + var hasAllTokens = allInboundConnectionsConverge.All(inbound => + tokens.Any(t => + t is { Consumed: false, Blocked: false } && + t.FromActivityId == inbound.Source.Activity.Id && + t.ToActivityId == targetActivity.Id && + t.Outcome == inbound.Source.Port + ) + ); + + if (hasAllTokens) + await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync); + } + else + { + await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync); + } + + break; + + case MergeMode.Stream: default: - // Approximation that proceeds on dead paths. - var inboundConnectionsNone = flowGraph.GetForwardInboundConnections(targetActivity); - var hasUnconsumed = inboundConnectionsNone.Any(inbound => + // Flows freely - approximation that proceeds when upstream completes, ignoring dead paths. + var inboundConnectionsStream = flowGraph.GetForwardInboundConnections(targetActivity); + var hasUnconsumed = inboundConnectionsStream.Any(inbound => tokens.Any(t => !t.Consumed && !t.Blocked && t.ToActivityId == inbound.Source.Activity.Id) ); diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExecutionContextExtensions.cs index 317a54abb..9495a7105 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExecutionContextExtensions.cs @@ -7,100 +7,129 @@ public static class ActivityExecutionContextExtensions { private const string GraphTransientProperty = "FlowGraph"; - public static IActivity? GetStartActivity(this Activities.Flowchart flowchart, string? triggerActivityId) + extension(Activities.Flowchart flowchart) { - var activities = flowchart.Activities; - - // If there's a trigger that triggered this workflow, use that. - var triggerActivity = triggerActivityId != null ? activities.FirstOrDefault(x => x.Id == triggerActivityId) : null; - - if (triggerActivity != null) - return triggerActivity; - - // If an explicit Start activity was provided, use that. - if (flowchart.Start != null) - return flowchart.Start; - - // If there is a Start activity on the flowchart, use that. - var startActivity = activities.FirstOrDefault(x => x is Start); - - if (startActivity != null) - return startActivity; - - // If there's an activity marked as "Can Start Workflow", use that. - var canStartWorkflowActivity = activities.FirstOrDefault(x => x.GetCanStartWorkflow()); - - if (canStartWorkflowActivity != null) - return canStartWorkflowActivity; - - // If there is a single activity that has no inbound connections, use that. - var root = flowchart.GetRootActivity(); - - if (root != null) - return root; - - // If no start activity found, return the first activity. - return activities.FirstOrDefault(); - } - - /// - /// Checks if there is any pending work for the flowchart. - /// - internal static bool HasPendingWork(this ActivityExecutionContext context) - { - var flowchart = (Activities.Flowchart)context.Activity; - var workflowExecutionContext = context.WorkflowExecutionContext; - var activityIds = flowchart.Activities.Select(x => x.Id).ToList(); - var children = context.Children; - var hasRunningActivityInstances = children.Where(x => activityIds.Contains(x.Activity.Id)).Any(x => x.Status == ActivityStatus.Running); - var hasUnconsumedTokens = flowchart.GetTokenList(context).Any(x => x is { Consumed: false, Blocked: false }); - var hasFaulted = context.HasFaultedChildren(); - - var hasPendingWork = workflowExecutionContext.Scheduler.List().Any(workItem => + public IActivity? GetStartActivity(string? triggerActivityId) { - var ownerInstanceId = workItem.Owner?.Id; + return flowchart.GetTriggerActivity(triggerActivityId) + ?? flowchart.Start + ?? flowchart.GetExplicitStartActivity() + ?? flowchart.GetCanStartWorkflowActivity() + ?? flowchart.GetRootActivity() + ?? flowchart.Activities.FirstOrDefault(); + } - if (ownerInstanceId == null) - return false; + /// + /// Gets the activity that was triggered by the specified trigger activity ID. + /// + public IActivity? GetTriggerActivity(string? triggerActivityId) + { + return triggerActivityId == null + ? null : + flowchart.Activities.FirstOrDefault(x => x.Id == triggerActivityId); + } - if (ownerInstanceId == context.Id) - return true; + /// + /// Gets the explicit Start activity from the flowchart. + /// + public IActivity? GetExplicitStartActivity() + { + return flowchart.Activities.FirstOrDefault(x => x is Start); + } - var ownerContext = context.WorkflowExecutionContext.ActivityExecutionContexts.First(x => x.Id == ownerInstanceId); - var ancestors = ownerContext.GetAncestors().ToList(); - - return ancestors.Any(x => x == context); - }); - - return hasRunningActivityInstances || hasPendingWork || hasUnconsumedTokens || hasFaulted; + /// + /// Gets the first activity marked as "Can Start Workflow". + /// + public IActivity? GetCanStartWorkflowActivity() + { + return flowchart.Activities.FirstOrDefault(x => x.GetCanStartWorkflow()); + } } - internal static bool HasFaultedChildren(this ActivityExecutionContext context) + extension(ActivityExecutionContext context) { - return context.Children.Any(x => x.Status == ActivityStatus.Faulted); - } + /// + /// Checks if there is any pending work for the flowchart. + /// + public bool HasPendingWork() + { + return context.HasRunningActivityInstances() + || context.HasScheduledWork() + || context.HasUnconsumedTokens() + || context.HasFaultedChildren(); + } - internal static FlowGraph GetFlowGraph(this ActivityExecutionContext context) - { - // Store in TransientProperties so FlowChart is not persisted in WorkflowState - var flowchart = (Activities.Flowchart)context.Activity; - var startActivity = flowchart.GetStartActivity(context.WorkflowExecutionContext.TriggerActivityId); - return context.TransientProperties.GetOrAdd(GraphTransientProperty, () => new FlowGraph(flowchart.Connections, startActivity)); - } - - internal static async Task CancelInboundAncestorsAsync(this ActivityExecutionContext flowchartContext, IActivity activity) - { - if(flowchartContext.Activity is not Activities.Flowchart) - throw new InvalidOperationException("Activity context is not a flowchart."); + public FlowGraph GetFlowGraph() + { + // Store in TransientProperties so FlowChart is not persisted in WorkflowState + var flowchart = (Activities.Flowchart)context.Activity; + var startActivity = flowchart.GetStartActivity(context.WorkflowExecutionContext.TriggerActivityId); + return context.TransientProperties.GetOrAdd(GraphTransientProperty, () => new FlowGraph(flowchart.Connections, startActivity)); + } + + public async Task CancelInboundAncestorsAsync(IActivity activity) + { + if(context.Activity is not Activities.Flowchart) + throw new InvalidOperationException("Activity context is not a flowchart."); - var flowGraph = flowchartContext.GetFlowGraph(); - var ancestorActivities = flowGraph.GetAncestorActivities(activity); - var inboundActivityExecutionContexts = flowchartContext.WorkflowExecutionContext.ActivityExecutionContexts.Where(x => ancestorActivities.Contains(x.Activity) && x.ParentActivityExecutionContext == flowchartContext).ToList(); + var flowGraph = context.GetFlowGraph(); + var ancestorActivities = flowGraph.GetAncestorActivities(activity); + var inboundActivityExecutionContexts = context.WorkflowExecutionContext.ActivityExecutionContexts.Where(x => ancestorActivities.Contains(x.Activity) && x.ParentActivityExecutionContext == context).ToList(); - // Cancel each ancestor activity. - foreach (var activityExecutionContext in inboundActivityExecutionContexts) + // Cancel each ancestor activity. + foreach (var activityExecutionContext in inboundActivityExecutionContexts) + { + await activityExecutionContext.CancelActivityAsync(); + } + } + + /// + /// Checks if the flowchart has any unconsumed tokens. + /// + public bool HasUnconsumedTokens() { - await activityExecutionContext.CancelActivityAsync(); + var flowchart = (Activities.Flowchart)context.Activity; + return flowchart.GetTokenList(context).Any(x => x is { Consumed: false, Blocked: false }); + } + + /// + /// Checks if the flowchart has any running activity instances. + /// + public bool HasRunningActivityInstances() + { + var flowchart = (Activities.Flowchart)context.Activity; + var activityIds = flowchart.Activities.Select(x => x.Id).ToList(); + var children = context.Children; + return children.Where(x => activityIds.Contains(x.Activity.Id)).Any(x => x.Status == ActivityStatus.Running); + } + + /// + /// Checks if the flowchart has any scheduled work items. + /// + public bool HasScheduledWork() + { + var workflowExecutionContext = context.WorkflowExecutionContext; + + return workflowExecutionContext.Scheduler.List().Any(workItem => + { + var ownerInstanceId = workItem.Owner?.Id; + + if (ownerInstanceId == null) + return false; + + if (ownerInstanceId == context.Id) + return true; + + var ownerContext = context.WorkflowExecutionContext.ActivityExecutionContexts.First(x => x.Id == ownerInstanceId); + var ancestors = ownerContext.GetAncestors().ToList(); + + return ancestors.Any(x => x == context); + }); + } + + public bool HasFaultedChildren() + { + return context.Children.Any(x => x.Status == ActivityStatus.Faulted); } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs index 176126183..8a202c489 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs @@ -6,43 +6,50 @@ namespace Elsa.Workflows.Activities.Flowchart.Extensions; public static class ActivityExtensions { - public static MergeMode? GetMergeMode(this IActivity activity) + extension(IActivity activity) { - if (!activity.CustomProperties.TryGetValue("mergeMode", out var value)) - return null; - - // Handle both string and enum values for backwards compatibility - return value switch + public MergeMode? GetMergeMode() { - MergeMode mode => mode, - string str when Enum.TryParse(str, true, out var mode) => mode, - _ => null - }; - } + if (!activity.CustomProperties.TryGetValue("mergeMode", out var value)) + return null; - public static void SetMergeMode(this IActivity activity, MergeMode? value) - { - if (value == null) - activity.CustomProperties.Remove("mergeMode"); - else - activity.CustomProperties["mergeMode"] = value.ToString()!; - } - - public static async Task GetMergeModeAsync(this IActivity activity, ActivityExecutionContext context) - { - if (activity.Type != "Elsa.FlowJoin") - { - return activity.GetMergeMode(); + // Handle both string and enum values for backwards compatibility + var result = value switch + { + MergeMode mode => mode, + string str when Enum.TryParse(str, true, out var mode) => mode, + _ => (MergeMode?)null + }; + + // Treat MergeMode.None as equivalent to null (no merge mode set) + return result == MergeMode.None ? null : result; } - - // Handle deprecated FlowJoin activity by evaluating its JoinMode property and mapping it to the appropriate MergeMode equivalent. - var joinActivityExecutionContext = await context.WorkflowExecutionContext.CreateActivityExecutionContextAsync(activity); - var joinMode = await joinActivityExecutionContext.EvaluateInputPropertyAsync(x => x.Mode); - return joinMode switch + public void SetMergeMode(MergeMode? value) { - FlowJoinMode.WaitAny => MergeMode.Race, - _ => MergeMode.Converge - }; + // Treat MergeMode.None as equivalent to null (no merge mode set) + if (value == null || value == MergeMode.None) + activity.CustomProperties.Remove("mergeMode"); + else + activity.CustomProperties["mergeMode"] = value.ToString()!; + } + + public async Task GetMergeModeAsync(ActivityExecutionContext context) + { + if (activity.Type != "Elsa.FlowJoin") + { + return activity.GetMergeMode(); + } + + // Handle deprecated FlowJoin activity by evaluating its JoinMode property and mapping it to the appropriate MergeMode equivalent. + var joinActivityExecutionContext = await context.WorkflowExecutionContext.CreateActivityExecutionContextAsync(activity); + var joinMode = await joinActivityExecutionContext.EvaluateInputPropertyAsync(x => x.Mode); + + return joinMode switch + { + FlowJoinMode.WaitAny => MergeMode.Race, + _ => MergeMode.Merge // WaitAll maps to Merge (wait for all activated branches) + }; + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ConnectionsExtensions.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ConnectionsExtensions.cs index 46ff61b7f..2e3ca8aa0 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ConnectionsExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ConnectionsExtensions.cs @@ -7,33 +7,36 @@ namespace Elsa.Workflows.Activities.Flowchart.Extensions; /// public static class ConnectionsExtensions { - /// - /// Returns all inbound connections of the specified activity. - /// - public static IEnumerable InboundConnections(this ICollection connections, IActivity activity) => connections.Where(x => x.Target.Activity == activity).Distinct().ToList(); + extension(ICollection connections) + { + /// + /// Returns all inbound connections of the specified activity. + /// + public IEnumerable InboundConnections(IActivity activity) => connections.Where(x => x.Target.Activity == activity).Distinct().ToList(); - /// - /// Returns all inbound activities of the specified activity. - /// - public static IEnumerable InboundActivities(this ICollection connections, IActivity activity) => connections.InboundConnections(activity).Select(x => x.Source.Activity); + /// + /// Returns all inbound activities of the specified activity. + /// + public IEnumerable InboundActivities(IActivity activity) => connections.InboundConnections(activity).Select(x => x.Source.Activity); - /// - /// Returns all outbound connections of the specified activity. - /// - public static IEnumerable OutboundConnections(this ICollection connections, IActivity activity) => connections.Where(x => x.Source.Activity == activity).Distinct().ToList(); + /// + /// Returns all outbound connections of the specified activity. + /// + public IEnumerable OutboundConnections(IActivity activity) => connections.Where(x => x.Source.Activity == activity).Distinct().ToList(); - /// - /// Returns all outbound connections of the specified activity matching the specified outcomes. - /// - public static IEnumerable OutboundConnections(this ICollection connections, IActivity activity, Outcomes outcomes) => connections.OutboundConnections(activity).Where(c => outcomes.Names.Contains(c.Source.Port)); + /// + /// Returns all outbound connections of the specified activity matching the specified outcomes. + /// + public IEnumerable OutboundConnections(IActivity activity, Outcomes outcomes) => connections.OutboundConnections(activity).Where(c => outcomes.Names.Contains(c.Source.Port)); - /// - /// Returns all outbound activities of the specified activity. - /// - public static IEnumerable OutboundActivities(this ICollection connections, IActivity activity) => connections.OutboundConnections(activity).Select(x => x.Source.Activity); + /// + /// Returns all outbound activities of the specified activity. + /// + public IEnumerable OutboundActivities(IActivity activity) => connections.OutboundConnections(activity).Select(x => x.Source.Activity); - /// - /// Returns all outbound activities of the specified activity matching the specified outcomes - /// - public static IEnumerable OutboundActivities(this ICollection connections, IActivity activity, Outcomes outcomes) => connections.OutboundConnections(activity).Where(c => outcomes.Names.Contains(c.Source.Port)).Select(x => x.Source.Activity); + /// + /// Returns all outbound activities of the specified activity matching the specified outcomes + /// + public IEnumerable OutboundActivities(IActivity activity, Outcomes outcomes) => connections.OutboundConnections(activity).Where(c => outcomes.Names.Contains(c.Source.Port)).Select(x => x.Source.Activity); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/MergeMode.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/MergeMode.cs index ad3ecbcde..8d84fbc15 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/MergeMode.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/MergeMode.cs @@ -2,28 +2,52 @@ namespace Elsa.Workflows.Activities.Flowchart.Models; /// /// Specifies the strategy for handling multiple inbound execution paths in a workflow. +/// Uses flow-based terminology to describe merge behavior. /// public enum MergeMode { /// - /// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths. - /// Suitable for flexible, unstructured merges where optional branches shouldn't block. + /// No merge mode set. Treated as if merge mode was not specified (null). + /// Provides backward compatibility for existing workflows. + /// Defaults to Stream behavior at runtime. /// None, /// - /// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths. - /// Use for required synchronization points. - /// - Converge, - - /// - /// Schedule on each arriving token, allowing multiple executions if supported. + /// Flows freely when possible, ignoring dead/untaken paths. + /// Opportunistic execution based on upstream completion. + /// Uses approximation that proceeds after all upstream sources complete. + /// Suitable for flexible, unstructured merges where optional branches shouldn't block. /// Stream, /// + /// Waits for all forward (acyclic) inbound connections before proceeding. + /// Unlike Converge, this ignores backward connections (loops). + /// Will block on dead/untaken paths if they are forward connections. + /// Use for synchronization points in structured fork-join patterns. + /// + Merge, + + /// + /// Converges all inbound paths, requiring every connection to execute. + /// Blocks until all branches complete, including unactivated ones. + /// Strictest mode - will block on dead/untaken paths. + /// Use when every single inbound path must execute before proceeding. + /// + Converge, + + /// + /// Cascades execution for each arriving token independently. + /// Allows multiple concurrent executions (one per arriving token). + /// Use for streaming scenarios where each branch should trigger separate processing. + /// + Cascade, + + /// + /// Races inbound branches, executing on first arrival and blocking others. /// Schedule on the first arriving token, block or cancel others. + /// Use for competitive scenarios where only the first result matters. /// Race } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/For.cs b/src/modules/Elsa.Workflows.Core/Activities/For.cs index a607c73e6..0dcef9454 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/For.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/For.cs @@ -26,9 +26,9 @@ public class For : Activity /// public For(int start, int end, int step, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : this(source, line) { - Start = new Input(start); - End = new Input(end); - Step = new Input(step); + Start = new(start); + End = new(end); + Step = new(step); } /// diff --git a/src/modules/Elsa.Workflows.Core/Activities/ForEachT.cs b/src/modules/Elsa.Workflows.Core/Activities/ForEachT.cs index 9d9d06901..09efb9cc9 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/ForEachT.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/ForEachT.cs @@ -9,8 +9,9 @@ using Elsa.Workflows.Models; namespace Elsa.Workflows.Activities; /// -/// A strongly-typed for-each construct where is the item type. +/// A strongly-typed for-each construct where is the item type. /// +/// The type of items in the collection to iterate over. public class ForEach : Activity { private const string CurrentIndexProperty = "CurrentIndex"; diff --git a/src/modules/Elsa.Workflows.Core/Activities/Fork.cs b/src/modules/Elsa.Workflows.Core/Activities/Fork.cs index 8f8f6e98b..da7fbe8d9 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Fork.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Fork.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.ComponentModel; using System.Runtime.CompilerServices; +using Elsa.Extensions; using Elsa.Workflows.Attributes; using Elsa.Workflows.Signals; using Elsa.Workflows.UIHints; @@ -38,7 +39,17 @@ public class Fork : Activity public ICollection Branches { get; set; } = new List(); /// - protected override ValueTask ExecuteAsync(ActivityExecutionContext context) => context.ScheduleActivities(Branches, CompleteChildAsync); + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + // If there are no branches, complete immediately + if (Branches.Count == 0) + { + await context.CompleteActivityAsync(); + return; + } + + await context.ScheduleActivities(Branches, CompleteChildAsync); + } private async ValueTask CompleteChildAsync(ActivityCompletedContext context) { @@ -57,7 +68,7 @@ public class Fork : Activity // Append activity to the set of completed activities. var completedActivityIds = targetContext.UpdateProperty>("Completed", set => { - set ??= new HashSet(); + set ??= new(); set.Add(completedChildActivityId); return set; }); diff --git a/src/modules/Elsa.Workflows.Core/Activities/ParallelForEachT.cs b/src/modules/Elsa.Workflows.Core/Activities/ParallelForEachT.cs index 274141172..cef5648b5 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/ParallelForEachT.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/ParallelForEachT.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; using Elsa.Expressions.Helpers; +using Elsa.Expressions.Models; using Elsa.Extensions; using Elsa.Workflows.Attributes; using Elsa.Workflows.Memory; @@ -19,6 +20,27 @@ public class ParallelForEach : Activity private const string ScheduledTagsProperty = nameof(ScheduledTagsProperty); private const string CompletedTagsProperty = nameof(CompletedTagsProperty); + /// + public ParallelForEach(Func> @delegate, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : this(new Input(@delegate), source, line) + { + } + + /// + public ParallelForEach(Func> @delegate, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : this(new Input(@delegate), source, line) + { + } + + /// + public ParallelForEach(ICollection items, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : this(new Input(items), source, line) + { + } + + /// + public ParallelForEach(Input items, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : this(source, line) + { + Items = items; + } + /// public ParallelForEach([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) { @@ -42,7 +64,7 @@ public class ParallelForEach : Activity var items = context.GetItemSource(Items); var tags = new List(); var currentIndex = 0; - + await foreach (var item in items) { // For each item, declare a new variable for the work to be scheduled. @@ -90,7 +112,7 @@ public class ParallelForEach : Activity private ICollection GetTagList(ActivityExecutionContext context, string propertyName) { // Read the list of tags from the context using the specified property name. The value is stored as JsonArray, so we need to deserialize it. - var jsonArray = context.GetProperty(propertyName); + var jsonArray = context.GetProperty(propertyName)!; return jsonArray.Select(x => x.ConvertTo()).ToList(); } diff --git a/src/modules/Elsa.Workflows.Core/Activities/While.cs b/src/modules/Elsa.Workflows.Core/Activities/While.cs index 215160f2a..3dc28bab1 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/While.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/While.cs @@ -21,7 +21,7 @@ public class While : Activity /// public static While True(IActivity body) => new(body) { - Condition = new Input(true) + Condition = new(true) }; /// diff --git a/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs b/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs index b30d7669e..fd5608325 100644 --- a/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs +++ b/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs @@ -6,7 +6,7 @@ using Elsa.Workflows.Models; namespace Elsa.Workflows.Builders; /// -public class WorkflowBuilder(IActivityVisitor activityVisitor, IIdentityGraphService identityGraphService, IActivityRegistry activityRegistry, IIdentityGenerator identityGenerator) +public class WorkflowBuilder(IActivityVisitor activityVisitor, IIdentityGraphService identityGraphService, IActivityRegistry activityRegistry) : IWorkflowBuilder { /// diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs index fa1f67c30..33906c603 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs @@ -402,7 +402,7 @@ public partial class WorkflowExecutionContext : IExecutionContext /// /// The expression execution context for the current workflow execution. /// - public ExpressionExecutionContext? ExpressionExecutionContext { get; private set; } + public ExpressionExecutionContext ExpressionExecutionContext { get; private set; } = null!; /// public IEnumerable Variables => Workflow.Variables; diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityFactory.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityFactory.cs deleted file mode 100644 index 17f55f35e..000000000 --- a/src/modules/Elsa.Workflows.Core/Contracts/IActivityFactory.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Elsa.Workflows.Models; - -namespace Elsa.Workflows; - -public interface IActivityFactory -{ - IActivity Create(Type type, ActivityConstructorContext context); -} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs index de652e991..00f0b0382 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs @@ -131,7 +131,6 @@ public interface IWorkflowStateSerializer /// Deserializes the specified serialized state. /// /// The serialized state. - /// The cancellation token. /// The deserialized workflow state. [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] T Deserialize(string serializedState); diff --git a/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj b/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj index 9c4e5234e..e400062f4 100644 --- a/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj +++ b/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj @@ -19,6 +19,10 @@ + + + + diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityDescriptorExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityDescriptorExtensions.cs index a3d942346..e78e92ba0 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityDescriptorExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityDescriptorExtensions.cs @@ -9,54 +9,57 @@ namespace Elsa.Extensions; /// public static class ActivityDescriptorExtensions { - /// - /// Returns the specified attribute from the activity descriptor. - /// - public static T? GetAttribute(this ActivityDescriptor activityDescriptor) where T : Attribute + extension(ActivityDescriptor activityDescriptor) { - return (T?)activityDescriptor.Attributes.FirstOrDefault(x => x.GetType() == typeof(T)); - } + /// + /// Returns the specified attribute from the activity descriptor. + /// + public T? GetAttribute() where T : Attribute + { + return (T?)activityDescriptor.Attributes.FirstOrDefault(x => x.GetType() == typeof(T)); + } - /// - /// Returns each wrapped input from the specified activity. - /// - public static IEnumerable GetWrappedInputPropertyDescriptors(this ActivityDescriptor activityDescriptor, IActivity activity) - { - return activityDescriptor.Inputs.Where(x => x.IsWrapped); - } - - /// - /// Returns each naked input from the specified activity. - /// - public static IEnumerable GetNakedInputPropertyDescriptors(this ActivityDescriptor activityDescriptor, IActivity activity) - { - return activityDescriptor.Inputs.Where(x => !x.IsWrapped); - } - - /// - /// Returns each input from the specified activity. - /// - public static IDictionary GetWrappedInputProperties(this ActivityDescriptor activityDescriptor, IActivity activity) - { - var wrappedInputDescriptors = activityDescriptor.Inputs.Where(x => x.IsWrapped).ToList(); - var inputLookup = wrappedInputDescriptors.ToDictionary(x => x.Name, x => (Input?)x.ValueGetter(activity)); - return inputLookup; - } - - /// - /// Returns each input descriptor from the specified activity. - /// - public static InputDescriptor? GetWrappedInputPropertyDescriptor(this ActivityDescriptor activityDescriptor, IActivity activity, string name) - { - return activityDescriptor.Inputs.FirstOrDefault(x => x.Name == name && x.IsWrapped); - } - - /// - /// Returns each input from the specified activity. - /// - public static Input? GetWrappedInputProperty(this ActivityDescriptor activityDescriptor, IActivity activity, string name) - { - var inputDescriptor = activityDescriptor.Inputs.FirstOrDefault(x => x.Name == name && x.IsWrapped); - return inputDescriptor?.ValueGetter(activity) as Input; + /// + /// Returns each wrapped input from the specified activity. + /// + public IEnumerable GetWrappedInputPropertyDescriptors(IActivity activity) + { + return activityDescriptor.Inputs.Where(x => x.IsWrapped); + } + + /// + /// Returns each naked input from the specified activity. + /// + public IEnumerable GetNakedInputPropertyDescriptors(IActivity activity) + { + return activityDescriptor.Inputs.Where(x => !x.IsWrapped); + } + + /// + /// Returns each input from the specified activity. + /// + public IDictionary GetWrappedInputProperties(IActivity activity) + { + var wrappedInputDescriptors = activityDescriptor.Inputs.Where(x => x.IsWrapped).ToList(); + var inputLookup = wrappedInputDescriptors.ToDictionary(x => x.Name, x => (Input?)x.ValueGetter(activity)); + return inputLookup; + } + + /// + /// Returns each input descriptor from the specified activity. + /// + public InputDescriptor? GetWrappedInputPropertyDescriptor(IActivity activity, string name) + { + return activityDescriptor.Inputs.FirstOrDefault(x => x.Name == name && x.IsWrapped); + } + + /// + /// Returns each input from the specified activity. + /// + public Input? GetWrappedInputProperty(IActivity activity, string name) + { + var inputDescriptor = activityDescriptor.Inputs.FirstOrDefault(x => x.Name == name && x.IsWrapped); + return inputDescriptor?.ValueGetter(activity) as Input; + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs index aee9509a3..4bdbd64e6 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs @@ -11,133 +11,136 @@ namespace Elsa.Extensions; public static partial class ActivityExecutionContextExtensions { - /// - /// Evaluates each input property of the activity. - /// - public static async Task EvaluateInputPropertiesAsync(this ActivityExecutionContext context) - { - var activityDescriptor = context.ActivityDescriptor; - var inputDescriptors = activityDescriptor.Inputs.Where(x => x.AutoEvaluate).ToList(); - - // Evaluate inputs. - foreach (var inputDescriptor in inputDescriptors) - await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor); - - context.SetHasEvaluatedProperties(); - } - - /// - /// Evaluates the specified input property of the activity. - /// - public static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, Expression>> propertyExpression) - { - var inputName = propertyExpression.GetProperty()!.Name; - var input = await EvaluateInputPropertyAsync(context, inputName); - return input.ConvertTo(); - } - - /// - /// Evaluates a specific input property of the activity. - /// - public static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, string inputName) - { - var activity = context.Activity; - var activityRegistryLookup = context.GetRequiredService(); - var activityDescriptor = await activityRegistryLookup.FindAsync(activity.Type) ?? throw new Exception("Activity descriptor not found"); - var inputDescriptor = activityDescriptor.GetWrappedInputPropertyDescriptor(activity, inputName); - - if (inputDescriptor == null) - throw new Exception($"No input with name {inputName} could be found"); - - return await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor); - } - - /// - /// Evaluates the specified input and sets the result in the activity execution context's memory space. - /// /// The being extended. - /// The input to evaluate. - /// The type of the input. - /// The evaluated value. - public static async Task EvaluateAsync(this ActivityExecutionContext context, Input input) + extension(ActivityExecutionContext context) { - var evaluator = context.GetRequiredService(); - var memoryBlockReference = input.MemoryBlockReference(); - var value = await evaluator.EvaluateAsync(input, context.ExpressionExecutionContext); - memoryBlockReference.Set(context, value); - return value; - } - - private static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor) - { - try + /// + /// Evaluates each input property of the activity. + /// + public async Task EvaluateInputPropertiesAsync() { - return await EvaluateInputPropertyCoreAsync(context, activityDescriptor, inputDescriptor); - } - catch (Exception e) - { - throw new InputEvaluationException(inputDescriptor.Name, $"Failed to evaluate activity input '{inputDescriptor.Name}'", e); - } - } - - private static async Task EvaluateInputPropertyCoreAsync(this ActivityExecutionContext context, ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor) - { - var activity = context.Activity; - var defaultValue = inputDescriptor.DefaultValue; - var value = defaultValue; - var input = inputDescriptor.ValueGetter(activity); - var identityGenerator = context.GetRequiredService(); + var activityDescriptor = context.ActivityDescriptor; + var inputDescriptors = activityDescriptor.Inputs.Where(x => x.AutoEvaluate).ToList(); - if (inputDescriptor.IsWrapped) - { - var wrappedInput = (Input?)input; + // Evaluate inputs. + foreach (var inputDescriptor in inputDescriptors) + await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor); - if (defaultValue != null && wrappedInput == null) + context.SetHasEvaluatedProperties(); + } + + /// + /// Evaluates the specified input property of the activity. + /// + public async Task EvaluateInputPropertyAsync(Expression>> propertyExpression) + { + var inputName = propertyExpression.GetProperty()!.Name; + var input = await EvaluateInputPropertyAsync(context, inputName); + return input.ConvertTo(); + } + + /// + /// Evaluates a specific input property of the activity. + /// + public async Task EvaluateInputPropertyAsync(string inputName) + { + var activity = context.Activity; + var activityRegistryLookup = context.GetRequiredService(); + var activityDescriptor = await activityRegistryLookup.FindAsync(activity.Type) ?? throw new Exception("Activity descriptor not found"); + var inputDescriptor = activityDescriptor.GetWrappedInputPropertyDescriptor(activity, inputName); + + if (inputDescriptor == null) + throw new Exception($"No input with name {inputName} could be found"); + + return await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor); + } + + /// + /// Evaluates the specified input and sets the result in the activity execution context's memory space. + /// + /// The input to evaluate. + /// The type of the input. + /// The evaluated value. + public async Task EvaluateAsync(Input input) + { + var evaluator = context.GetRequiredService(); + var memoryBlockReference = input.MemoryBlockReference(); + var value = await evaluator.EvaluateAsync(input, context.ExpressionExecutionContext); + memoryBlockReference.Set(context, value); + return value; + } + + private async Task EvaluateInputPropertyAsync(ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor) + { + try { - var typedInput = typeof(Input<>).MakeGenericType(inputDescriptor.Type); - var valueExpression = new Literal(defaultValue) + return await EvaluateInputPropertyCoreAsync(context, activityDescriptor, inputDescriptor); + } + catch (Exception e) + { + throw new InputEvaluationException(inputDescriptor.Name, $"Failed to evaluate activity input '{inputDescriptor.Name}'", e); + } + } + + private async Task EvaluateInputPropertyCoreAsync(ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor) + { + var activity = context.Activity; + var defaultValue = inputDescriptor.DefaultValue; + var value = defaultValue; + var input = inputDescriptor.ValueGetter(activity); + var identityGenerator = context.GetRequiredService(); + + if (inputDescriptor.IsWrapped) + { + var wrappedInput = (Input?)input; + + if (defaultValue != null && wrappedInput == null) { - Id = identityGenerator.GenerateId(), - }; - wrappedInput = (Input)Activator.CreateInstance(typedInput, valueExpression)!; - inputDescriptor.ValueSetter(activity, wrappedInput); + var typedInput = typeof(Input<>).MakeGenericType(inputDescriptor.Type); + var valueExpression = new Literal(defaultValue) + { + Id = identityGenerator.GenerateId(), + }; + wrappedInput = (Input)Activator.CreateInstance(typedInput, valueExpression)!; + inputDescriptor.ValueSetter(activity, wrappedInput); + } + else + { + var expressionEvaluator = context.GetRequiredService(); + var expressionExecutionContext = context.ExpressionExecutionContext; + var inputEvaluatorType = inputDescriptor.EvaluatorType ?? typeof(DefaultActivityInputEvaluator); + + if (wrappedInput?.Expression != null) + { + var inputEvaluator = (IActivityInputEvaluator)context.GetRequiredService(inputEvaluatorType); + var inputEvaluatorContext = new ActivityInputEvaluatorContext(context, expressionExecutionContext, inputDescriptor, wrappedInput, expressionEvaluator); + value = await inputEvaluator.EvaluateAsync(inputEvaluatorContext); + } + } + + var memoryReference = wrappedInput?.MemoryBlockReference(); + + if (memoryReference != null) + { + // When input is created from an activity provider, there may be no memory block reference ID. + if (memoryReference.Id == null!) + memoryReference.Id = $"{activity.NodeId}.{inputDescriptor.Name}"; // Construct a deterministic ID. + + // Declare the input memory block in the current context. + context.ExpressionExecutionContext.Set(memoryReference, value!); + } } else { - var expressionEvaluator = context.GetRequiredService(); - var expressionExecutionContext = context.ExpressionExecutionContext; - var inputEvaluatorType = inputDescriptor.EvaluatorType ?? typeof(DefaultActivityInputEvaluator); - - if (wrappedInput?.Expression != null) - { - var inputEvaluator = (IActivityInputEvaluator)context.GetRequiredService(inputEvaluatorType); - var inputEvaluatorContext = new ActivityInputEvaluatorContext(context, expressionExecutionContext, inputDescriptor, wrappedInput, expressionEvaluator); - value = await inputEvaluator.EvaluateAsync(inputEvaluatorContext); - } + value = input; } - var memoryReference = wrappedInput?.MemoryBlockReference(); + await StoreInputValueAsync(context, inputDescriptor, value!); - if (memoryReference != null) - { - // When input is created from an activity provider, there may be no memory block reference ID. - if (memoryReference.Id == null!) - memoryReference.Id = $"{activity.NodeId}.{inputDescriptor.Name}"; // Construct a deterministic ID. - - // Declare the input memory block in the current context. - context.ExpressionExecutionContext.Set(memoryReference, value!); - } + return value; } - else - { - value = input; - } - - await StoreInputValueAsync(context, inputDescriptor, value!); - - return value; } - + private static Task StoreInputValueAsync(ActivityExecutionContext context, InputDescriptor inputDescriptor, object value) { // Store the serialized input value in the activity state. diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputOutput.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputOutput.cs index 00c6c626e..7bb6a8cec 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputOutput.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputOutput.cs @@ -5,34 +5,37 @@ namespace Elsa.Workflows; public static class ActivityExecutionContextExtensions { - public static IDictionary GetInputs(this ActivityExecutionContext context) + extension(ActivityExecutionContext context) { - return context.ActivityState!; - } - - public static IDictionary GetOutputs(this ActivityExecutionContext context) - { - var activity = context.Activity; - var expressionExecutionContext = context.ExpressionExecutionContext; - var activityDescriptor = context.ActivityDescriptor; - var outputDescriptors = activityDescriptor.Outputs; - - var outputs = outputDescriptors.ToDictionary(x => x.Name, x => + public IDictionary GetInputs() { - if (x.IsSerializable == false) - return "(not serializable)"; + return context.ActivityState!; + } - var cachedValue = activity.GetOutput(expressionExecutionContext, x.Name); + public IDictionary GetOutputs() + { + var activity = context.Activity; + var expressionExecutionContext = context.ExpressionExecutionContext; + var activityDescriptor = context.ActivityDescriptor; + var outputDescriptors = activityDescriptor.Outputs; - if (cachedValue != null) - return cachedValue; + var outputs = outputDescriptors.ToDictionary(x => x.Name, x => + { + if (x.IsSerializable == false) + return "(not serializable)"; - if (x.ValueGetter(activity) is Output output && context.TryGet(output.MemoryBlockReference(), out var outputValue)) - return outputValue!; + var cachedValue = activity.GetOutput(expressionExecutionContext, x.Name); - return null!; - }); + if (cachedValue != null) + return cachedValue; - return outputs; + if (x.ValueGetter(activity) is Output output && context.TryGet(output.MemoryBlockReference(), out var outputValue)) + return outputValue!; + + return null!; + }); + + return outputs; + } } } \ 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 501d08122..90524fe51 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs @@ -27,140 +27,140 @@ public static partial class ActivityExecutionContextExtensions { private const string ExtensionsMetadataKey = "Extensions"; - /// - /// Attempts to get a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T. - /// - public static bool TryGetWorkflowInput(this ActivityExecutionContext context, string key, out T value, JsonSerializerOptions? serializerOptions = null) + /// The + extension(ActivityExecutionContext context) { - var wellKnownTypeRegistry = context.GetRequiredService(); - - if (context.WorkflowInput.TryGetValue(key, out var v)) + /// + /// Attempts to get a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T. + /// + public bool TryGetWorkflowInput(string key, out T value, JsonSerializerOptions? serializerOptions = null) { - value = v.ConvertTo(new(serializerOptions, wellKnownTypeRegistry))!; - return true; + var wellKnownTypeRegistry = context.GetRequiredService(); + + if (context.WorkflowInput.TryGetValue(key, out var v)) + { + value = v.ConvertTo(new(serializerOptions, wellKnownTypeRegistry))!; + return true; + } + + value = default!; + return false; } - value = default!; - return false; - } + /// + /// Gets a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T. + /// + public T GetWorkflowInput(JsonSerializerOptions? serializerOptions = null) => context.GetWorkflowInput(typeof(T).Name, serializerOptions); - /// - /// Gets a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T. - /// - public static T GetWorkflowInput(this ActivityExecutionContext context, JsonSerializerOptions? serializerOptions = null) => context.GetWorkflowInput(typeof(T).Name, serializerOptions); - - /// - /// Gets a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T. - /// - public static T GetWorkflowInput(this ActivityExecutionContext context, string key, JsonSerializerOptions? serializerOptions = null) - { - var wellKnownTypeRegistry = context.GetRequiredService(); - return context.WorkflowInput[key].ConvertTo(new(serializerOptions, wellKnownTypeRegistry))!; - } - - /// - /// Sets the Result property of the specified activity. - /// - /// The being extended. - /// The value to set. - /// Thrown when the specified activity does not implement . - public static void SetResult(this ActivityExecutionContext context, object? value) - { - var activity = context.Activity as IActivityWithResult ?? throw new($"Cannot set result on activity {context.Activity.Id} because it does not implement {nameof(IActivityWithResult)}."); - context.Set(activity.Result, value, "Result"); - } - - /// - /// Returns true if this activity is triggered for the first time and not being resumed. - /// - public static bool IsTriggerOfWorkflow(this ActivityExecutionContext context) => context.WorkflowExecutionContext.TriggerActivityId == context.Activity.Id; - - /// - /// Creates a workflow variable by name and optionally sets the value. - /// - /// The being extended. - /// The name of the variable. - /// The value of the variable. - /// The type of storage driver to use for the variable. - /// A callback to configure the memory block. - /// The created . - public static Variable CreateVariable(this ActivityExecutionContext context, string name, object? value, Type? storageDriverType = null, Action? configure = null) - { - return context.ExpressionExecutionContext.CreateVariable(name, value, storageDriverType, configure); - } - - /// - /// Sets a workflow variable by name. - /// - /// The being extended. - /// The name of the variable. - /// The value of the variable. - /// A callback to configure the memory block. - /// The created . - public static Variable SetVariable(this ActivityExecutionContext context, string name, object? value, Action? configure = null) => - context.ExpressionExecutionContext.SetVariable(name, value, configure); - - public static Variable SetDynamicVariable(this ActivityExecutionContext context, string name, T value, Action? configure = null) - { - // Check if a predefined variable already exists. - var predefinedVariable = context.ExpressionExecutionContext.GetVariable(name); - - if (predefinedVariable != null) + /// + /// Gets a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T. + /// + public T GetWorkflowInput(string key, JsonSerializerOptions? serializerOptions = null) { - context.SetVariable(name, value); - return predefinedVariable; + var wellKnownTypeRegistry = context.GetRequiredService(); + return context.WorkflowInput[key].ConvertTo(new(serializerOptions, wellKnownTypeRegistry))!; } - // No predefined variable exists, so we will add a dynamic variable to the current container in scope. - var container = context.FindParentWithVariableContainer(); + /// + /// Sets the Result property of the specified activity. + /// + /// being extended. + /// The value to set. + /// Thrown when the specified activity does not implement . + public void SetResult(object? value) + { + var activity = context.Activity as IActivityWithResult ?? throw new($"Cannot set result on activity {context.Activity.Id} because it does not implement {nameof(IActivityWithResult)}."); + context.Set(activity.Result, value, "Result"); + } - if (container == null) - throw new("No parent variable container found"); + /// + /// Returns true if this activity is triggered for the first time and not being resumed. + /// + public bool IsTriggerOfWorkflow() => context.WorkflowExecutionContext.TriggerActivityId == context.Activity.Id; - var existingVariable = container.DynamicVariables.FirstOrDefault(x => x.Name == name); + /// + /// Creates a workflow variable by name and optionally sets the value. + /// + /// The name of the variable. + /// The value of the variable. + /// The type of storage driver to use for the variable. + /// A callback to configure the memory block. + /// The created . + public Variable CreateVariable(string name, object? value, Type? storageDriverType = null, Action? configure = null) + { + return context.ExpressionExecutionContext.CreateVariable(name, value, storageDriverType, configure); + } - if (existingVariable == null) - container.DynamicVariables.Add(new Variable(name, value)); + /// + /// Sets a workflow variable by name. + /// + /// The name of the variable. + /// The value of the variable. + /// A callback to configure the memory block. + /// The created . + public Variable SetVariable(string name, object? value, Action? configure = null) => + context.ExpressionExecutionContext.SetVariable(name, value, configure); - return context.SetVariable(name, value); - } + public Variable SetDynamicVariable(string name, T value, Action? configure = null) + { + // Check if a predefined variable already exists. + var predefinedVariable = context.ExpressionExecutionContext.GetVariable(name); - /// - /// Gets a workflow variable by name. - /// - /// The being extended. - /// The name of the variable. - /// The type of the variable. - /// The variable if found, otherwise null. - public static T? GetVariable(this ActivityExecutionContext context, string name) => context.ExpressionExecutionContext.GetVariable(name); + if (predefinedVariable != null) + { + context.SetVariable(name, value); + return predefinedVariable; + } - /// - /// Returns a dictionary of variable keys and their values across scopes. - /// - public static IDictionary GetVariableValues(this ActivityExecutionContext activityExecutionContext) => activityExecutionContext.ExpressionExecutionContext.ReadAndFlattenMemoryBlocks(); + // No predefined variable exists, so we will add a dynamic variable to the current container in scope. + var container = context.FindParentWithVariableContainer(); - /// - /// Returns a set of tuples containing the activity and its descriptor for all activities with outputs. - /// - /// The being extended. - public static async IAsyncEnumerable<(IActivity Activity, ActivityDescriptor ActivityDescriptor)> GetActivitiesWithOutputs(this ActivityExecutionContext activityExecutionContext) - { - // Get current container. - var currentContainerNode = activityExecutionContext.FindParentWithVariableContainer()?.ActivityNode; + if (container == null) + throw new("No parent variable container found"); - if (currentContainerNode == null) - yield break; + var existingVariable = container.DynamicVariables.FirstOrDefault(x => x.Name == name); - // Get all nodes in the current container - var workflowExecutionContext = activityExecutionContext.WorkflowExecutionContext; - var containedNodes = workflowExecutionContext.Nodes.Where(x => x.Parents.Contains(currentContainerNode)).Distinct().ToList(); + if (existingVariable == null) + container.DynamicVariables.Add(new Variable(name, value)); - // Select activities with outputs. - var activityRegistry = workflowExecutionContext.GetRequiredService(); - var activitiesWithOutputs = containedNodes.GetActivitiesWithOutputs(activityRegistry); + return context.SetVariable(name, value); + } - await foreach (var (activity, activityDescriptor) in activitiesWithOutputs) - yield return (activity, activityDescriptor); + /// + /// Gets a workflow variable by name. + /// + /// The name of the variable. + /// The type of the variable. + /// The variable if found, otherwise null. + public T? GetVariable(string name) => context.ExpressionExecutionContext.GetVariable(name); + + /// + /// Returns a dictionary of variable keys and their values across scopes. + /// + public IDictionary GetVariableValues() => context.ExpressionExecutionContext.ReadAndFlattenMemoryBlocks(); + + /// + /// Returns a set of tuples containing the activity and its descriptor for all activities with outputs. + /// + public async IAsyncEnumerable<(IActivity Activity, ActivityDescriptor ActivityDescriptor)> GetActivitiesWithOutputs() + { + // Get current container. + var currentContainerNode = context.FindParentWithVariableContainer()?.ActivityNode; + + if (currentContainerNode == null) + yield break; + + // Get all nodes in the current container + var workflowExecutionContext = context.WorkflowExecutionContext; + var containedNodes = workflowExecutionContext.Nodes.Where(x => x.Parents.Contains(currentContainerNode)).Distinct().ToList(); + + // Select activities with outputs. + var activityRegistry = workflowExecutionContext.GetRequiredService(); + var activitiesWithOutputs = containedNodes.GetActivitiesWithOutputs(activityRegistry); + + await foreach (var (activity, activityDescriptor) in activitiesWithOutputs) + yield return (activity, activityDescriptor); + } } /// @@ -177,315 +177,313 @@ public static partial class ActivityExecutionContextExtensions } } - /// - /// Returns the activity with the specified ID or name. - /// - public static IActivity? FindActivityByIdOrName(this ActivityExecutionContext activityExecutionContext, string idOrName) + /// The being extended. + extension(ActivityExecutionContext activityExecutionContext) { - // Get current container. - var currentContainerNode = activityExecutionContext.FindParentWithVariableContainer()?.ActivityNode; - - if (currentContainerNode == null) - return null; - - // Get all nodes in the current container - var workflowExecutionContext = activityExecutionContext.WorkflowExecutionContext; - var containedNodes = workflowExecutionContext.Nodes.Where(x => x.Parents.Contains(currentContainerNode)).Distinct().ToList(); - var node = containedNodes.FirstOrDefault(x => x.Activity.Name == idOrName || x.Activity.Id == idOrName); - return node?.Activity; - } - - /// - /// Returns the outcome name for the specified port property name. - /// - /// The being extended. - /// The name of the port property. - /// The outcome name. - public static string GetOutcomeName(this ActivityExecutionContext context, string portPropertyName) - { - var owner = context.Activity; - var ports = owner.GetType().GetProperties().Where(x => typeof(IActivity).IsAssignableFrom(x.PropertyType)).ToList(); - - var portQuery = - from p in ports - where p.Name == portPropertyName - select p; - - var portProperty = portQuery.First(); - return portProperty.GetCustomAttribute()?.Name ?? portProperty.Name; - } - - /// - /// Send a signal up the current hierarchy of ancestors. - /// - public static async ValueTask SendSignalAsync(this ActivityExecutionContext context, object signal) - { - var receivingContexts = new[] + /// + /// Returns the activity with the specified ID or name. + /// + public IActivity? FindActivityByIdOrName(string idOrName) { - context - }.Concat(context.GetAncestors()).ToList(); - var logger = context.GetRequiredService>(); - var signalType = signal.GetType(); - var signalTypeName = signalType.Name; + // Get current container. + var currentContainerNode = activityExecutionContext.FindParentWithVariableContainer()?.ActivityNode; - // Let all ancestors receive the signal. - foreach (var ancestorContext in receivingContexts) + if (currentContainerNode == null) + return null; + + // Get all nodes in the current container + var workflowExecutionContext = activityExecutionContext.WorkflowExecutionContext; + var containedNodes = workflowExecutionContext.Nodes.Where(x => x.Parents.Contains(currentContainerNode)).Distinct().ToList(); + var node = containedNodes.FirstOrDefault(x => x.Activity.Name == idOrName || x.Activity.Id == idOrName); + return node?.Activity; + } + + /// + /// Returns the outcome name for the specified port property name. + /// + /// The name of the port property. + /// The outcome name. + public string GetOutcomeName(string portPropertyName) { - var signalContext = new SignalContext(ancestorContext, context, context.CancellationToken); + var owner = activityExecutionContext.Activity; + var ports = owner.GetType().GetProperties().Where(x => typeof(IActivity).IsAssignableFrom(x.PropertyType)).ToList(); - if (ancestorContext.Activity is not ISignalHandler handler) - continue; + var portQuery = + from p in ports + where p.Name == portPropertyName + select p; - logger.LogDebug("Receiving signal {SignalType} on activity {ActivityId} of type {ActivityType}", signalTypeName, ancestorContext.Activity.Id, ancestorContext.Activity.Type); - await handler.ReceiveSignalAsync(signal, signalContext); + var portProperty = portQuery.First(); + return portProperty.GetCustomAttribute()?.Name ?? portProperty.Name; + } - if (signalContext.StopPropagationRequested) + /// + /// Send a signal up the current hierarchy of ancestors. + /// + public async ValueTask SendSignalAsync(object signal) + { + var receivingContexts = new[] { - logger.LogDebug("Propagation of signal {SignalType} on activity {ActivityId} of type {ActivityType} was stopped", signalTypeName, ancestorContext.Activity.Id, ancestorContext.Activity.Type); - return; + activityExecutionContext + }.Concat(activityExecutionContext.GetAncestors()).ToList(); + var logger = activityExecutionContext.GetRequiredService>(); + var signalType = signal.GetType(); + var signalTypeName = signalType.Name; + + // Let all ancestors receive the signal. + foreach (var ancestorContext in receivingContexts) + { + var signalContext = new SignalContext(ancestorContext, activityExecutionContext, activityExecutionContext.CancellationToken); + + if (ancestorContext.Activity is not ISignalHandler handler) + continue; + + logger.LogDebug("Receiving signal {SignalType} on activity {ActivityId} of type {ActivityType}", signalTypeName, ancestorContext.Activity.Id, ancestorContext.Activity.Type); + await handler.ReceiveSignalAsync(signal, signalContext); + + if (signalContext.StopPropagationRequested) + { + logger.LogDebug("Propagation of signal {SignalType} on activity {ActivityId} of type {ActivityType} was stopped", signalTypeName, ancestorContext.Activity.Id, ancestorContext.Activity.Type); + return; + } } } - } - /// - /// Complete the current activity. This should only be called by activities that explicitly suppress automatic-completion. - /// - public static async ValueTask ScheduleOutcomesAsync(this ActivityExecutionContext context, params string[] outcomes) - { - // Record the outcomes, if any. - context.JournalData["Outcomes"] = outcomes; - - // Record the output, if any. - var activity = context.Activity; - var expressionExecutionContext = context.ExpressionExecutionContext; - var activityDescriptor = context.ActivityDescriptor; - var outputDescriptors = activityDescriptor.Outputs; - var outputs = outputDescriptors.ToDictionary(x => x.Name, x => activity.GetOutput(expressionExecutionContext, x.Name)!); - var serializer = context.GetRequiredService(); - - foreach (var output in outputs) + /// + /// Complete the current activity. This should only be called by activities that explicitly suppress automatic-completion. + /// + public async ValueTask ScheduleOutcomesAsync(params string[] outcomes) { - var outputName = output.Key; - var outputValue = output.Value; + // Record the outcomes, if any. + activityExecutionContext.JournalData["Outcomes"] = outcomes; - if (outputValue == null!) - continue; + // Record the output, if any. + var activity = activityExecutionContext.Activity; + var expressionExecutionContext = activityExecutionContext.ExpressionExecutionContext; + var activityDescriptor = activityExecutionContext.ActivityDescriptor; + var outputDescriptors = activityDescriptor.Outputs; + var outputs = outputDescriptors.ToDictionary(x => x.Name, x => activity.GetOutput(expressionExecutionContext, x.Name)!); + var serializer = activityExecutionContext.GetRequiredService(); - var serializedOutputValue = serializer.Serialize(outputValue); - context.JournalData[outputName] = serializedOutputValue; + foreach (var output in outputs) + { + var outputName = output.Key; + var outputValue = output.Value; + + if (outputValue == null!) + continue; + + var serializedOutputValue = serializer.Serialize(outputValue); + activityExecutionContext.JournalData[outputName] = serializedOutputValue; + } + + // Send a signal. + await activityExecutionContext.SendSignalAsync(new ScheduleActivityOutcomes(outcomes)); } - // Send a signal. - await context.SendSignalAsync(new ScheduleActivityOutcomes(outcomes)); - } + /// + /// Cancel the activity. For blocking activities, it means their bookmarks will be removed. For job activities, the background work will be cancelled. + /// + public async Task CancelActivityAsync() + { + // If the activity is not running, do nothing. + if (activityExecutionContext.Status != ActivityStatus.Running && activityExecutionContext.Status != ActivityStatus.Faulted) + return; - /// - /// Cancel the activity. For blocking activities, it means their bookmarks will be removed. For job activities, the background work will be cancelled. - /// - public static async Task CancelActivityAsync(this ActivityExecutionContext context) - { - // If the activity is not running, do nothing. - if (context.Status != ActivityStatus.Running && context.Status != ActivityStatus.Faulted) - return; + // Select all child contexts. + var childContexts = activityExecutionContext.Children.ToList(); - // Select all child contexts. - var childContexts = context.Children.ToList(); + foreach (var childContext in childContexts) + await CancelActivityAsync(childContext); - foreach (var childContext in childContexts) - await CancelActivityAsync(childContext); + var publisher = activityExecutionContext.GetRequiredService(); + activityExecutionContext.TransitionTo(ActivityStatus.Canceled); + activityExecutionContext.ClearBookmarks(); + activityExecutionContext.ClearCompletionCallbacks(); + activityExecutionContext.WorkflowExecutionContext.Bookmarks.RemoveWhere(x => x.ActivityNodeId == activityExecutionContext.NodeId); - var publisher = context.GetRequiredService(); - context.TransitionTo(ActivityStatus.Canceled); - context.ClearBookmarks(); - context.ClearCompletionCallbacks(); - context.WorkflowExecutionContext.Bookmarks.RemoveWhere(x => x.ActivityNodeId == context.NodeId); + // Add an execution log entry. + activityExecutionContext.AddExecutionLogEntry("Canceled"); - // Add an execution log entry. - context.AddExecutionLogEntry("Canceled"); + await activityExecutionContext.SendSignalAsync(new CancelSignal()); + await publisher.SendAsync(new ActivityCancelled(activityExecutionContext)); + } - await context.SendSignalAsync(new CancelSignal()); - await publisher.SendAsync(new ActivityCancelled(context)); - } - - /// - /// Marks the current activity as faulted, records an exception, and transitions its status to . - /// An incident is logged, and the fault count for the activity and its ancestor activities is incremented. - /// - /// The representing the execution context of the current activity. - /// The exception to be recorded as the cause of the fault. - public static void Fault(this ActivityExecutionContext context, Exception e) - { - context.Exception = e; - context.TransitionTo(ActivityStatus.Faulted); - var activity = context.Activity; - var exceptionState = ExceptionState.FromException(e); - var systemClock = context.GetRequiredService(); - var now = systemClock.UtcNow; - var incident = new ActivityIncident(activity.Id, activity.NodeId, activity.Type, e.Message, exceptionState, now); - context.WorkflowExecutionContext.Incidents.Add(incident); - context.AggregateFaultCount++; + /// + /// Marks the current activity as faulted, records an exception, and transitions its status to . + /// An incident is logged, and the fault count for the activity and its ancestor activities is incremented. + /// + /// The exception to be recorded as the cause of the fault. + public void Fault(Exception e) + { + activityExecutionContext.Exception = e; + activityExecutionContext.TransitionTo(ActivityStatus.Faulted); + var activity = activityExecutionContext.Activity; + var exceptionState = ExceptionState.FromException(e); + var systemClock = activityExecutionContext.GetRequiredService(); + var now = systemClock.UtcNow; + var incident = new ActivityIncident(activity.Id, activity.NodeId, activity.Type, e.Message, exceptionState, now); + activityExecutionContext.WorkflowExecutionContext.Incidents.Add(incident); + activityExecutionContext.AggregateFaultCount++; - var ancestors = context.GetAncestors(); + var ancestors = activityExecutionContext.GetAncestors(); - foreach (var ancestor in ancestors) - ancestor.AggregateFaultCount++; - - context.TransitionTo(ActivityStatus.Faulted); - } - - /// - /// Recovers the current activity from a faulted state by resetting fault counts and transitioning the activity to the running status. - /// - public static void RecoverFromFault(this ActivityExecutionContext context) - { - context.AggregateFaultCount = 0; - - var ancestors = context.GetAncestors(); - - foreach (var ancestor in ancestors) - ancestor.AggregateFaultCount--; - - context.TransitionTo(ActivityStatus.Running); - } - - /// - /// Returns the first context that is associated with a . - /// - public static ActivityExecutionContext? FindParentWithVariableContainer(this ActivityExecutionContext context) - { - return context.FindParent(x => x.Activity is IVariableContainer); - } - - /// - /// Returns the first context in the hierarchy that matches the specified predicate. - /// - /// The context to start searching from. - /// The predicate to match. - /// The first context that matches the predicate or null if no match was found. - public static ActivityExecutionContext? FindParent(this ActivityExecutionContext context, Func predicate) - { - var currentContext = context; - - while (currentContext != null) - { - if (predicate(currentContext)) - return currentContext; - - currentContext = currentContext.ParentActivityExecutionContext; + foreach (var ancestor in ancestors) + ancestor.AggregateFaultCount++; } - return null; - } - - /// - /// Returns a flattened list of the current context's ancestors. - /// - public static IEnumerable GetAncestors(this ActivityExecutionContext context) - { - var current = context.ParentActivityExecutionContext; - - while (current != null) + /// + /// Recovers the current activity from a faulted state by resetting fault counts and transitioning the activity to the running status. + /// + public void RecoverFromFault() { - yield return current; - current = current.ParentActivityExecutionContext; + activityExecutionContext.AggregateFaultCount = 0; + + var ancestors = activityExecutionContext.GetAncestors(); + + foreach (var ancestor in ancestors) + ancestor.AggregateFaultCount--; + + activityExecutionContext.TransitionTo(ActivityStatus.Running); } - } - /// - /// Returns a flattened list of the current context's descendants. - /// - public static IEnumerable GetDescendents(this ActivityExecutionContext context) - { - var children = context.Children.ToList(); - - foreach (var child in children) + /// + /// Returns the first context that is associated with a . + /// + public ActivityExecutionContext? FindParentWithVariableContainer() { - yield return child; - - foreach (var descendent in GetDescendents(child)) - yield return descendent; + return activityExecutionContext.FindParent(x => x.Activity is IVariableContainer); } - } - /// - /// Returns a flattened list of the current context's immediate active children. - /// - public static IEnumerable GetActiveChildren(this ActivityExecutionContext context) - { - return context.Children; - } - - /// - /// Returns a flattened list of the current context's immediate children. - /// - public static IEnumerable GetChildren(this ActivityExecutionContext context) - { - return context.Children; - } - - /// - /// Returns a flattened list of the current context's descendants. - /// - public static IEnumerable GetDescendants(this ActivityExecutionContext context) - { - var children = context.Children.ToList(); - - foreach (var child in children) + /// + /// Returns the first context in the hierarchy that matches the specified predicate. + /// + /// The predicate to match. + /// The first context that matches the predicate or null if no match was found. + public ActivityExecutionContext? FindParent(Func predicate) { - yield return child; + var currentContext = activityExecutionContext; - foreach (var descendant in child.GetDescendants()) - yield return descendant; - } - } + while (currentContext != null) + { + if (predicate(currentContext)) + return currentContext; - /// - /// Sets extension data in the metadata. Represents specific data that is exposed generically for an activity. - /// - public static void SetExtensionsMetadata(this ActivityExecutionContext context, string key, object? value) - { - var extensionsDictionary = context.GetExtensionsMetadata() ?? new Dictionary(); + currentContext = currentContext.ParentActivityExecutionContext; + } - extensionsDictionary[key] = value; - - context.Metadata[ExtensionsMetadataKey] = extensionsDictionary; - } - - /// - /// Retrieves the extension data from the metadata. Represents specific data that is exposed generically for an activity. - /// - public static Dictionary? GetExtensionsMetadata(this ActivityExecutionContext context) - { - return context.Metadata.TryGetValue(ExtensionsMetadataKey, out var value) ? value as Dictionary : null; - } - - /// - /// 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(); + /// + /// Returns a flattened list of the current context's ancestors. + /// + public IEnumerable GetAncestors() + { + var current = activityExecutionContext.ParentActivityExecutionContext; + + while (current != null) + { + yield return current; + current = current.ParentActivityExecutionContext; + } + } + + /// + /// Returns a flattened list of the current context's descendants. + /// + public IEnumerable GetDescendents() + { + var children = activityExecutionContext.Children.ToList(); + + foreach (var child in children) + { + yield return child; + + foreach (var descendent in GetDescendents(child)) + yield return descendent; + } + } + + /// + /// Returns a flattened list of the current context's immediate active children. + /// + public IEnumerable GetActiveChildren() + { + return activityExecutionContext.Children; + } + + /// + /// Returns a flattened list of the current context's immediate children. + /// + public IEnumerable GetChildren() + { + return activityExecutionContext.Children; + } + + /// + /// Returns a flattened list of the current context's descendants. + /// + public IEnumerable GetDescendants() + { + var children = activityExecutionContext.Children.ToList(); + + foreach (var child in children) + { + yield return child; + + foreach (var descendant in child.GetDescendants()) + yield return descendant; + } + } + + /// + /// Sets extension data in the metadata. Represents specific data that is exposed generically for an activity. + /// + public void SetExtensionsMetadata(string key, object? value) + { + var extensionsDictionary = activityExecutionContext.GetExtensionsMetadata() ?? new Dictionary(); + + extensionsDictionary[key] = value; + + activityExecutionContext.Metadata[ExtensionsMetadataKey] = extensionsDictionary; + } + + /// + /// Retrieves the extension data from the metadata. Represents specific data that is exposed generically for an activity. + /// + public Dictionary? GetExtensionsMetadata() + { + return activityExecutionContext.Metadata.TryGetValue(ExtensionsMetadataKey, out var value) ? value as Dictionary : null; + } + + /// + /// 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 object? GetActivityOutput(Expression> property) + { + if (property.Body is not MemberExpression memberExpr) + { + return null; + } + + var propertyName = memberExpr.Member.Name; - return registry.FindOutputByActivityInstanceId(context.Id, propertyName); + var value = activityExecutionContext.Get(propertyName); + + if (value != null) + return value; + + var registry = activityExecutionContext.WorkflowExecutionContext.GetActivityOutputRegister(); + + return registry.FindOutputByActivityInstanceId(activityExecutionContext.Id, propertyName); + } + + public bool GetHasEvaluatedProperties() => activityExecutionContext.TransientProperties.TryGetValue("HasEvaluatedProperties", out var value) && value; + public void SetHasEvaluatedProperties() => activityExecutionContext.TransientProperties["HasEvaluatedProperties"] = true; } - - 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/ActivityExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExtensions.cs index b6f033699..9b3ab5c10 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExtensions.cs @@ -15,175 +15,177 @@ namespace Elsa.Extensions; [PublicAPI] public static class ActivityExtensions { - /// - /// Gets the input properties of the specified activity. - /// - public static IDictionary GetNamedInputs(this IActivity activity) - { - var inputProps = activity.GetInputProperties().ToList(); - - var query = - from inputProp in inputProps - let inputValue = (Input?)inputProp.GetValue(activity) - where inputValue != null - select (inputProp, inputValue); - - return query.DistinctBy(x => x.inputProp.Name).ToDictionary(x => x.inputProp.Name, x => x.inputValue); - } - - /// - /// Gets the input properties of the specified activity. - /// - public static IEnumerable GetInputs(this IActivity activity) => GetNamedInputs(activity).Values; - - /// - /// Gets the input with the specified name. - /// - public static Input? GetInput(this IActivity activity, string inputName) => GetNamedInputs(activity).TryGetValue(inputName, out var input) ? input : null; - - /// - /// Gets the output properties of the specified activity. - /// - public static IEnumerable GetOutputs(this IActivity activity) - { - var outputProps = activity.GetType().GetProperties().Where(x => typeof(Output).IsAssignableFrom(x.PropertyType)).ToList(); - - var query = - from outputProp in outputProps - let output = (Output?)outputProp.GetValue(activity) - where output != null - select new NamedOutput(outputProp.Name, output); - - return query.Select(x => x!).ToList(); - } - - /// - /// Gets the output with the specified name. - /// /// The activity to get the output from. - /// The activity execution context. - /// Name of the output. - /// The output value. - public static object? GetOutput(this IActivity activity, ActivityExecutionContext context, string? outputName = null) + extension(IActivity activity) { - var workflowExecutionContext = context.WorkflowExecutionContext; - var outputRegister = workflowExecutionContext.GetActivityOutputRegister(); - - // If the provided activity execution context is the same as the current activity's execution context, we return the exact output value of the current activity execution context. - if(context.Activity.NodeId == activity.NodeId) - return outputRegister.FindOutputByActivityInstanceId(context.Id, outputName); - - // If the provided activity execution context is different from the current activity's execution context, we look for the last output value of the activity. - return outputRegister.FindOutputByActivityId(activity.Id, outputName); - } - - /// - /// Gets the output with the specified name. - /// - /// The activity to get the output from. - /// The expression execution context. - /// Name of the output. - /// The output value. - public static object? GetOutput(this IActivity activity, ExpressionExecutionContext context, string? outputName = null) - { - var activityExecutionContext = context.GetActivityExecutionContext(); + /// + /// Gets the input properties of the specified activity. + /// + public IDictionary GetNamedInputs() + { + var inputProps = activity.GetInputProperties().ToList(); - if (activityExecutionContext == null) - return null; + var query = + from inputProp in inputProps + let inputValue = (Input?)inputProp.GetValue(activity) + where inputValue != null + select (inputProp, inputValue); + + return query.DistinctBy(x => x.inputProp.Name).ToDictionary(x => x.inputProp.Name, x => x.inputValue); + } + + /// + /// Gets the input properties of the specified activity. + /// + public IEnumerable GetInputs() => GetNamedInputs(activity).Values; + + /// + /// Gets the input with the specified name. + /// + public Input? GetInput(string inputName) => GetNamedInputs(activity).TryGetValue(inputName, out var input) ? input : null; + + /// + /// Gets the output properties of the specified activity. + /// + public IEnumerable GetOutputs() + { + var outputProps = activity.GetType().GetProperties().Where(x => typeof(Output).IsAssignableFrom(x.PropertyType)).ToList(); + + var query = + from outputProp in outputProps + let output = (Output?)outputProp.GetValue(activity) + where output != null + select new NamedOutput(outputProp.Name, output); + + return query.Select(x => x!).ToList(); + } + + /// + /// Gets the output with the specified name. + /// + /// The activity execution context. + /// Name of the output. + /// The output value. + public object? GetOutput(ActivityExecutionContext context, string? outputName = null) + { + var workflowExecutionContext = context.WorkflowExecutionContext; + var outputRegister = workflowExecutionContext.GetActivityOutputRegister(); - return activity.GetOutput(activityExecutionContext, outputName); + // If the provided activity execution context is the same as the current activity's execution context, we return the exact output value of the current activity execution context. + if(context.Activity.NodeId == activity.NodeId) + return outputRegister.FindOutputByActivityInstanceId(context.Id, outputName); + + // If the provided activity execution context is different from the current activity's execution context, we look for the last output value of the activity. + return outputRegister.FindOutputByActivityId(activity.Id, outputName); + } + + /// + /// Gets the output with the specified name. + /// + /// The expression execution context. + /// Name of the output. + /// The output value. + public object? GetOutput(ExpressionExecutionContext context, string? outputName = null) + { + var activityExecutionContext = context.GetActivityExecutionContext(); + + if (activityExecutionContext == null) + return null; + + return activity.GetOutput(activityExecutionContext, outputName); + } + + /// + /// Gets the output with the specified name. + /// + /// The context. + /// Name of the output. + /// The type of the output. + /// The output value. + public T? GetOutput(ActivityExecutionContext context, string outputName) + { + var outputValue = activity.GetOutput(context, outputName); + return outputValue == null ? default! : outputValue.ConvertTo(); + } + + /// + /// Gets the output with the specified name. + /// + /// The context. + /// Name of the output. + /// The type of the output. + /// The output value. + public T? GetOutput(ExpressionExecutionContext context, string outputName) + { + return activity.GetOutput(context.GetActivityExecutionContext(), outputName); + } } - /// - /// Gets the output with the specified name. - /// /// The activity. - /// The context. - /// Name of the output. - /// The type of the output. - /// The output value. - public static T? GetOutput(this IActivity activity, ActivityExecutionContext context, string outputName) - { - var outputValue = activity.GetOutput(context, outputName); - return outputValue == null ? default! : outputValue.ConvertTo(); - } - - /// - /// Gets the output with the specified name. - /// - /// The activity. - /// The context. - /// Name of the output. - /// The type of the output. - /// The output value. - public static T? GetOutput(this IActivity activity, ExpressionExecutionContext context, string outputName) - { - return activity.GetOutput(context.GetActivityExecutionContext(), outputName); - } - - /// - /// Gets the output with the specified name. - /// - /// The activity. - /// The context. - /// The output expression. /// The type of the activity. - /// The type of the output. - /// The output value. - public static T? GetOutput(this TActivity activity, ActivityExecutionContext context, Expression> outputExpression) + extension(TActivity activity) { - var outputName = outputExpression.GetPropertyName(); - return ((IActivity)activity!).GetOutput(context, outputName); - } - - /// - /// Gets the output with the specified name. - /// - /// The activity. - /// The context. - /// The output expression. - /// The type of the activity. - /// The type of the output. - /// The output value. - public static T? GetOutput(this TActivity activity, ExpressionExecutionContext context, Expression> outputExpression) - { - var outputName = outputExpression.GetPropertyName(); - return ((IActivity)activity!).GetOutput(context, outputName); + /// + /// Gets the output with the specified name. + /// + /// The context. + /// The output expression. + /// The type of the output. + /// The output value. + public T? GetOutput(ActivityExecutionContext context, Expression> outputExpression) + { + var outputName = outputExpression.GetPropertyName(); + return ((IActivity)activity!).GetOutput(context, outputName); + } + + /// + /// Gets the output with the specified name. + /// + /// The context. + /// The output expression. + /// The type of the output. + /// The output value. + public T? GetOutput(ExpressionExecutionContext context, Expression> outputExpression) + { + var outputName = outputExpression.GetPropertyName(); + return ((IActivity)activity!).GetOutput(context, outputName); + } } - /// - /// Gets the Result output of the specified activity. - /// /// The activity. - /// The context. - /// The type as which to return the output. - /// The output value. - public static T? GetResult(this IActivity activity, ExpressionExecutionContext context) + extension(IActivity activity) { - var value = GetResult(activity, context); - return value == null ? default! : value.ConvertTo(); - } - - /// - /// Gets the Result output of the specified activity. - /// - /// The activity. - /// The context. - /// The output value. - public static object? GetResult(this IActivity activity, ExpressionExecutionContext context) - { - return activity.GetResult(context.GetActivityExecutionContext()); - } + /// + /// Gets the Result output of the specified activity. + /// + /// The context. + /// The type as which to return the output. + /// The output value. + public T? GetResult(ExpressionExecutionContext context) + { + var value = GetResult(activity, context); + return value == null ? default! : value.ConvertTo(); + } - /// - /// Gets the Result output of the specified activity. - /// - /// The activity. - /// The context. - /// The output value. - public static object? GetResult(this IActivity activity, ActivityExecutionContext context) - { - return activity.GetOutput(context, ActivityOutputRegister.DefaultOutputName); + /// + /// Gets the Result output of the specified activity. + /// + /// The context. + /// The output value. + public object? GetResult(ExpressionExecutionContext context) + { + return activity.GetResult(context.GetActivityExecutionContext()); + } + + /// + /// Gets the Result output of the specified activity. + /// + /// The context. + /// The output value. + public object? GetResult(ActivityExecutionContext context) + { + return activity.GetOutput(context, ActivityOutputRegister.DefaultOutputName); + } } /// @@ -225,40 +227,43 @@ public static class ActivityExtensions return context.GetLastActivityResult(); } - /// - /// Gets the input properties of the specified activity. - /// - public static IEnumerable GetInputProperties(this IActivity activity) => activity.GetType().GetProperties().Where(x => typeof(Input).IsAssignableFrom(x.PropertyType)).ToList(); - - /// - /// Gets the method for the specified method name on the specified activity. - /// - public static TDelegate GetDelegate(this IActivity activity, string methodName) where TDelegate : Delegate + extension(IActivity activity) { - var activityType = activity.GetType(); - const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy; - var resumeMethodInfo = default(MethodInfo?); - var currentType = activityType; + /// + /// Gets the input properties of the specified activity. + /// + public IEnumerable GetInputProperties() => activity.GetType().GetProperties().Where(x => typeof(Input).IsAssignableFrom(x.PropertyType)).ToList(); - while (currentType != null && resumeMethodInfo == null) + /// + /// Gets the method for the specified method name on the specified activity. + /// + public TDelegate GetDelegate(string methodName) where TDelegate : Delegate { - resumeMethodInfo = currentType.GetMethod(methodName, bindingFlags); - currentType = currentType.BaseType; + var activityType = activity.GetType(); + const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy; + var resumeMethodInfo = default(MethodInfo?); + var currentType = activityType; + + while (currentType != null && resumeMethodInfo == null) + { + resumeMethodInfo = currentType.GetMethod(methodName, bindingFlags); + currentType = currentType.BaseType; + } + + if (resumeMethodInfo == null) + throw new Exception($"Can't find method name {methodName} on type {activityType} or its base type {activityType.BaseType}"); + + return resumeMethodInfo.IsStatic ? (TDelegate)Delegate.CreateDelegate(typeof(TDelegate), resumeMethodInfo) : (TDelegate)Delegate.CreateDelegate(typeof(TDelegate), activity, resumeMethodInfo); } - if (resumeMethodInfo == null) - throw new Exception($"Can't find method name {methodName} on type {activityType} or its base type {activityType.BaseType}"); + /// + /// Gets the Resume method for the specified activity. + /// + public ExecuteActivityDelegate GetResumeActivityDelegate(string resumeMethodName) => activity.GetDelegate(resumeMethodName); - return resumeMethodInfo.IsStatic ? (TDelegate)Delegate.CreateDelegate(typeof(TDelegate), resumeMethodInfo) : (TDelegate)Delegate.CreateDelegate(typeof(TDelegate), activity, resumeMethodInfo); + /// + /// Gets the Child Activity Completed method for the specified activity. + /// + public ActivityCompletionCallback GetActivityCompletionCallback(string completionMethodName) => activity.GetDelegate(completionMethodName); } - - /// - /// Gets the Resume method for the specified activity. - /// - public static ExecuteActivityDelegate GetResumeActivityDelegate(this IActivity driver, string resumeMethodName) => driver.GetDelegate(resumeMethodName); - - /// - /// Gets the Child Activity Completed method for the specified activity. - /// - public static ActivityCompletionCallback GetActivityCompletionCallback(this IActivity driver, string completionMethodName) => driver.GetDelegate(completionMethodName); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs index 938134cc1..e2d65a532 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs @@ -13,49 +13,52 @@ public static class ActivityPropertyExtensions private static readonly string[] SourcePropertyName = ["source", "Source"]; private static readonly string[] CommitStrategyName = ["commitStrategyName", "CommitStrategyName"]; - /// - /// Gets a flag indicating whether this activity can be used for starting a workflow. - /// Usually used for triggers, but also used to disambiguate between two or more starting activities and no starting activity was specified. - /// - public static bool GetCanStartWorkflow(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(CanStartWorkflowPropertyName, () => false); - - /// - /// Sets a flag indicating whether this activity can be used for starting a workflow. - /// - public static void SetCanStartWorkflow(this IActivity activity, bool value) => activity.CustomProperties[CanStartWorkflowPropertyName[0]] = value; - - /// - /// Gets a flag indicating if this activity should execute synchronously or asynchronously. - /// By default, activities with an of , or - /// will execute synchronously, while activities of the kind will execute asynchronously. - /// - public static bool? GetRunAsynchronously(this IActivity activity) + extension(IActivity activity) { - return activity.CustomProperties.GetValueOrDefault(RunAsynchronouslyPropertyName, defaultValueFactory: () => null); + /// + /// Gets a flag indicating whether this activity can be used for starting a workflow. + /// Usually used for triggers, but also used to disambiguate between two or more starting activities and no starting activity was specified. + /// + public bool GetCanStartWorkflow() => activity.CustomProperties.GetValueOrDefault(CanStartWorkflowPropertyName, () => false); + + /// + /// Sets a flag indicating whether this activity can be used for starting a workflow. + /// + public void SetCanStartWorkflow(bool value) => activity.CustomProperties[CanStartWorkflowPropertyName[0]] = value; + + /// + /// Gets a flag indicating if this activity should execute synchronously or asynchronously. + /// By default, activities with an of , or + /// will execute synchronously, while activities of the kind will execute asynchronously. + /// + public bool? GetRunAsynchronously() + { + return activity.CustomProperties.GetValueOrDefault(RunAsynchronouslyPropertyName, defaultValueFactory: () => null); + } + + /// + /// Sets a flag indicating if this activity should execute synchronously or asynchronously. + /// By default, activities with an of , or + /// will execute synchronously, while activities of the kind will execute asynchronously. + /// + public void SetRunAsynchronously(bool? value) => activity.CustomProperties[RunAsynchronouslyPropertyName[0]] = value ?? false; + + /// + /// Gets the source file and line number where this activity was instantiated, if any. + /// + public string? GetSource() => activity.CustomProperties.GetValueOrDefault(SourcePropertyName, () => default(string?)); + + /// + /// Sets the source file and line number where this activity was instantiated, if any. + /// + public void SetSource(string value) => activity.CustomProperties[SourcePropertyName[0]] = value; + + /// + /// Gets the commit state behavior for the specified activity. + /// + public string? GetCommitStrategy() => activity.CustomProperties.GetValueOrDefault(CommitStrategyName, () => null); } - /// - /// Sets a flag indicating if this activity should execute synchronously or asynchronously. - /// By default, activities with an of , or - /// will execute synchronously, while activities of the kind will execute asynchronously. - /// - public static void SetRunAsynchronously(this IActivity activity, bool? value) => activity.CustomProperties[RunAsynchronouslyPropertyName[0]] = value ?? false; - - /// - /// Gets the source file and line number where this activity was instantiated, if any. - /// - public static string? GetSource(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(SourcePropertyName, () => default(string?)); - - /// - /// Sets the source file and line number where this activity was instantiated, if any. - /// - public static void SetSource(this IActivity activity, string value) => activity.CustomProperties[SourcePropertyName[0]] = value; - - /// - /// Gets the commit state behavior for the specified activity. - /// - public static string? GetCommitStrategy(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(CommitStrategyName, () => null); - /// /// Sets the commit state behavior for the specified activity. /// @@ -68,35 +71,38 @@ public static class ActivityPropertyExtensions return activity; } - /// - /// Sets the source file and line number where this activity was instantiated, if any. - /// - public static void SetSource(this IActivity activity, string? sourceFile, int? lineNumber) + extension(IActivity activity) { - if (sourceFile == null || lineNumber == null) - return; + /// + /// Sets the source file and line number where this activity was instantiated, if any. + /// + public void SetSource(string? sourceFile, int? lineNumber) + { + if (sourceFile == null || lineNumber == null) + return; - var source = $"{Path.GetFileName(sourceFile)}:{lineNumber}"; - activity.SetSource(source); + var source = $"{Path.GetFileName(sourceFile)}:{lineNumber}"; + activity.SetSource(source); + } + + /// + /// Gets the display text for the specified activity. + /// + public string? GetDisplayText() => activity.Metadata.TryGetValue("displayText", out var value) ? value.ToString() : null; + + /// + /// Sets the display text for the specified activity. + /// + public void SetDisplayText(string value) => activity.Metadata["displayText"] = value; + + /// + /// Gets the description for the specified activity. + /// + public string? GetDescription() => activity.Metadata.TryGetValue("description", out var value) ? value.ToString() : null; + + /// + /// Sets the description for the specified activity. + /// + public void SetDescription(string value) => activity.Metadata["description"] = value; } - - /// - /// Gets the display text for the specified activity. - /// - public static string? GetDisplayText(this IActivity activity) => activity.Metadata.TryGetValue("displayText", out var value) ? value.ToString() : null; - - /// - /// Sets the display text for the specified activity. - /// - public static void SetDisplayText(this IActivity activity, string value) => activity.Metadata["displayText"] = value; - - /// - /// Gets the description for the specified activity. - /// - public static string? GetDescription(this IActivity activity) => activity.Metadata.TryGetValue("description", out var value) ? value.ToString() : null; - - /// - /// Sets the description for the specified activity. - /// - public static void SetDescription(this IActivity activity, string value) => activity.Metadata["description"] = value; } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityRegistryExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityRegistryExtensions.cs index 5c467dbe0..6c26748e3 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityRegistryExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityRegistryExtensions.cs @@ -15,17 +15,20 @@ public static class ActivityRegistryExtensions /// public static Task FindAsync(this IActivityRegistryLookupService activityRegistry, IActivity activity) => activityRegistry.FindAsync(activity.Type, activity.Version); - /// - /// Finds the activity descriptor for the specified activity type. - /// - public static ActivityDescriptor? Find(this IActivityRegistry activityRegistry) where T:IActivity => activityRegistry.Find(ActivityTypeNameHelper.GenerateTypeName()); - - /// - /// Registers the specified activity type with the registry. - /// /// The activity registry. - /// The cancellation token. - /// The type of the activity to register. - public static async Task RegisterAsync(this IActivityRegistry activityRegistry, CancellationToken cancellationToken = default) where T : IActivity => - await activityRegistry.RegisterAsync(typeof(T), cancellationToken); + extension(IActivityRegistry activityRegistry) + { + /// + /// Finds the activity descriptor for the specified activity type. + /// + public ActivityDescriptor? Find() where T:IActivity => activityRegistry.Find(ActivityTypeNameHelper.GenerateTypeName()); + + /// + /// Registers the specified activity type with the registry. + /// + /// The cancellation token. + /// The type of the activity to register. + public async Task RegisterAsync(CancellationToken cancellationToken = default) where T : IActivity => + await activityRegistry.RegisterAsync(typeof(T), cancellationToken); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/BackgroundActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/BackgroundActivityExecutionContextExtensions.cs index a446f237b..369685b03 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/BackgroundActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/BackgroundActivityExecutionContextExtensions.cs @@ -11,71 +11,74 @@ public static class BackgroundActivityExecutionContextExtensions /// A key into the activity execution context's transient properties that indicates whether the current activity is being executed in the background. /// public static readonly object IsBackgroundExecution = new(); - - /// - /// Configures the activity execution context to execute the current activity in the background. - /// - public static void SetIsBackgroundExecution(this ActivityExecutionContext activityExecutionContext, bool value = true) - { - activityExecutionContext.TransientProperties[IsBackgroundExecution] = value; - } - - /// - /// Gets a value indicating whether the current activity is being executed in the background. - /// - public static bool GetIsBackgroundExecution(this ActivityExecutionContext activityExecutionContext) - { - return activityExecutionContext.TransientProperties.ContainsKey(IsBackgroundExecution); - } - - /// - /// Sets the background outcomes. - /// - public static void SetBackgroundOutcomes(this ActivityExecutionContext activityExecutionContext, IEnumerable outcomes) - { - var outcomesList = outcomes.ToList(); - activityExecutionContext.SetProperty("BackgroundOutcomes", outcomesList); - } - - /// - /// Gets the background outcomes. - /// - public static IEnumerable? GetBackgroundOutcomes(this ActivityExecutionContext activityExecutionContext) - { - return activityExecutionContext.GetProperty>("BackgroundOutcomes"); - } - - /// - /// Sets the background outcomes. - /// - public static void SetBackgroundCompletion(this ActivityExecutionContext activityExecutionContext) - { - activityExecutionContext.SetProperty("BackgroundCompletion", true); - } - - /// - /// Gets the background outcomes. - /// - public static bool? GetBackgroundCompleted(this ActivityExecutionContext activityExecutionContext) - { - return activityExecutionContext.GetProperty("BackgroundCompletion"); - } - - /// - /// Sets the background scheduled activities. - /// - public static void SetBackgroundScheduledActivities(this ActivityExecutionContext activityExecutionContext, IEnumerable scheduledActivities) - { - var scheduledActivitiesList = scheduledActivities.ToList(); - activityExecutionContext.SetProperty("BackgroundScheduledActivities", scheduledActivitiesList); - } - - /// - /// Gets the background scheduled activities. - /// + /// - public static IEnumerable GetBackgroundScheduledActivities(this ActivityExecutionContext activityExecutionContext) + extension(ActivityExecutionContext activityExecutionContext) { - return activityExecutionContext.GetProperty>("BackgroundScheduledActivities") ?? []; + /// + /// Configures the activity execution context to execute the current activity in the background. + /// + public void SetIsBackgroundExecution(bool value = true) + { + activityExecutionContext.TransientProperties[IsBackgroundExecution] = value; + } + + /// + /// Gets a value indicating whether the current activity is being executed in the background. + /// + public bool GetIsBackgroundExecution() + { + return activityExecutionContext.TransientProperties.ContainsKey(IsBackgroundExecution); + } + + /// + /// Sets the background outcomes. + /// + public void SetBackgroundOutcomes(IEnumerable outcomes) + { + var outcomesList = outcomes.ToList(); + activityExecutionContext.SetProperty("BackgroundOutcomes", outcomesList); + } + + /// + /// Gets the background outcomes. + /// + public IEnumerable? GetBackgroundOutcomes() + { + return activityExecutionContext.GetProperty>("BackgroundOutcomes"); + } + + /// + /// Sets the background outcomes. + /// + public void SetBackgroundCompletion() + { + activityExecutionContext.SetProperty("BackgroundCompletion", true); + } + + /// + /// Gets the background outcomes. + /// + public bool? GetBackgroundCompleted() + { + return activityExecutionContext.GetProperty("BackgroundCompletion"); + } + + /// + /// Sets the background scheduled activities. + /// + public void SetBackgroundScheduledActivities(IEnumerable scheduledActivities) + { + var scheduledActivitiesList = scheduledActivities.ToList(); + activityExecutionContext.SetProperty("BackgroundScheduledActivities", scheduledActivitiesList); + } + + /// + /// Gets the background scheduled activities. + /// + public IEnumerable GetBackgroundScheduledActivities() + { + return activityExecutionContext.GetProperty>("BackgroundScheduledActivities") ?? []; + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/BehaviorCollectionExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/BehaviorCollectionExtensions.cs index 712d370d1..d5c3d4d35 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/BehaviorCollectionExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/BehaviorCollectionExtensions.cs @@ -5,12 +5,15 @@ namespace Elsa.Extensions; public static class BehaviorCollectionExtensions { - public static void Add(this ICollection behaviors, IActivity owner) where T : IBehavior => behaviors.Add((T)Activator.CreateInstance(typeof(T), owner)!); - - public static void Remove(this ICollection behaviors) where T : IBehavior + extension(ICollection behaviors) { - var behaviorsToRemove = behaviors.Where(x => x is T).ToList(); + public void Add(IActivity owner) where T : IBehavior => behaviors.Add((T)Activator.CreateInstance(typeof(T), owner)!); - foreach (var behavior in behaviorsToRemove) behaviors.Remove(behavior); + public void Remove() where T : IBehavior + { + var behaviorsToRemove = behaviors.Where(x => x is T).ToList(); + + foreach (var behavior in behaviorsToRemove) behaviors.Remove(behavior); + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/BookmarkExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/BookmarkExtensions.cs index 2a7f20c7b..33cb24ca2 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/BookmarkExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/BookmarkExtensions.cs @@ -10,23 +10,26 @@ namespace Elsa.Extensions; /// public static class BookmarkExtensions { - /// - /// Filters the specified set of bookmarks by the specified activity type. - /// /// The set of bookmarks to filter. - /// The activity type to filter by. - /// The filtered set of bookmarks. - public static IEnumerable Filter(this IEnumerable bookmarks) where T : IActivity + extension(IEnumerable bookmarks) { - var bookmarkName = ActivityTypeNameHelper.GenerateTypeName(); - return bookmarks.Filter(bookmarkName); + /// + /// Filters the specified set of bookmarks by the specified activity type. + /// + /// The activity type to filter by. + /// The filtered set of bookmarks. + public IEnumerable Filter() where T : IActivity + { + var bookmarkName = ActivityTypeNameHelper.GenerateTypeName(); + return bookmarks.Filter(bookmarkName); + } + + public IEnumerable Filter(string name) + { + return bookmarks.Where(x => x.Name == name); + } } - - public static IEnumerable Filter(this IEnumerable bookmarks, string name) - { - return bookmarks.Where(x => x.Name == name); - } - + /// /// Returns the Data property of the bookmark as a strongly-typed object. /// diff --git a/src/modules/Elsa.Workflows.Core/Extensions/BreakBehaviorActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/BreakBehaviorActivityExecutionContextExtensions.cs index 8fa3f170e..9dcbadbad 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/BreakBehaviorActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/BreakBehaviorActivityExecutionContextExtensions.cs @@ -5,14 +5,17 @@ namespace Elsa.Workflows; /// public static class BreakBehaviorActivityExecutionContextExtensions { - /// - /// Gets a value indicating whether the current activity is breaking out of a loop. - /// - public static bool GetIsBreaking(this ActivityExecutionContext context) => context.GetProperty("IsBreaking"); - - /// - /// Sets a value indicating whether the current activity is breaking out of a loop. - /// /// - public static void SetIsBreaking(this ActivityExecutionContext context) => context.SetProperty("IsBreaking", true); + extension(ActivityExecutionContext context) + { + /// + /// Gets a value indicating whether the current activity is breaking out of a loop. + /// + public bool GetIsBreaking() => context.GetProperty("IsBreaking"); + + /// + /// Sets a value indicating whether the current activity is breaking out of a loop. + /// + public void SetIsBreaking() => context.SetProperty("IsBreaking", true); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/DictionaryExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/DictionaryExtensions.cs index 9aae63257..d8bf45d96 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/DictionaryExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/DictionaryExtensions.cs @@ -6,8 +6,12 @@ namespace Elsa.Extensions; public static class DictionaryExtensions { - public static bool TryGetValue(this IDictionary dictionary, string key, out T value) => dictionary.TryGetValue(key, out value); - public static bool TryGetValue(this IDictionary dictionary, IEnumerable keys, out T value) => dictionary.TryGetValue(keys, out value); + extension(IDictionary dictionary) + { + public bool TryGetValue(string key, out T value) => dictionary.TryGetValue(key, out value); + public bool TryGetValue(IEnumerable keys, out T value) => dictionary.TryGetValue(keys, out value); + } + public static bool TryGetValue(this IDictionary dictionary, string key, out T value) => dictionary.TryGetValue(key, out value); public static bool TryGetValue(this IDictionary dictionary, TKey key, out T value) @@ -22,46 +26,56 @@ public static class DictionaryExtensions return true; } - public static bool TryGetValue(this IDictionary dictionary, TKey key, out T value) + extension(IDictionary dictionary) { - if (!dictionary.TryGetValue(key, out var item)) + public bool TryGetValue(TKey key, out T value) { + if (!dictionary.TryGetValue(key, out var item)) + { + value = default!; + return false; + } + + var result = TryConvertValue(item); + value = result.Success ? (T)result.Value! : default!; + return result.Success; + } + + public bool TryGetValue(IEnumerable keys, out T value) + { + foreach (var key in keys) + { + if (dictionary.TryGetValue(key, out var item)) + { + var result = TryConvertValue(item); + value = result.Success ? (T)result.Value! : default!; + return result.Success; + } + } + value = default!; return false; } - - var result = TryConvertValue(item); - value = result.Success ? (T)result.Value! : default!; - return result.Success; - } - - public static bool TryGetValue(this IDictionary dictionary, IEnumerable keys, out T value) - { - foreach (var key in keys) - { - if (dictionary.TryGetValue(key, out var item)) - { - var result = TryConvertValue(item); - value = result.Success ? (T)result.Value! : default!; - return result.Success; - } - } - - value = default!; - return false; } public static T? GetValue(this IDictionary dictionary, TKey key) => ConvertValue(dictionary[key]); public static T? GetValue(this IDictionary dictionary, string key) => ConvertValue(dictionary[key]); public static T? GetValueOrDefault(this IDictionary dictionary, TKey key, Func defaultValueFactory) => TryGetValue(dictionary, key, out var value) ? value : defaultValueFactory(); - public static T? GetValueOrDefault(this IDictionary dictionary, TKey key, Func defaultValueFactory) => TryGetValue(dictionary, key, out var value) ? value : defaultValueFactory(); - public static T? GetValueOrDefault(this IDictionary dictionary, TKey key) => GetValueOrDefault(dictionary, key, () => default); - public static T? GetValueOrDefault(this IDictionary dictionary, string key, Func defaultValueFactory) => TryGetValue(dictionary, key, out var value) ? value : defaultValueFactory(); - public static T? GetValueOrDefault(this IDictionary dictionary, IEnumerable keys, Func defaultValueFactory) => TryGetValue(dictionary, keys, out var value) ? value : defaultValueFactory(); - public static T? GetValueOrDefault(this IDictionary dictionary, string key) => GetValueOrDefault(dictionary, key, () => default); - public static object? GetValueOrDefault(this IDictionary dictionary, string key) => GetValueOrDefault(dictionary, key, () => null); - + extension(IDictionary dictionary) + { + public T? GetValueOrDefault(TKey key, Func defaultValueFactory) => TryGetValue(dictionary, key, out var value) ? value : defaultValueFactory(); + public T? GetValueOrDefault(TKey key) => GetValueOrDefault(dictionary, key, () => default); + } + + extension(IDictionary dictionary) + { + public T? GetValueOrDefault(string key, Func defaultValueFactory) => TryGetValue(dictionary, key, out var value) ? value : defaultValueFactory(); + public T? GetValueOrDefault(IEnumerable keys, Func defaultValueFactory) => TryGetValue(dictionary, keys, out var value) ? value : defaultValueFactory(); + public T? GetValueOrDefault(string key) => GetValueOrDefault(dictionary, key, () => default); + public object? GetValueOrDefault(string key) => GetValueOrDefault(dictionary, key, () => null); + } + public static T GetOrAdd(this IDictionary dictionary, TKey key, Func valueFactory) { if(dictionary.TryGetValue(key, out T? value)) @@ -82,22 +96,25 @@ public static class DictionaryExtensions return value; } - public static IDictionary AddInput(this IDictionary dictionary, T value) where T : notnull => dictionary.AddInput(typeof(T).Name, value); + extension(IDictionary dictionary) + { + public IDictionary AddInput(T value) where T : notnull => dictionary.AddInput(typeof(T).Name, value); - public static IDictionary AddInput(this IDictionary dictionary, string key, object value) - { - dictionary.Add(key, value); - return dictionary; - } - - /// - /// Merges the specified dictionary with the other dictionary. - /// When a key exists in both dictionaries, the value in the other dictionary will overwrite the value in the specified dictionary. - /// - public static void Merge(this IDictionary dictionary, IDictionary other) - { - foreach (var (key, value) in other) - dictionary[key] = value; + public IDictionary AddInput(string key, object value) + { + dictionary.Add(key, value); + return dictionary; + } + + /// + /// Merges the specified dictionary with the other dictionary. + /// When a key exists in both dictionaries, the value in the other dictionary will overwrite the value in the specified dictionary. + /// + public void Merge(IDictionary other) + { + foreach (var (key, value) in other) + dictionary[key] = value; + } } private static T? ConvertValue(object? value) => value.ConvertTo(); diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionEvaluatorExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionEvaluatorExtensions.cs index bcd770d1e..8367cd216 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionEvaluatorExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionEvaluatorExtensions.cs @@ -10,19 +10,22 @@ namespace Elsa.Extensions; /// public static class ExpressionEvaluatorExtensions { - /// - /// Evaluates the specified expression and returns the result. - /// - public static ValueTask EvaluateAsync(this IExpressionEvaluator evaluator, Input input, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default) + extension(IExpressionEvaluator evaluator) { - return evaluator.EvaluateAsync(input.Expression!, context, options); - } + /// + /// Evaluates the specified expression and returns the result. + /// + public ValueTask EvaluateAsync(Input input, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default) + { + return evaluator.EvaluateAsync(input.Expression!, context, options); + } - /// - /// Evaluates the specified expression and returns the result. - /// - public static ValueTask EvaluateAsync(this IExpressionEvaluator evaluator, Input input, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default) - { - return evaluator.EvaluateAsync(input.Expression!, input.Type, context, options); + /// + /// Evaluates the specified expression and returns the result. + /// + public ValueTask EvaluateAsync(Input input, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default) + { + return evaluator.EvaluateAsync(input.Expression!, input.Type, context, options); + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs index aba7ae1ac..b3f149017 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs @@ -66,307 +66,309 @@ public static class ExpressionExecutionContextExtensions [InputKey] = input }; - /// - /// Returns the of the specified - /// - public static bool TryGetWorkflowExecutionContext(this ExpressionExecutionContext context, out WorkflowExecutionContext workflowExecutionContext) => context.TransientProperties.TryGetValue(WorkflowExecutionContextKey, out workflowExecutionContext!); - - /// - /// Returns the of the specified - /// - public static WorkflowExecutionContext GetWorkflowExecutionContext(this ExpressionExecutionContext context) => (WorkflowExecutionContext)context.TransientProperties[WorkflowExecutionContextKey]; - - /// - /// Returns the of the specified - /// - public static ActivityExecutionContext GetActivityExecutionContext(this ExpressionExecutionContext context) => (ActivityExecutionContext)context.TransientProperties[ActivityExecutionContextKey]; - - /// - /// Returns the of the specified - /// - public static bool TryGetActivityExecutionContext(this ExpressionExecutionContext context, out ActivityExecutionContext activityExecutionContext) => context.TransientProperties.TryGetValue(ActivityExecutionContextKey, out activityExecutionContext!); - - /// - /// Returns the of the specified - /// - public static IActivity GetActivity(this ExpressionExecutionContext context) => (IActivity)context.TransientProperties[ActivityKey]; - - /// - /// Returns the value of the specified input. - /// - public static T? Get(this ExpressionExecutionContext context, Input? input) => input != null ? context.GetBlock(input.MemoryBlockReference).Value.ConvertTo() : default; - - /// - /// Returns the value of the specified output. - /// - public static T? Get(this ExpressionExecutionContext context, Output output) => context.GetBlock(output.MemoryBlockReference).Value.ConvertTo(); - - /// - /// Returns the value of the specified output. - /// - public static object? Get(this ExpressionExecutionContext context, Output output) => context.GetBlock(output.MemoryBlockReference).Value; - - /// - /// Returns the value of the variable with the specified name. - /// - public static T? GetVariable(this ExpressionExecutionContext context, string name) - { - var block = context.GetVariableBlock(name); - return (T?)block?.Value; - } - - /// - /// Returns the variable with the specified name. - /// - public static Variable? GetVariable(this ExpressionExecutionContext context, string name, bool localScopeOnly = false) - { - var block = context.GetVariableBlock(name, localScopeOnly); - return block?.Metadata is VariableBlockMetadata metadata ? metadata.Variable : null; - } - - private static MemoryBlock? GetVariableBlock(this ExpressionExecutionContext context, string name, bool localScopeOnly = false) - { - foreach (var block in context.Memory.Blocks.Where(b => b.Value.Metadata is VariableBlockMetadata)) - { - var metadata = block.Value.Metadata as VariableBlockMetadata; - if (metadata!.Variable.Name == name) - return block.Value; - } - - return localScopeOnly ? null : context.ParentContext?.GetVariableBlock(name); - } - - /// - /// Creates a named variable in the context. - /// - public static Variable CreateVariable(this ExpressionExecutionContext context, string name, T? value, Type? storageDriverType = null, Action? configure = null) - { - var existingVariable = context.GetVariable(name, localScopeOnly: true); - - if (existingVariable != null) - throw new($"Variable {name} already exists in the context."); - - var variable = new Variable(name, value) - { - StorageDriverType = storageDriverType ?? typeof(WorkflowInstanceStorageDriver) - }; - - var parsedValue = variable.ParseValue(value); - - // Find the first parent context that has a variable container. - // If not found, use the current context. - var variableContainerContext = context.GetVariableContainerContext(); - - variableContainerContext.Set(variable, parsedValue, configure); - return variable; - } - - /// - /// Returns the first parent context that contains a variable container. - /// - public static ExpressionExecutionContext GetVariableContainerContext(this ExpressionExecutionContext context) - { - return context.FindParent(x => - { - var activityExecutionContext = x.TryGetActivityExecutionContext(out var activityExecutionContextResult) ? activityExecutionContextResult : null; - return activityExecutionContext?.Activity is IVariableContainer; - }) ?? context; - } - - /// - /// Sets the value of a named variable in the context. - /// - public static Variable SetVariable(this ExpressionExecutionContext context, string name, T? value, Action? configure = null) - { - var variable = context.GetVariable(name); - - if (variable == null) - return CreateVariable(context, name, value, configure: configure); - - // Get the context where the variable is defined. - var contextWithVariable = context.FindContextContainingBlock(variable.Id) ?? context; - - // Set the value on the variable. - var parsedValue = variable.ParseValue(value); - variable.Set(contextWithVariable, parsedValue, configure); - - // Return the variable. - return variable; - } - - /// - /// Sets the output to the specified value. - /// - public static void Set(this ExpressionExecutionContext context, Output? output, object? value, Action? configure = null) - { - if (output != null) - { - // Set the value on the output. - var outputMemoryBlockReference = output.MemoryBlockReference(); - var parsedValue = output.ParseValue(value); - context.Set(outputMemoryBlockReference, parsedValue, configure); - - // If the referenced output is a workflow output definition, set the value on the workflow execution context. - var workflowExecutionContext = context.GetWorkflowExecutionContext(); - var workflow = workflowExecutionContext.Workflow; - var workflowOutputDefinition = workflow.Outputs.FirstOrDefault(x => x.Name == outputMemoryBlockReference.Id); - - if (workflowOutputDefinition != null) - workflowExecutionContext.Output[workflowOutputDefinition.Name] = value!; - } - } - - /// - /// Returns a dictionary of memory block keys and values across scopes. - /// - public static IDictionary ReadAndFlattenMemoryBlocks(this ExpressionExecutionContext context) => - context.FlattenMemoryBlocks().ToDictionary(x => x.Key, x => x.Value.Value!); - - /// - /// Returns a dictionary of memory blocks across scopes. - /// - public static IDictionary FlattenMemoryBlocks(this ExpressionExecutionContext context) - { - var currentContext = context; - var memoryBlocks = new Dictionary(); - - while (currentContext != null) - { - var register = currentContext.Memory; - - foreach (var entry in register.Blocks) - memoryBlocks.TryAdd(entry.Key, entry.Value); - - currentContext = currentContext.ParentContext; - } - - return memoryBlocks; - } - - /// - /// Returns a the first context that contains a memory block with the specified ID. - /// - public static ExpressionExecutionContext? FindContextContainingBlock(this ExpressionExecutionContext context, string blockId) - { - return context.FindParent(x => x.Memory.HasBlock(blockId)); - } - - /// - /// Returns the first context in the hierarchy that matches the specified predicate. - /// /// The context to start searching from. - /// The predicate to match. - /// The first context that matches the predicate or null if no match was found. - public static ExpressionExecutionContext? FindParent(this ExpressionExecutionContext context, Func predicate) + extension(ExpressionExecutionContext context) { - var currentContext = context; + /// + /// Returns the of the specified + /// + public bool TryGetWorkflowExecutionContext(out WorkflowExecutionContext workflowExecutionContext) => context.TransientProperties.TryGetValue(WorkflowExecutionContextKey, out workflowExecutionContext!); - while (currentContext != null) + /// + /// Returns the of the specified + /// + public WorkflowExecutionContext GetWorkflowExecutionContext() => (WorkflowExecutionContext)context.TransientProperties[WorkflowExecutionContextKey]; + + /// + /// Returns the of the specified + /// + public ActivityExecutionContext GetActivityExecutionContext() => (ActivityExecutionContext)context.TransientProperties[ActivityExecutionContextKey]; + + /// + /// Returns the of the specified + /// + public bool TryGetActivityExecutionContext(out ActivityExecutionContext activityExecutionContext) => context.TransientProperties.TryGetValue(ActivityExecutionContextKey, out activityExecutionContext!); + + /// + /// Returns the of the specified + /// + public IActivity GetActivity() => (IActivity)context.TransientProperties[ActivityKey]; + + /// + /// Returns the value of the specified input. + /// + public T? Get(Input? input) => input != null ? context.GetBlock(input.MemoryBlockReference).Value.ConvertTo() : default; + + /// + /// Returns the value of the specified output. + /// + public T? Get(Output output) => context.GetBlock(output.MemoryBlockReference).Value.ConvertTo(); + + /// + /// Returns the value of the specified output. + /// + public object? Get(Output output) => context.GetBlock(output.MemoryBlockReference).Value; + + /// + /// Returns the value of the variable with the specified name. + /// + public T? GetVariable(string name) { - if (predicate(currentContext)) - return currentContext; - - currentContext = currentContext.ParentContext; + var block = context.GetVariableBlock(name); + return (T?)block?.Value; } - return null; - } - - /// - /// Returns the value of the specified variable. - /// - public static object GetVariableInScope(this ExpressionExecutionContext context, string variableName) - { - var variable = context.GetVariable(variableName); - var value = variable?.Get(context); - - return ConvertIEnumerableToArray(value); - } - - /// - /// Gets all variables names in scope. - /// - public static IEnumerable GetVariableNamesInScope(this ExpressionExecutionContext context) => - EnumerateVariablesInScope(context) - .Select(x => x.Name) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Distinct(); - - /// - /// Gets all variables in scope. - /// - public static IEnumerable GetVariablesInScope(this ExpressionExecutionContext context) => - EnumerateVariablesInScope(context) - .Where(x => !string.IsNullOrWhiteSpace(x.Name)) - .DistinctBy(x => x.Name); - - /// - /// Sets the value of a named variable in the context. - /// - public static void SetVariableInScope(this ExpressionExecutionContext context, string variableName, object? value) - { - var q = from v in EnumerateVariablesInScope(context) - where v.Name == variableName - where v.TryGet(context, out _) - select v; - - var variable = q.FirstOrDefault(); - - if (variable != null) - variable.Set(context, value); - - if (variable == null) - CreateVariable(context, variableName, value); - } - - /// - /// Enumerates all variables in scope. - /// - public static IEnumerable EnumerateVariablesInScope(this ExpressionExecutionContext context) - { - var currentScope = context; - - while (currentScope != null) + /// + /// Returns the variable with the specified name. + /// + public Variable? GetVariable(string name, bool localScopeOnly = false) { - if (!currentScope.TryGetActivityExecutionContext(out var activityExecutionContext)) - { - var variables = currentScope.Memory.Blocks.Values - .Where(x => x.Metadata is VariableBlockMetadata) - .Select(x => x.Metadata as VariableBlockMetadata) - .Select(x => x!.Variable) - .ToList(); + var block = context.GetVariableBlock(name, localScopeOnly); + return block?.Metadata is VariableBlockMetadata metadata ? metadata.Variable : null; + } - foreach (var variable in variables) - yield return variable; - } - else + private MemoryBlock? GetVariableBlock(string name, bool localScopeOnly = false) + { + foreach (var block in context.Memory.Blocks.Where(b => b.Value.Metadata is VariableBlockMetadata)) { - var variables = activityExecutionContext.Variables; - - foreach (var variable in variables) - yield return variable; + var metadata = block.Value.Metadata as VariableBlockMetadata; + if (metadata!.Variable.Name == name) + return block.Value; } - currentScope = currentScope.ParentContext; + return localScopeOnly ? null : context.ParentContext?.GetVariableBlock(name); } - if (context.TryGetWorkflowExecutionContext(out var workflowExecutionContext)) + /// + /// Creates a named variable in the context. + /// + public Variable CreateVariable(string name, T? value, Type? storageDriverType = null, Action? configure = null) { - if (workflowExecutionContext.Workflow.ResultVariable != null) - yield return workflowExecutionContext.Workflow.ResultVariable; - } - } + var existingVariable = context.GetVariable(name, localScopeOnly: true); - /// - /// Returns the input value associated with the specified in the given . - /// - /// The type of the input value. - /// The containing the input. - /// The specifying the input to retrieve. - /// The input value associated with the specified in the . - public static T? GetInput(this ExpressionExecutionContext context, InputDefinition inputDefinition) - { - return context.GetInput(inputDefinition.Name); + if (existingVariable != null) + throw new($"Variable {name} already exists in the context."); + + var variable = new Variable(name, value) + { + StorageDriverType = storageDriverType ?? typeof(WorkflowInstanceStorageDriver) + }; + + var parsedValue = variable.ParseValue(value); + + // Find the first parent context that has a variable container. + // If not found, use the current context. + var variableContainerContext = context.GetVariableContainerContext(); + + variableContainerContext.Set(variable, parsedValue, configure); + return variable; + } + + /// + /// Returns the first parent context that contains a variable container. + /// + public ExpressionExecutionContext GetVariableContainerContext() + { + return context.FindParent(x => + { + var activityExecutionContext = x.TryGetActivityExecutionContext(out var activityExecutionContextResult) ? activityExecutionContextResult : null; + return activityExecutionContext?.Activity is IVariableContainer; + }) ?? context; + } + + /// + /// Sets the value of a named variable in the context. + /// + public Variable SetVariable(string name, T? value, Action? configure = null) + { + var variable = context.GetVariable(name); + + if (variable == null) + return CreateVariable(context, name, value, configure: configure); + + // Get the context where the variable is defined. + var contextWithVariable = context.FindContextContainingBlock(variable.Id) ?? context; + + // Set the value on the variable. + var parsedValue = variable.ParseValue(value); + variable.Set(contextWithVariable, parsedValue, configure); + + // Return the variable. + return variable; + } + + /// + /// Sets the output to the specified value. + /// + public void Set(Output? output, object? value, Action? configure = null) + { + if (output != null) + { + // Set the value on the output. + var outputMemoryBlockReference = output.MemoryBlockReference(); + var parsedValue = output.ParseValue(value); + context.Set(outputMemoryBlockReference, parsedValue, configure); + + // If the referenced output is a workflow output definition, set the value on the workflow execution context. + var workflowExecutionContext = context.GetWorkflowExecutionContext(); + var workflow = workflowExecutionContext.Workflow; + var workflowOutputDefinition = workflow.Outputs.FirstOrDefault(x => x.Name == outputMemoryBlockReference.Id); + + if (workflowOutputDefinition != null) + workflowExecutionContext.Output[workflowOutputDefinition.Name] = value!; + } + } + + /// + /// Returns a dictionary of memory block keys and values across scopes. + /// + public IDictionary ReadAndFlattenMemoryBlocks() => + context.FlattenMemoryBlocks().ToDictionary(x => x.Key, x => x.Value.Value!); + + /// + /// Returns a dictionary of memory blocks across scopes. + /// + public IDictionary FlattenMemoryBlocks() + { + var currentContext = context; + var memoryBlocks = new Dictionary(); + + while (currentContext != null) + { + var register = currentContext.Memory; + + foreach (var entry in register.Blocks) + memoryBlocks.TryAdd(entry.Key, entry.Value); + + currentContext = currentContext.ParentContext; + } + + return memoryBlocks; + } + + /// + /// Returns a the first context that contains a memory block with the specified ID. + /// + public ExpressionExecutionContext? FindContextContainingBlock(string blockId) + { + return context.FindParent(x => x.Memory.HasBlock(blockId)); + } + + /// + /// Returns the first context in the hierarchy that matches the specified predicate. + /// + /// The predicate to match. + /// The first context that matches the predicate or null if no match was found. + public ExpressionExecutionContext? FindParent(Func predicate) + { + var currentContext = context; + + while (currentContext != null) + { + if (predicate(currentContext)) + return currentContext; + + currentContext = currentContext.ParentContext; + } + + return null; + } + + /// + /// Returns the value of the specified variable. + /// + public object GetVariableInScope(string variableName) + { + var variable = context.GetVariable(variableName); + var value = variable?.Get(context); + + return ConvertIEnumerableToArray(value); + } + + /// + /// Gets all variables names in scope. + /// + public IEnumerable GetVariableNamesInScope() => + EnumerateVariablesInScope(context) + .Select(x => x.Name) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(); + + /// + /// Gets all variables in scope. + /// + public IEnumerable GetVariablesInScope() => + EnumerateVariablesInScope(context) + .Where(x => !string.IsNullOrWhiteSpace(x.Name)) + .DistinctBy(x => x.Name); + + /// + /// Sets the value of a named variable in the context. + /// + public void SetVariableInScope(string variableName, object? value) + { + var q = from v in EnumerateVariablesInScope(context) + where v.Name == variableName + where v.TryGet(context, out _) + select v; + + var variable = q.FirstOrDefault(); + + if (variable != null) + variable.Set(context, value); + + if (variable == null) + CreateVariable(context, variableName, value); + } + + /// + /// Enumerates all variables in scope. + /// + public IEnumerable EnumerateVariablesInScope() + { + var currentScope = context; + + while (currentScope != null) + { + if (!currentScope.TryGetActivityExecutionContext(out var activityExecutionContext)) + { + var variables = currentScope.Memory.Blocks.Values + .Where(x => x.Metadata is VariableBlockMetadata) + .Select(x => x.Metadata as VariableBlockMetadata) + .Select(x => x!.Variable) + .ToList(); + + foreach (var variable in variables) + yield return variable; + } + else + { + var variables = activityExecutionContext.Variables; + + foreach (var variable in variables) + yield return variable; + } + + currentScope = currentScope.ParentContext; + } + + if (context.TryGetWorkflowExecutionContext(out var workflowExecutionContext)) + { + if (workflowExecutionContext.Workflow.ResultVariable != null) + yield return workflowExecutionContext.Workflow.ResultVariable; + } + } + + /// + /// Returns the input value associated with the specified in the given . + /// + /// The type of the input value. + /// The specifying the input to retrieve. + /// The input value associated with the specified in the . + public T? GetInput(InputDefinition inputDefinition) + { + return context.GetInput(inputDefinition.Name); + } } private static JsonSerializerOptions? _serializerOptions; @@ -382,152 +384,153 @@ public static class ExpressionExecutionContextExtensions return serializerOptions; } - /// - /// Returns the value of the specified input. - /// /// - /// The name of the input. - /// The type of the input. - /// The value of the specified input. - public static T? GetInput(this ExpressionExecutionContext context, string name) + extension(ExpressionExecutionContext context) { - var value = context.GetInput(name); - var serializerOptions = GetSerializerOptions(context); - var converterOptions = new ObjectConverterOptions(serializerOptions); - return value.ConvertTo(converterOptions); - } - - /// - /// Returns the value of the specified input. - /// - /// - /// The name of the input. - /// The value of the specified input. - public static object? GetInput(this ExpressionExecutionContext context, string name) - { - if (context.IsContainedWithinCompositeActivity()) + /// + /// Returns the value of the specified input. + /// + /// The name of the input. + /// The type of the input. + /// The value of the specified input. + public T? GetInput(string name) { - // If there's a variable in the current scope with the specified name, return that. - var variable = context.GetVariable(name); - - if (variable != null) - return variable.Get(context); + var value = context.GetInput(name); + var serializerOptions = GetSerializerOptions(context); + var converterOptions = new ObjectConverterOptions(serializerOptions); + return value.ConvertTo(converterOptions); } - // Otherwise, return the input. - var workflowExecutionContext = context.GetWorkflowExecutionContext(); - var input = workflowExecutionContext.Input; - return input.TryGetValue(name, out var value) ? value : null; - } - - /// - /// Returns the value of the specified output. - /// - /// - /// The ID or name of the activity. - /// The name of the output. - /// The value of the specified output. - /// Thrown when the activity is not found. - public static object? GetOutput(this ExpressionExecutionContext context, string activityIdOrName, string? outputName) - { - var workflowExecutionContext = context.GetWorkflowExecutionContext(); - var activityExecutionContext = context.GetActivityExecutionContext(); - var activity = activityExecutionContext.FindActivityByIdOrName(activityIdOrName); - - if (activity == null) - throw new InvalidOperationException("Activity not found."); - - var outputRegister = workflowExecutionContext.GetActivityOutputRegister(); - var outputRecordCandidates = outputRegister.FindMany(activity.Id, outputName); - var containerIds = activityExecutionContext.GetAncestors().Select(x => x.Id).ToList(); - var filteredOutputRecordCandidates = outputRecordCandidates.Where(x => containerIds.Contains(x.ContainerId)); - var outputRecord = filteredOutputRecordCandidates.FirstOrDefault(); - return outputRecord?.Value; - } - - /// - /// Returns all activity outputs. - /// - public static async IAsyncEnumerable GetActivityOutputs(this ExpressionExecutionContext context) - { - if (!context.TryGetActivityExecutionContext(out var activityExecutionContext)) - yield break; - - var useActivityName = activityExecutionContext.WorkflowExecutionContext.Workflow.CreatedWithModernTooling(); - var activitiesWithOutputs = activityExecutionContext.GetActivitiesWithOutputs(); - - if (useActivityName) - activitiesWithOutputs = activitiesWithOutputs.Where(x => !string.IsNullOrWhiteSpace(x.Activity.Name)); - - await foreach (var activityWithOutput in activitiesWithOutputs) + /// + /// Returns the value of the specified input. + /// + /// The name of the input. + /// The value of the specified input. + public object? GetInput(string name) { - var activity = activityWithOutput.Activity; - var activityDescriptor = activityWithOutput.ActivityDescriptor; - var activityIdentifier = useActivityName ? activity.Name : activity.Id; - var activityIdPascalName = activityIdentifier.Pascalize(); - - foreach (var output in activityDescriptor.Outputs) + if (context.IsContainedWithinCompositeActivity()) { - var outputPascalName = output.Name.Pascalize(); - yield return new(activity.Id, activityIdPascalName, [ - outputPascalName - ]); + // If there's a variable in the current scope with the specified name, return that. + var variable = context.GetVariable(name); + + if (variable != null) + return variable.Get(context); } - } - } - /// - /// Returns a value indicating whether the current activity is inside a composite activity. - /// - public static bool IsContainedWithinCompositeActivity(this ExpressionExecutionContext context) - { - if (!context.TryGetActivityExecutionContext(out var activityExecutionContext)) - return false; - - // If the first workflow definition in the ancestor hierarchy and that workflow definition has a parent, then we are inside a composite activity. - var firstWorkflowContext = activityExecutionContext.GetAncestors().FirstOrDefault(x => x.Activity is Workflow); - - return firstWorkflowContext?.ParentActivityExecutionContext != null; - } - - /// - /// Returns the result of the activity that was executed before the current activity. - /// - public static object? GetLastResult(this ExpressionExecutionContext context) - { - var workflowExecutionContext = context.GetWorkflowExecutionContext(); - return workflowExecutionContext.GetLastActivityResult(); - } - - /// - /// Returns all activity inputs. - /// - public static IEnumerable GetWorkflowInputs(this ExpressionExecutionContext context) - { - // Check if we are evaluating an expression during workflow execution. - if (context.TryGetWorkflowExecutionContext(out var workflowExecutionContext)) - { + // Otherwise, return the input. + var workflowExecutionContext = context.GetWorkflowExecutionContext(); var input = workflowExecutionContext.Input; + return input.TryGetValue(name, out var value) ? value : null; + } - foreach (var inputEntry in input) + /// + /// Returns the value of the specified output. + /// + /// The ID or name of the activity. + /// The name of the output. + /// The value of the specified output. + /// Thrown when the activity is not found. + public object? GetOutput(string activityIdOrName, string? outputName) + { + var workflowExecutionContext = context.GetWorkflowExecutionContext(); + var activityExecutionContext = context.GetActivityExecutionContext(); + var activity = activityExecutionContext.FindActivityByIdOrName(activityIdOrName); + + if (activity == null) + throw new InvalidOperationException("Activity not found."); + + var outputRegister = workflowExecutionContext.GetActivityOutputRegister(); + var outputRecordCandidates = outputRegister.FindMany(activity.Id, outputName); + var containerIds = activityExecutionContext.GetAncestors().Select(x => x.Id).ToList(); + var filteredOutputRecordCandidates = outputRecordCandidates.Where(x => containerIds.Contains(x.ContainerId)); + var outputRecord = filteredOutputRecordCandidates.FirstOrDefault(); + return outputRecord?.Value; + } + + /// + /// Returns all activity outputs. + /// + public async IAsyncEnumerable GetActivityOutputs() + { + if (!context.TryGetActivityExecutionContext(out var activityExecutionContext)) + yield break; + + var useActivityName = activityExecutionContext.WorkflowExecutionContext.Workflow.CreatedWithModernTooling(); + var activitiesWithOutputs = activityExecutionContext.GetActivitiesWithOutputs(); + + if (useActivityName) + activitiesWithOutputs = activitiesWithOutputs.Where(x => !string.IsNullOrWhiteSpace(x.Activity.Name)); + + await foreach (var activityWithOutput in activitiesWithOutputs) { - var inputPascalName = inputEntry.Key.Pascalize(); - var inputValue = inputEntry.Value; - yield return new(inputPascalName, inputValue); + var activity = activityWithOutput.Activity; + var activityDescriptor = activityWithOutput.ActivityDescriptor; + var activityIdentifier = useActivityName ? activity.Name : activity.Id; + var activityIdPascalName = activityIdentifier.Pascalize(); + + foreach (var output in activityDescriptor.Outputs) + { + var outputPascalName = output.Name.Pascalize(); + yield return new(activity.Id, activityIdPascalName, [ + outputPascalName + ]); + } } } - else - { - // We end up here when we are evaluating an expression during trigger indexing. - // The scenario being that a workflow definition might have variables declared, that we want to be able to access from JavaScript expressions. - foreach (var block in context.Memory.Blocks.Values) - { - if (block.Metadata is not VariableBlockMetadata variableBlockMetadata) - continue; - var variable = variableBlockMetadata.Variable; - var variablePascalName = variable.Name.Pascalize(); - yield return new(variablePascalName, block.Value); + /// + /// Returns a value indicating whether the current activity is inside a composite activity. + /// + public bool IsContainedWithinCompositeActivity() + { + if (!context.TryGetActivityExecutionContext(out var activityExecutionContext)) + return false; + + // If the first workflow definition in the ancestor hierarchy and that workflow definition has a parent, then we are inside a composite activity. + var firstWorkflowContext = activityExecutionContext.GetAncestors().FirstOrDefault(x => x.Activity is Workflow); + + return firstWorkflowContext?.ParentActivityExecutionContext != null; + } + + /// + /// Returns the result of the activity that was executed before the current activity. + /// + public object? GetLastResult() + { + var workflowExecutionContext = context.GetWorkflowExecutionContext(); + return workflowExecutionContext.GetLastActivityResult(); + } + + /// + /// Returns all activity inputs. + /// + public IEnumerable GetWorkflowInputs() + { + // Check if we are evaluating an expression during workflow execution. + if (context.TryGetWorkflowExecutionContext(out var workflowExecutionContext)) + { + var input = workflowExecutionContext.Input; + + foreach (var inputEntry in input) + { + var inputPascalName = inputEntry.Key.Pascalize(); + var inputValue = inputEntry.Value; + yield return new(inputPascalName, inputValue); + } + } + else + { + // We end up here when we are evaluating an expression during trigger indexing. + // The scenario being that a workflow definition might have variables declared, that we want to be able to access from JavaScript expressions. + foreach (var block in context.Memory.Blocks.Values) + { + if (block.Metadata is not VariableBlockMetadata variableBlockMetadata) + continue; + + var variable = variableBlockMetadata.Variable; + var variablePascalName = variable.Name.Pascalize(); + yield return new(variablePascalName, block.Value); + } } } } diff --git a/src/modules/Elsa.Workflows.Core/Extensions/InputExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/InputExtensions.cs index 89f0fba2d..a6d388173 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/InputExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/InputExtensions.cs @@ -11,49 +11,50 @@ namespace Elsa.Extensions; /// public static class InputExtensions { - /// - /// Returns the value of the specified input, or a default value if the input is not found. - /// - public static T? GetOrDefault(this Input? input, ActivityExecutionContext context, Func? defaultValue = default) - { - var value = context.Get(input); - return value != null ? value : defaultValue != null ? defaultValue.Invoke() : default; - } - - /// - /// Returns the value of the specified input, or a default value if the input is not found. - /// - public static T? GetOrDefault(this Input? input, ExpressionExecutionContext context, Func? defaultValue = default) - { - var value = context.Get(input); - return value != null ? value : defaultValue != null ? defaultValue.Invoke() : default; - } - - /// - /// Returns the value of the specified input. - /// /// The input. - /// The context. - /// The name of the input. /// The type of the input. - /// The value of the specified input. - /// Throws an exception if the input is not found. - public static T Get(this Input? input, ActivityExecutionContext context, [CallerArgumentExpression("input")] string? inputName = default) + extension(Input? input) { - return context.Get(input) ?? throw new Exception($"{inputName} is required."); - } + /// + /// Returns the value of the specified input, or a default value if the input is not found. + /// + public T? GetOrDefault(ActivityExecutionContext context, Func? defaultValue = default) + { + var value = context.Get(input); + return value != null ? value : defaultValue != null ? defaultValue.Invoke() : default; + } - /// - /// Returns the value of the specified input. - /// - /// The input. - /// The context. - /// The name of the input. - /// The type of the input. - /// The value of the specified input. - /// Throws an exception if the input is not found. - public static T Get(this Input? input, ExpressionExecutionContext context, [CallerArgumentExpression("input")] string? inputName = default) - { - return context.Get(input) ?? throw new Exception($"{inputName} is required."); + /// + /// Returns the value of the specified input, or a default value if the input is not found. + /// + public T? GetOrDefault(ExpressionExecutionContext context, Func? defaultValue = default) + { + var value = context.Get(input); + return value != null ? value : defaultValue != null ? defaultValue.Invoke() : default; + } + + /// + /// Returns the value of the specified input. + /// + /// The context. + /// The name of the input. + /// The value of the specified input. + /// Throws an exception if the input is not found. + public T Get(ActivityExecutionContext context, [CallerArgumentExpression("input")] string? inputName = default) + { + return context.Get(input) ?? throw new Exception($"{inputName} is required."); + } + + /// + /// Returns the value of the specified input. + /// + /// The context. + /// The name of the input. + /// The value of the specified input. + /// Throws an exception if the input is not found. + public T Get(ExpressionExecutionContext context, [CallerArgumentExpression("input")] string? inputName = default) + { + return context.Get(input) ?? throw new Exception($"{inputName} is required."); + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/MemoryBlockReferenceExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/MemoryBlockReferenceExtensions.cs index e90a454bd..63810951e 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/MemoryBlockReferenceExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/MemoryBlockReferenceExtensions.cs @@ -15,7 +15,7 @@ public static class MemoryBlockReferenceExtensions /// /// The to get the value from. /// The ID of the memory block to get the value for. - /// The value of the memory reference referenced by the specified . + /// The value of the memory reference referenced by the specified block ID. public static object? Get(this ExpressionExecutionContext context, string blockId) { var matchingContext = context.FindContextContainingBlock(blockId) ?? context; @@ -27,57 +27,57 @@ public static class MemoryBlockReferenceExtensions /// /// The to get the value from. /// The ID of the memory block to get the value for. - /// The value of the memory reference referenced by the specified . + /// The value of the memory reference referenced by the specified block ID. public static object? Get(this ActivityExecutionContext context, string blockId) => context.ExpressionExecutionContext.Get(blockId); - /// - /// Gets the value of the memory reference referenced by the specified . - /// /// The to get the value for. - /// The to get the value from. - /// The value of the memory reference referenced by the specified . - public static object? Get(this MemoryBlockReference reference, ActivityExecutionContext context) + extension(MemoryBlockReference reference) { - var matchingContext = context.ExpressionExecutionContext.FindContextContainingBlock(reference.Id) ?? context.ExpressionExecutionContext; - return reference.Get(matchingContext); - } + /// + /// Gets the value of the memory reference referenced by the specified . + /// + /// The to get the value from. + /// The value of the memory reference referenced by the specified . + public object? Get(ActivityExecutionContext context) + { + var matchingContext = context.ExpressionExecutionContext.FindContextContainingBlock(reference.Id) ?? context.ExpressionExecutionContext; + return reference.Get(matchingContext); + } - /// - /// Gets the of referenced by the specified . - /// - /// The reference to get the reference for. - /// The to get the reference from. - /// The referenced by the specified . - public static MemoryBlock GetBlock(this MemoryBlockReference reference, ExpressionExecutionContext context) - { - var matchingContext = context.FindContextContainingBlock(reference.Id) ?? context; - return reference.GetBlock(matchingContext.Memory); - } + /// + /// Gets the of referenced by the specified . + /// + /// The to get the reference from. + /// The referenced by the specified . + public MemoryBlock GetBlock(ExpressionExecutionContext context) + { + var matchingContext = context.FindContextContainingBlock(reference.Id) ?? context; + return reference.GetBlock(matchingContext.Memory); + } - /// - /// Gets the value of the memory reference referenced by the specified . - /// - /// The to get the value for. - /// The to get the value from. - /// The type of the value to get. - /// The value of the memory reference referenced by the specified . - public static T? Get(this MemoryBlockReference reference, ActivityExecutionContext context) => reference.Get(context).ConvertTo(); + /// + /// Gets the value of the memory reference referenced by the specified . + /// + /// The to get the value from. + /// The type of the value to get. + /// The value of the memory reference referenced by the specified . + public T? Get(ActivityExecutionContext context) => reference.Get(context).ConvertTo(); - /// - /// Sets the specified value on the memory referenced by the specified . - /// If the referenced block doesn't exist in the current scope, an attempt is made to find the block in the first parent scope that references an activity that is a variable container. - /// If that fails, the root scope is used. - /// - /// The to set the value for. - /// The to set the value on. - /// The value to set. - public static void Set(this MemoryBlockReference reference, ActivityExecutionContext context, object? value) - { - var matchingContext = - context.ExpressionExecutionContext.FindContextContainingBlock(reference.Id) - ?? context.FindParentWithVariableContainer()?.ExpressionExecutionContext - ?? context.WorkflowExecutionContext.ExpressionExecutionContext!; + /// + /// Sets the specified value on the memory referenced by the specified . + /// If the referenced block doesn't exist in the current scope, an attempt is made to find the block in the first parent scope that references an activity that is a variable container. + /// If that fails, the root scope is used. + /// + /// The to set the value on. + /// The value to set. + public void Set(ActivityExecutionContext context, object? value) + { + var matchingContext = + context.ExpressionExecutionContext.FindContextContainingBlock(reference.Id) + ?? context.FindParentWithVariableContainer()?.ExpressionExecutionContext + ?? context.WorkflowExecutionContext.ExpressionExecutionContext!; - reference.Set(matchingContext, value); + reference.Set(matchingContext, value); + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ObjectExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ObjectExtensions.cs index 129260940..db34e7640 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ObjectExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ObjectExtensions.cs @@ -5,20 +5,23 @@ namespace Elsa.Extensions; public static class ObjectExtensions { - /// - /// Converts an object into a dictionary. - /// - public static IDictionary ToDictionary(this object source, BindingFlags bindingAttr = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) => source.ToDictionaryInternal(bindingAttr); - - /// - /// Converts an object into a dictionary. - /// - public static IReadOnlyDictionary ToReadOnlyDictionary(this object source, BindingFlags bindingAttr = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) => source.ToDictionaryInternal(bindingAttr); + extension(object source) + { + /// + /// Converts an object into a dictionary. + /// + public IDictionary ToDictionary(BindingFlags bindingAttr = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) => source.ToDictionaryInternal(bindingAttr); - private static Dictionary ToDictionaryInternal(this object source, BindingFlags bindingAttr = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) => - source.GetType().GetProperties(bindingAttr).ToDictionary - ( - propInfo => propInfo.Name, - propInfo => propInfo.GetValue(source, null)! - ); + /// + /// Converts an object into a dictionary. + /// + public IReadOnlyDictionary ToReadOnlyDictionary(BindingFlags bindingAttr = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) => source.ToDictionaryInternal(bindingAttr); + + private Dictionary ToDictionaryInternal(BindingFlags bindingAttr = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) => + source.GetType().GetProperties(bindingAttr).ToDictionary + ( + propInfo => propInfo.Name, + propInfo => propInfo.GetValue(source, null)! + ); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs index 5d1c8ae9d..8d39ab1d4 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs @@ -18,52 +18,58 @@ public static class OutputExtensions /// public static Input CreateInput(this Output output) => new(output); - /// - /// Sets the output to the specified value. - /// - public static void Set(this Output? output, ActivityExecutionContext context, T? value, [CallerArgumentExpression("output")] string? outputName = null) => context.Set(output, value, outputName); - - /// - /// Sets the output to the specified value. - /// - public static void Set(this Output? output, ExpressionExecutionContext context, T? value) => context.Set(output, value); - - /// - /// Sets the output to the specified value. - /// - public static void Set(this Output? output, ActivityExecutionContext context, Variable value) => context.Set(output, value.Get(context)); - - /// - /// Sets the output to the specified value. - /// - public static void Set(this Output? output, ExpressionExecutionContext context, Variable value) => context.Set(output, value.Get(context)); - - /// - /// Gets the target type of the specified variable type, if any, linked to the output. - /// - public static Type? GetTargetType(this Output? output, ActivityExecutionContext context) + extension(Output? output) { - var memoryBlockReference = output?.MemoryBlockReference(); - - if (memoryBlockReference is null) - return null; + /// + /// Sets the output to the specified value. + /// + public void Set(ActivityExecutionContext context, T? value, [CallerArgumentExpression("output")] string? outputName = null) => context.Set(output, value, outputName); - if(!context.ExpressionExecutionContext.TryGetBlock(memoryBlockReference, out var memoryBlock)) - return null; - - var parsedContentVariableType = (memoryBlock.Metadata as VariableBlockMetadata)?.Variable.GetType(); - return parsedContentVariableType?.GenericTypeArguments.FirstOrDefault(); + /// + /// Sets the output to the specified value. + /// + public void Set(ExpressionExecutionContext context, T? value) => context.Set(output, value); + + /// + /// Sets the output to the specified value. + /// + public void Set(ActivityExecutionContext context, Variable value) => context.Set(output, value.Get(context)); + + /// + /// Sets the output to the specified value. + /// + public void Set(ExpressionExecutionContext context, Variable value) => context.Set(output, value.Get(context)); } - /// - /// Returns a value indicating whether the output has a target. - /// - public static bool HasTarget(this Output? output, ActivityExecutionContext context) + extension(Output? output) { - var memoryBlockReference = output?.MemoryBlockReference(); - return memoryBlockReference is not null && context.ExpressionExecutionContext.TryGetBlock(memoryBlockReference, out _); + /// + /// Gets the target type of the specified variable type, if any, linked to the output. + /// + public Type? GetTargetType(ActivityExecutionContext context) + { + var memoryBlockReference = output?.MemoryBlockReference(); + + if (memoryBlockReference is null) + return null; + + if(!context.ExpressionExecutionContext.TryGetBlock(memoryBlockReference, out var memoryBlock)) + return null; + + var parsedContentVariableType = (memoryBlock.Metadata as VariableBlockMetadata)?.Variable.GetType(); + return parsedContentVariableType?.GenericTypeArguments.FirstOrDefault(); + } + + /// + /// Returns a value indicating whether the output has a target. + /// + public bool HasTarget(ActivityExecutionContext context) + { + var memoryBlockReference = output?.MemoryBlockReference(); + return memoryBlockReference is not null && context.ExpressionExecutionContext.TryGetBlock(memoryBlockReference, out _); + } } - + public static object? ParseValue(this Output output, object? value) { var genericType = output.GetType(); diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ServiceCollectionExtensions.cs index 041e22424..2b88f6a03 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ServiceCollectionExtensions.cs @@ -6,13 +6,16 @@ namespace Elsa.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddStorageDriver(this IServiceCollection services) where T : class, IStorageDriver + extension(IServiceCollection services) { - return services.AddScoped(); - } - - public static IServiceCollection AddActivityStateFilter(this IServiceCollection services) where T : class, IActivityStateFilter - { - return services.AddScoped(); + public IServiceCollection AddStorageDriver() where T : class, IStorageDriver + { + return services.AddScoped(); + } + + public IServiceCollection AddActivityStateFilter() where T : class, IActivityStateFilter + { + return services.AddScoped(); + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/VariableExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/VariableExtensions.cs index 81af7820d..a2da27a98 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/VariableExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/VariableExtensions.cs @@ -50,37 +50,40 @@ public static class VariableExtensions /// public static Variable WithMemoryStorage(this Variable variable) => (Variable)variable.WithStorage(); - /// - /// Configures the variable to use the specified type. - /// - public static Variable WithStorage(this Variable variable) => variable.WithStorage(typeof(T)); + extension(Variable variable) + { + /// + /// Configures the variable to use the specified type. + /// + public Variable WithStorage() => variable.WithStorage(typeof(T)); - /// - /// Configures the variable to use the specified type. - /// - public static Variable WithStorage(this Variable variable, Type storageDriverType) - { - variable.StorageDriverType = storageDriverType; - return variable; - } - - public static void Set(this Variable variable, ActivityExecutionContext context, object? value) - { - var parsedValue = variable.ParseValue(value); - // Set the value. - ((MemoryBlockReference)variable).Set(context, parsedValue); + /// + /// Configures the variable to use the specified type. + /// + public Variable WithStorage(Type storageDriverType) + { + variable.StorageDriverType = storageDriverType; + return variable; + } + + public void Set(ActivityExecutionContext context, object? value) + { + var parsedValue = variable.ParseValue(value); + // Set the value. + ((MemoryBlockReference)variable).Set(context, parsedValue); + } + + /// + /// Converts the specified value into a type that is compatible with the variable. + /// + [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)")] + public object? ParseValue(object? value) + { + var genericType = variable.GetType(); + return ParseValue(genericType, value); + } } - /// - /// Converts the specified value into a type that is compatible with the variable. - /// - [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)")] - public static object? ParseValue(this Variable variable, object? value) - { - var genericType = variable.GetType(); - return ParseValue(genericType, value); - } - public static object? ParseValue(Type type, object? value) { var genericType = type.GenericTypeArguments.FirstOrDefault(); @@ -88,30 +91,33 @@ public static class VariableExtensions return genericType == null ? value : value?.ConvertTo(genericType, converterOptions); } - /// - /// Converts the specified value into a type that is compatible with the variable. - /// - [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)")] - public static bool TryParseValue(this Variable variable, object? value, out object? parsedValue) + extension(Variable variable) { - try + /// + /// Converts the specified value into a type that is compatible with the variable. + /// + [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)")] + public bool TryParseValue(object? value, out object? parsedValue) { - parsedValue = variable.ParseValue(value); - return true; + try + { + parsedValue = variable.ParseValue(value); + return true; + } + catch + { + parsedValue = null; + return false; + } } - catch - { - parsedValue = null; - return false; - } - } - /// - /// Return the type of the variable. - /// - public static Type GetVariableType(this Variable variable) - { - var variableType = variable.GetType(); - return variableType.GenericTypeArguments.Any() ? variableType.GetGenericArguments().First() : typeof(object); + /// + /// Return the type of the variable. + /// + public Type GetVariableType() + { + var variableType = variable.GetType(); + return variableType.GenericTypeArguments.Any() ? variableType.GetGenericArguments().First() : typeof(object); + } } } \ 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 c89f61bdc..e90c28b6f 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs @@ -12,132 +12,133 @@ namespace Elsa.Extensions; /// public static class WorkflowExecutionContextExtensions { - /// - /// Schedules the workflow for execution. - /// - public static ActivityWorkItem ScheduleWorkflow(this WorkflowExecutionContext workflowExecutionContext, IDictionary? input = null, IEnumerable? variables = null) + extension(WorkflowExecutionContext workflowExecutionContext) { - var workflow = workflowExecutionContext.Workflow; - var workItem = new ActivityWorkItem(workflow, input: input, variables: variables); - workflowExecutionContext.Scheduler.Schedule(workItem); - return workItem; - } - - /// - /// Schedules the root activity of the workflow. - /// - public static ActivityWorkItem ScheduleRoot(this WorkflowExecutionContext workflowExecutionContext, IDictionary? input = null, IEnumerable? variables = null) - { - var workflow = workflowExecutionContext.Workflow; - var workItem = new ActivityWorkItem(workflow.Root, input: input, variables: variables); - workflowExecutionContext.Scheduler.Schedule(workItem); - return workItem; - } - - /// - /// Schedules the specified activity of the workflow. - /// - public static ActivityWorkItem ScheduleActivity(this WorkflowExecutionContext workflowExecutionContext, IActivity activity, IDictionary? input = null, IEnumerable? variables = null) - { - var workItem = new ActivityWorkItem(activity, input: input, variables: variables); - workflowExecutionContext.Scheduler.Schedule(workItem); - return workItem; - } - - /// - /// Schedules the specified activity execution context of the workflow. - /// - public static ActivityWorkItem ScheduleActivityExecutionContext(this WorkflowExecutionContext workflowExecutionContext, ActivityExecutionContext activityExecutionContext, IDictionary? input = null, IEnumerable? variables = null) - { - var workItem = new ActivityWorkItem( - activityExecutionContext.Activity, - input: input, - variables: variables, - existingActivityExecutionContext: activityExecutionContext); - workflowExecutionContext.Scheduler.Schedule(workItem); - return workItem; - } - - /// - /// Schedules the activity of the specified bookmark. - /// - /// The created work item, or null if the specified bookmark doesn't exist in the - public static ActivityWorkItem? ScheduleBookmark(this WorkflowExecutionContext workflowExecutionContext, Bookmark bookmark, IDictionary? input = null, IEnumerable? variables = null) - { - // Get the activity execution context that owns the bookmark. - var bookmarkedActivityContext = workflowExecutionContext.ActivityExecutionContexts.FirstOrDefault(x => x.Id == bookmark.ActivityInstanceId); - var logger = workflowExecutionContext.GetRequiredService>(); - - if (bookmarkedActivityContext == null) + /// + /// Schedules the workflow for execution. + /// + public ActivityWorkItem ScheduleWorkflow(IDictionary? input = null, IEnumerable? variables = null) { - logger.LogWarning("Could not find activity execution context with ID {ActivityInstanceId} for bookmark {BookmarkId}", bookmark.ActivityInstanceId, bookmark.Id); - return null; + var workflow = workflowExecutionContext.Workflow; + var workItem = new ActivityWorkItem(workflow, input: input, variables: variables); + workflowExecutionContext.Scheduler.Schedule(workItem); + return workItem; } - var bookmarkedActivity = bookmarkedActivityContext.Activity; - - // Schedule the activity to resume. - var workItem = new ActivityWorkItem(bookmarkedActivity) + /// + /// Schedules the root activity of the workflow. + /// + public ActivityWorkItem ScheduleRoot(IDictionary? input = null, IEnumerable? variables = null) { - ExistingActivityExecutionContext = bookmarkedActivityContext, - Input = input ?? new Dictionary(), - Variables = variables - }; - workflowExecutionContext.Scheduler.Schedule(workItem); - - // If no resumption point was specified, use a "noop" to prevent the regular "ExecuteAsync" method to be invoked and instead complete the activity. - // Unless the bookmark is configured to auto-complete, in which case we'll just complete the activity. - workflowExecutionContext.ExecuteDelegate = bookmark.CallbackMethodName != null - ? bookmarkedActivity.GetResumeActivityDelegate(bookmark.CallbackMethodName) - : bookmark.AutoComplete - ? WorkflowExecutionContext.Complete - : WorkflowExecutionContext.Noop; - - // Store the bookmark to resume in the context. - workflowExecutionContext.ResumedBookmarkContext = new(bookmark); - logger.LogDebug("Scheduled activity {ActivityId} to resume from bookmark {BookmarkId}", bookmarkedActivity.Id, bookmark.Id); - - return workItem; - } - - /// - /// Schedules the specified activity. - /// - public static ActivityWorkItem Schedule( - this WorkflowExecutionContext workflowExecutionContext, - ActivityNode activityNode, - ActivityExecutionContext owner, - ScheduleWorkOptions? options = null) - { - var schedulerStrategy = workflowExecutionContext.GetRequiredService(); - return schedulerStrategy.Schedule(workflowExecutionContext, activityNode, owner, options); - } - - /// - /// Returns true if all activities have completed or canceled, false otherwise. - /// - public static bool AllActivitiesCompleted(this WorkflowExecutionContext workflowExecutionContext) => workflowExecutionContext.ActivityExecutionContexts.All(x => x.IsCompleted); - - public static object? GetOutputByActivityId(this WorkflowExecutionContext workflowExecutionContext, string activityId, string? outputName = null) - { - var outputRegister = workflowExecutionContext.GetActivityOutputRegister(); - return outputRegister.FindOutputByActivityId(activityId, outputName); - } - - public static IEnumerable FindActivityExecutionContexts(this WorkflowExecutionContext workflowExecutionContext, ActivityHandle activityHandle) - { - if (activityHandle.ActivityInstanceId != null) - return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Id == activityHandle.ActivityInstanceId); - if (activityHandle.ActivityNodeId != null) - return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.NodeId == activityHandle.ActivityNodeId); - if (activityHandle.ActivityId != null) - return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Activity.Id == activityHandle.ActivityId); - if (activityHandle.ActivityHash != null) - { - var activity = workflowExecutionContext.FindActivityByHash(activityHandle.ActivityHash); - return activity != null ? workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Activity.NodeId == activity.NodeId) : []; + var workflow = workflowExecutionContext.Workflow; + var workItem = new ActivityWorkItem(workflow.Root, input: input, variables: variables); + workflowExecutionContext.Scheduler.Schedule(workItem); + return workItem; } - return []; + /// + /// Schedules the specified activity of the workflow. + /// + public ActivityWorkItem ScheduleActivity(IActivity activity, IDictionary? input = null, IEnumerable? variables = null) + { + var workItem = new ActivityWorkItem(activity, input: input, variables: variables); + workflowExecutionContext.Scheduler.Schedule(workItem); + return workItem; + } + + /// + /// Schedules the specified activity execution context of the workflow. + /// + public ActivityWorkItem ScheduleActivityExecutionContext(ActivityExecutionContext activityExecutionContext, IDictionary? input = null, IEnumerable? variables = null) + { + var workItem = new ActivityWorkItem( + activityExecutionContext.Activity, + input: input, + variables: variables, + existingActivityExecutionContext: activityExecutionContext); + workflowExecutionContext.Scheduler.Schedule(workItem); + return workItem; + } + + /// + /// Schedules the activity of the specified bookmark. + /// + /// The created work item, or null if the specified bookmark doesn't exist in the + public ActivityWorkItem? ScheduleBookmark(Bookmark bookmark, IDictionary? input = null, IEnumerable? variables = null) + { + // Get the activity execution context that owns the bookmark. + var bookmarkedActivityContext = workflowExecutionContext.ActivityExecutionContexts.FirstOrDefault(x => x.Id == bookmark.ActivityInstanceId); + var logger = workflowExecutionContext.GetRequiredService>(); + + if (bookmarkedActivityContext == null) + { + logger.LogWarning("Could not find activity execution context with ID {ActivityInstanceId} for bookmark {BookmarkId}", bookmark.ActivityInstanceId, bookmark.Id); + return null; + } + + var bookmarkedActivity = bookmarkedActivityContext.Activity; + + // Schedule the activity to resume. + var workItem = new ActivityWorkItem(bookmarkedActivity) + { + ExistingActivityExecutionContext = bookmarkedActivityContext, + Input = input ?? new Dictionary(), + Variables = variables + }; + workflowExecutionContext.Scheduler.Schedule(workItem); + + // If no resumption point was specified, use a "noop" to prevent the regular "ExecuteAsync" method to be invoked and instead complete the activity. + // Unless the bookmark is configured to auto-complete, in which case we'll just complete the activity. + workflowExecutionContext.ExecuteDelegate = bookmark.CallbackMethodName != null + ? bookmarkedActivity.GetResumeActivityDelegate(bookmark.CallbackMethodName) + : bookmark.AutoComplete + ? WorkflowExecutionContext.Complete + : WorkflowExecutionContext.Noop; + + // Store the bookmark to resume in the context. + workflowExecutionContext.ResumedBookmarkContext = new(bookmark); + logger.LogDebug("Scheduled activity {ActivityId} to resume from bookmark {BookmarkId}", bookmarkedActivity.Id, bookmark.Id); + + return workItem; + } + + /// + /// Schedules the specified activity. + /// + public ActivityWorkItem Schedule(ActivityNode activityNode, + ActivityExecutionContext owner, + ScheduleWorkOptions? options = null) + { + var schedulerStrategy = workflowExecutionContext.GetRequiredService(); + return schedulerStrategy.Schedule(workflowExecutionContext, activityNode, owner, options); + } + + /// + /// Returns true if all activities have completed or canceled, false otherwise. + /// + public bool AllActivitiesCompleted() => workflowExecutionContext.ActivityExecutionContexts.All(x => x.IsCompleted); + + public object? GetOutputByActivityId(string activityId, string? outputName = null) + { + var outputRegister = workflowExecutionContext.GetActivityOutputRegister(); + return outputRegister.FindOutputByActivityId(activityId, outputName); + } + + public IEnumerable FindActivityExecutionContexts(ActivityHandle activityHandle) + { + if (activityHandle.ActivityInstanceId != null) + return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Id == activityHandle.ActivityInstanceId); + if (activityHandle.ActivityNodeId != null) + return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.NodeId == activityHandle.ActivityNodeId); + if (activityHandle.ActivityId != null) + return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Activity.Id == activityHandle.ActivityId); + if (activityHandle.ActivityHash != null) + { + var activity = workflowExecutionContext.FindActivityByHash(activityHandle.ActivityHash); + return activity != null ? workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Activity.NodeId == activity.NodeId) : []; + } + + return []; + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExtensions.cs index 0e181fcb2..a1248c6a7 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExtensions.cs @@ -8,19 +8,22 @@ namespace Elsa.Extensions; /// public static class WorkflowExtensions { - /// - /// Returns a boolean indicating whether the workflow was created with modern tooling. - /// - public static bool CreatedWithModernTooling(this Workflow workflow) => workflow.WorkflowMetadata.ToolVersion?.Major >= 3; - - /// - /// Executes the specified action depending on whether the workflow was created with modern tooling or not. - /// - public static void WhenCreatedWithModernTooling(this Workflow workflow, Action modernToolingAction, Action legacyToolingAction) + extension(Workflow workflow) { - if (workflow.CreatedWithModernTooling()) - modernToolingAction(); - else - legacyToolingAction(); + /// + /// Returns a boolean indicating whether the workflow was created with modern tooling. + /// + public bool CreatedWithModernTooling() => workflow.WorkflowMetadata.ToolVersion?.Major >= 3; + + /// + /// Executes the specified action depending on whether the workflow was created with modern tooling or not. + /// + public void WhenCreatedWithModernTooling(Action modernToolingAction, Action legacyToolingAction) + { + if (workflow.CreatedWithModernTooling()) + modernToolingAction(); + else + legacyToolingAction(); + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 6fde09cbf..72ef6c157 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -182,7 +182,6 @@ public class WorkflowsFeature : FeatureBase .AddScoped() .AddSingleton() .AddSingleton() - .AddSingleton() .AddTransient() .AddScoped(typeof(Func), sp => () => sp.GetRequiredService()) .AddScoped() diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs index b7028e2b8..e2237498c 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs @@ -1,4 +1,3 @@ -using Elsa.Common; using Elsa.Extensions; using Elsa.Workflows.Pipelines.ActivityExecution; using Microsoft.Extensions.Logging; @@ -19,7 +18,7 @@ public static class ExceptionHandlingMiddlewareExtensions /// /// Catches any exceptions thrown by downstream components and transitions the workflow into the faulted state. /// -public class ExceptionHandlingMiddleware(ActivityMiddlewareDelegate next, IIncidentStrategyResolver incidentStrategyResolver, ISystemClock systemClock, ILogger logger) +public class ExceptionHandlingMiddleware(ActivityMiddlewareDelegate next, IIncidentStrategyResolver incidentStrategyResolver, ILogger logger) : IActivityExecutionMiddleware { /// diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs b/src/modules/Elsa.Workflows.Core/Models/ActivityConstructorContext.cs similarity index 75% rename from src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs rename to src/modules/Elsa.Workflows.Core/Models/ActivityConstructorContext.cs index 4dbed1dc3..2e36b9859 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs +++ b/src/modules/Elsa.Workflows.Core/Models/ActivityConstructorContext.cs @@ -1,21 +1,35 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Nodes; using Elsa.Expressions.Helpers; using Elsa.Extensions; using Elsa.Workflows.Attributes; using Elsa.Workflows.Memory; -using Elsa.Workflows.Models; using Humanizer; -namespace Elsa.Workflows; +namespace Elsa.Workflows.Models; -/// -public class ActivityFactory : IActivityFactory +public record ActivityConstructorContext(ActivityDescriptor ActivityDescriptor, Func ActivityFactory) { - public IActivity Create(Type type, ActivityConstructorContext context) + public T CreateActivity() where T : IActivity => (T)ActivityFactory(typeof(T)); + public IActivity CreateActivity(Type type) => ActivityFactory(type); +} + +public static class JsonActivityConstructorContextHelper +{ + public static ActivityConstructorContext Create(ActivityDescriptor activityDescriptor, JsonElement element, JsonSerializerOptions serializerOptions) + { + return new ActivityConstructorContext(activityDescriptor, type => CreateActivity(activityDescriptor, type, element, serializerOptions)); + } + + public static T CreateActivity(ActivityDescriptor activityDescriptor, JsonElement element, JsonSerializerOptions serializerOptions) where T : IActivity + { + return (T)CreateActivity(activityDescriptor, typeof(T), element, serializerOptions); + } + + public static IActivity CreateActivity(ActivityDescriptor activityDescriptor, Type type, JsonElement element, JsonSerializerOptions serializerOptions) { // 1) Grab the raw text - var raw = context.Element.GetRawText(); + var raw = element.GetRawText(); // 2) Parse into a JsonNode so we can mutate var node = JsonNode.Parse(raw)!; @@ -24,21 +38,21 @@ public class ActivityFactory : IActivityFactory StripTypeMetadata(node); // 4) Get a cleaned JSON string - var cleanedJson = node.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var cleanedJson = node.ToJsonString(new(JsonSerializerDefaults.Web)); // 5) Parse it back to a JsonDocument so we can get a clean JsonElement using var doc = JsonDocument.Parse(cleanedJson); var cleanedElement = doc.RootElement.Clone(); - // 6) Deserialize into your IActivity - var activity = (IActivity)JsonSerializer.Deserialize(cleanedJson, type, context.SerializerOptions)!; + // 6) Deserialize into IActivity, or create a plain instance + var activity = (IActivity)JsonSerializer.Deserialize(cleanedJson, type, serializerOptions)!; // 7) Pull out your boolean flags from the cleaned element var canStartWorkflow = GetBoolean(cleanedElement, "canStartWorkflow"); var runAsynchronously = GetNullableBoolean(cleanedElement, "runAsynchronously"); if (runAsynchronously is null) { - if (context.ActivityDescriptor.Attributes.OfType().FirstOrDefault() is { } taskActivityAttribute) + if (activityDescriptor.Attributes.OfType().FirstOrDefault() is { } taskActivityAttribute) { runAsynchronously = taskActivityAttribute.RunAsynchronously; } @@ -53,8 +67,8 @@ public class ActivityFactory : IActivityFactory composite.Setup(); // 9) Your existing synthetic inputs/outputs routines, using the cleanedElement - ReadSyntheticInputs(context.ActivityDescriptor, activity, cleanedElement, context.SerializerOptions); - ReadSyntheticOutputs(context.ActivityDescriptor, activity, cleanedElement); + ReadSyntheticInputs(activityDescriptor, activity, cleanedElement, serializerOptions); + ReadSyntheticOutputs(activityDescriptor, activity, cleanedElement); // 10) Finally re‑apply those flags activity.SetCanStartWorkflow(canStartWorkflow); @@ -67,7 +81,7 @@ public class ActivityFactory : IActivityFactory /// Recursively remove any "_type" properties and unwrap any /// { "_type": "...", "items": [ ... ] } or "values": [ ... ] wrappers /// - private void StripTypeMetadata(JsonNode node) + private static void StripTypeMetadata(JsonNode node) { switch (node) { @@ -137,7 +151,7 @@ public class ActivityFactory : IActivityFactory } } - private void ReadSyntheticInputs(ActivityDescriptor activityDescriptor, IActivity activity, JsonElement activityRoot, JsonSerializerOptions options) + private static void ReadSyntheticInputs(ActivityDescriptor activityDescriptor, IActivity activity, JsonElement activityRoot, JsonSerializerOptions options) { foreach (var inputDescriptor in activityDescriptor.Inputs.Where(x => x.IsSynthetic)) { @@ -165,7 +179,7 @@ public class ActivityFactory : IActivityFactory } } - private void ReadSyntheticOutputs(ActivityDescriptor activityDescriptor, IActivity activity, JsonElement activityRoot) + private static void ReadSyntheticOutputs(ActivityDescriptor activityDescriptor, IActivity activity, JsonElement activityRoot) { foreach (var outputDescriptor in activityDescriptor.Outputs.Where(x => x.IsSynthetic)) { @@ -182,7 +196,10 @@ public class ActivityFactory : IActivityFactory if (!memoryReferenceElement.TryGetProperty("id", out var memoryReferenceIdElement)) continue; - var variable = new Variable { Id = memoryReferenceIdElement.GetString()! }; + var variable = new Variable + { + Id = memoryReferenceIdElement.GetString()! + }; variable.Name = variable.Id; var output = Activator.CreateInstance(wrappedType, variable)!; @@ -193,12 +210,15 @@ public class ActivityFactory : IActivityFactory private static bool GetBoolean(JsonElement element, string propertyName) { - return GetNullableBoolean(element, propertyName) ?? false; + return GetNullableBoolean(element, propertyName) ?? false; } - + private static bool? GetNullableBoolean(JsonElement element, string propertyName) { - var propertyNames = new[] { propertyName.Camelize(), propertyName.Pascalize() }; + var propertyNames = new[] + { + propertyName.Camelize(), propertyName.Pascalize() + }; foreach (var name in propertyNames) { @@ -208,11 +228,11 @@ public class ActivityFactory : IActivityFactory return (bool?)canStartWorkflowElement.GetValue(); } - if (element.TryGetProperty(propertyName.Camelize(), out var property) + if (element.TryGetProperty(propertyName.Camelize(), out var property) && (bool?)property.GetValue() is { } propValue) return propValue; } return null; } -} +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs b/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs index dbf28921c..89f214934 100644 --- a/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs +++ b/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs @@ -15,6 +15,11 @@ public class ActivityDescriptor /// public string TypeName { get; set; } = null!; + /// + /// The .NET type of the activity type. + /// + public Type ClrType { get; set; } = null!; + /// /// The namespace of the activity type. /// @@ -123,8 +128,4 @@ public class ActivityDescriptor /// [JsonIgnore] public Func? ConfigureSerializerOptions { get; set; } -} - -// TODO: Refactor this to remove the dependency on JsonElement and JsonSerializerOptions. -// This limits the ability to use this class in other contexts, such as constructing activities from the DSL. -public record ActivityConstructorContext(ActivityDescriptor ActivityDescriptor, JsonElement Element, JsonSerializerOptions SerializerOptions); \ No newline at end of file +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs b/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs index e2ba868f8..4b5e7520d 100644 --- a/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs +++ b/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs @@ -15,6 +15,7 @@ namespace Elsa.Workflows.Models; /// The date and time the bookmark was created. /// Whether or not the bookmark should be automatically burned. /// The name of the method on the activity class to invoke when the bookmark is resumed. +/// Whether or not the activity should be automatically completed when the bookmark is resumed. /// Custom properties associated with the bookmark. public record Bookmark( string Id, diff --git a/src/modules/Elsa.Workflows.Core/Pipelines/ActivityExecution/ActivityExecutionMiddlewareExtensions.cs b/src/modules/Elsa.Workflows.Core/Pipelines/ActivityExecution/ActivityExecutionMiddlewareExtensions.cs index 8d2010637..20a03b51f 100644 --- a/src/modules/Elsa.Workflows.Core/Pipelines/ActivityExecution/ActivityExecutionMiddlewareExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Pipelines/ActivityExecution/ActivityExecutionMiddlewareExtensions.cs @@ -5,35 +5,37 @@ namespace Elsa.Workflows.Pipelines.ActivityExecution; public static class ActivityExecutionMiddlewareExtensions { - public static IActivityExecutionPipelineBuilder UseMiddleware<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>(this IActivityExecutionPipelineBuilder pipelineBuilder, params object[] args) where TMiddleware : IActivityExecutionMiddleware + extension(IActivityExecutionPipelineBuilder pipelineBuilder) { - var delegateFactory = CreateMiddlewareDelegateFactory(pipelineBuilder, args); - return pipelineBuilder.Use(delegateFactory); - } - - public static IActivityExecutionPipelineBuilder Insert<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]TMiddleware>(this IActivityExecutionPipelineBuilder pipelineBuilder, int index, params object[] args) where TMiddleware : IActivityExecutionMiddleware - { - var delegateFactory = CreateMiddlewareDelegateFactory(pipelineBuilder, args); - return pipelineBuilder.Insert(index, delegateFactory); - } - - /// - /// Creates a middleware delegate for the specified middleware component. - /// - public static Func CreateMiddlewareDelegateFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>( - this IActivityExecutionPipelineBuilder pipelineBuilder, params object[] args) where TMiddleware : IActivityExecutionMiddleware - { - var middleware = typeof(TMiddleware); - - return next => + public IActivityExecutionPipelineBuilder UseMiddleware<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>(params object[] args) where TMiddleware : IActivityExecutionMiddleware { - var invokeMethod = MiddlewareHelpers.GetInvokeMethod(middleware); - var ctorArgs = new[] + var delegateFactory = CreateMiddlewareDelegateFactory(pipelineBuilder, args); + return pipelineBuilder.Use(delegateFactory); + } + + public IActivityExecutionPipelineBuilder Insert<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]TMiddleware>(int index, params object[] args) where TMiddleware : IActivityExecutionMiddleware + { + var delegateFactory = CreateMiddlewareDelegateFactory(pipelineBuilder, args); + return pipelineBuilder.Insert(index, delegateFactory); + } + + /// + /// Creates a middleware delegate for the specified middleware component. + /// + public Func CreateMiddlewareDelegateFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>(params object[] args) where TMiddleware : IActivityExecutionMiddleware + { + var middleware = typeof(TMiddleware); + + return next => { - next - }.Concat(args).Select(x => x).ToArray(); - var instance = ActivatorUtilities.CreateInstance(pipelineBuilder.ServiceProvider, middleware, ctorArgs); - return (ActivityMiddlewareDelegate)invokeMethod.CreateDelegate(typeof(ActivityMiddlewareDelegate), instance); - }; + var invokeMethod = MiddlewareHelpers.GetInvokeMethod(middleware); + var ctorArgs = new[] + { + next + }.Concat(args).ToArray(); + var instance = ActivatorUtilities.CreateInstance(pipelineBuilder.ServiceProvider, middleware, ctorArgs); + return (ActivityMiddlewareDelegate)invokeMethod.CreateDelegate(typeof(ActivityMiddlewareDelegate), instance); + }; + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Pipelines/WorkflowExecution/WorkflowExecutionMiddlewareExtensions.cs b/src/modules/Elsa.Workflows.Core/Pipelines/WorkflowExecution/WorkflowExecutionMiddlewareExtensions.cs index fe09fb43a..42155eb96 100644 --- a/src/modules/Elsa.Workflows.Core/Pipelines/WorkflowExecution/WorkflowExecutionMiddlewareExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Pipelines/WorkflowExecution/WorkflowExecutionMiddlewareExtensions.cs @@ -8,63 +8,61 @@ namespace Elsa.Workflows.Pipelines.WorkflowExecution; /// public static class WorkflowExecutionMiddlewareExtensions { - /// - /// Installs the specified middleware component into the pipeline being built. - /// - public static IWorkflowExecutionPipelineBuilder UseMiddleware<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>( - this IWorkflowExecutionPipelineBuilder pipelineBuilder, params object[] args) where TMiddleware : IWorkflowExecutionMiddleware + extension(IWorkflowExecutionPipelineBuilder pipelineBuilder) { - var delegateFactory = CreateMiddlewareDelegateFactory(pipelineBuilder, args); - return pipelineBuilder.Use(delegateFactory); - } - - /// - /// Installs the specified middleware component into the pipeline being built. - /// - public static IWorkflowExecutionPipelineBuilder Insert<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>( - this IWorkflowExecutionPipelineBuilder pipelineBuilder, int index, params object[] args) where TMiddleware : IWorkflowExecutionMiddleware - { - var delegateFactory = CreateMiddlewareDelegateFactory(pipelineBuilder, args); - return pipelineBuilder.Insert(index, delegateFactory); - } - - /// - /// Replaces the terminal middleware component with the specified middleware component. - /// - public static IWorkflowExecutionPipelineBuilder ReplaceTerminal<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>( - this IWorkflowExecutionPipelineBuilder pipelineBuilder, params object[] args) where TMiddleware : IWorkflowExecutionMiddleware - { - var index = pipelineBuilder.Components.Count() - 1; - return pipelineBuilder.Replace(index, args); - } - - /// - /// Replaces the middleware component at the specified index with the specified middleware component. - /// - public static IWorkflowExecutionPipelineBuilder Replace<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>( - this IWorkflowExecutionPipelineBuilder pipelineBuilder, int index, params object[] args) where TMiddleware : IWorkflowExecutionMiddleware - { - var delegateFactory = CreateMiddlewareDelegateFactory(pipelineBuilder, args); - return pipelineBuilder.Replace(index, delegateFactory); - } - - /// - /// Creates a middleware delegate for the specified middleware component. - /// - public static Func CreateMiddlewareDelegateFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>( - this IWorkflowExecutionPipelineBuilder pipelineBuilder, params object[] args) where TMiddleware : IWorkflowExecutionMiddleware - { - var middleware = typeof(TMiddleware); - - return next => + /// + /// Installs the specified middleware component into the pipeline being built. + /// + public IWorkflowExecutionPipelineBuilder UseMiddleware<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>(params object[] args) where TMiddleware : IWorkflowExecutionMiddleware { - var invokeMethod = MiddlewareHelpers.GetInvokeMethod(middleware); - var ctorParams = new[] + var delegateFactory = CreateMiddlewareDelegateFactory(pipelineBuilder, args); + return pipelineBuilder.Use(delegateFactory); + } + + /// + /// Installs the specified middleware component into the pipeline being built. + /// + public IWorkflowExecutionPipelineBuilder Insert<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>(int index, params object[] args) where TMiddleware : IWorkflowExecutionMiddleware + { + var delegateFactory = CreateMiddlewareDelegateFactory(pipelineBuilder, args); + return pipelineBuilder.Insert(index, delegateFactory); + } + + /// + /// Replaces the terminal middleware component with the specified middleware component. + /// + public IWorkflowExecutionPipelineBuilder ReplaceTerminal<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>(params object[] args) where TMiddleware : IWorkflowExecutionMiddleware + { + var index = pipelineBuilder.Components.Count() - 1; + return pipelineBuilder.Replace(index, args); + } + + /// + /// Replaces the middleware component at the specified index with the specified middleware component. + /// + public IWorkflowExecutionPipelineBuilder Replace<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>(int index, params object[] args) where TMiddleware : IWorkflowExecutionMiddleware + { + var delegateFactory = CreateMiddlewareDelegateFactory(pipelineBuilder, args); + return pipelineBuilder.Replace(index, delegateFactory); + } + + /// + /// Creates a middleware delegate for the specified middleware component. + /// + public Func CreateMiddlewareDelegateFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TMiddleware>(params object[] args) where TMiddleware : IWorkflowExecutionMiddleware + { + var middleware = typeof(TMiddleware); + + return next => { - next - }.Concat(args).Select(x => x).ToArray(); - var instance = ActivatorUtilities.CreateInstance(pipelineBuilder.ServiceProvider, middleware, ctorParams); - return (WorkflowMiddlewareDelegate)invokeMethod.CreateDelegate(typeof(WorkflowMiddlewareDelegate), instance); - }; + var invokeMethod = MiddlewareHelpers.GetInvokeMethod(middleware); + var ctorParams = new[] + { + next + }.Concat(args).ToArray(); + var instance = ActivatorUtilities.CreateInstance(pipelineBuilder.ServiceProvider, middleware, ctorParams); + return (WorkflowMiddlewareDelegate)invokeMethod.CreateDelegate(typeof(WorkflowMiddlewareDelegate), instance); + }; + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs index 924f3ddbe..28e017ca0 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs @@ -15,7 +15,6 @@ namespace Elsa.Workflows.Serialization.Converters; /// public class ActivityJsonConverter( IActivityRegistry activityRegistry, - IActivityFactory activityFactory, IExpressionDescriptorRegistry expressionDescriptorRegistry, ActivityWriter activityWriter, IServiceProvider serviceProvider) @@ -44,8 +43,7 @@ public class ActivityJsonConverter( if (activityDescriptor == null) { var notFoundActivityDescriptor = activityRegistry.Find()!; - var notFoundContext = new ActivityConstructorContext(notFoundActivityDescriptor, activityRoot, clonedOptions); - var notFoundActivity = (NotFoundActivity)activityFactory.Create(typeof(NotFoundActivity), notFoundContext); + var notFoundActivity = JsonActivityConstructorContextHelper.CreateActivity(notFoundActivityDescriptor, activityRoot, clonedOptions); notFoundActivity.Type = notFoundActivityTypeName; notFoundActivity.Version = 1; @@ -57,7 +55,7 @@ public class ActivityJsonConverter( return notFoundActivity; } - var context = new ActivityConstructorContext(activityDescriptor, activityRoot, clonedOptions); + var context = JsonActivityConstructorContextHelper.Create(activityDescriptor, activityRoot, clonedOptions); var activity = activityDescriptor.Constructor(context); return activity; } diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs index 00c49da52..838dae398 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs @@ -4,7 +4,6 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Elsa.Expressions.Contracts; using Elsa.Extensions; using Elsa.Workflows.Serialization.ReferenceHandlers; using Newtonsoft.Json.Linq; @@ -14,7 +13,7 @@ namespace Elsa.Workflows.Serialization.Converters; /// /// Reads objects as primitive types rather than values while also maintaining the .NET type name for reconstructing the actual type. /// -public class PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegistry) : JsonConverter +public class PolymorphicObjectConverter : JsonConverter { private const string TypePropertyName = "_type"; private const string ItemsPropertyName = "_items"; @@ -77,7 +76,7 @@ public class PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegi { var parsedModel = JsonElement.ParseValue(ref reader); var systemTextJson = parsedModel.GetProperty(IslandPropertyName).GetString(); - return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonObject.Parse(systemTextJson) : new JsonObject(); + return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonNode.Parse(systemTextJson)! : new JsonObject(); } var isJsonArray = targetType == typeof(JsonArray); @@ -86,7 +85,7 @@ public class PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegi { var parsedModel = JsonElement.ParseValue(ref reader); var systemTextJson = parsedModel.GetProperty(IslandPropertyName).GetString(); - return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonArray.Parse(systemTextJson) : new JsonArray(); + return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonNode.Parse(systemTextJson)! : new JsonArray(); } var isDictionary = typeof(IDictionary).IsAssignableFrom(targetType); diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs index 4a8cdcb7e..2317a6372 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs @@ -51,6 +51,6 @@ public class PolymorphicObjectConverterFactory : JsonConverterFactory if (typeof(IDictionary).IsAssignableFrom(typeToConvert)) return new PolymorphicDictionaryConverter(options, _wellKnownTypeRegistry); - return new PolymorphicObjectConverter(_wellKnownTypeRegistry); + return new PolymorphicObjectConverter(); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs index 7f25ab0c8..2fd774cca 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs @@ -15,7 +15,7 @@ using Container = Elsa.Workflows.Activities.Container; namespace Elsa.Workflows; /// -public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolver, IActivityFactory activityFactory, IPropertyUIHandlerResolver propertyUIHandlerResolver) : IActivityDescriber +public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolver, IPropertyUIHandlerResolver propertyUIHandlerResolver) : IActivityDescriber { /// public async Task DescribeActivityAsync([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type activityType, CancellationToken cancellationToken = default) @@ -70,6 +70,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve var descriptor = new ActivityDescriptor { TypeName = fullTypeName, + ClrType = activityType, Namespace = ns, Name = typeName, Category = category, @@ -91,7 +92,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve }, Constructor = context => { - var activity = activityFactory.Create(activityType, context); + var activity = context.CreateActivity(activityType); activity.Type = fullTypeName; return activity; }, diff --git a/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs b/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs index 4380ecae1..878a98474 100644 --- a/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs +++ b/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs @@ -8,7 +8,7 @@ public class CheckList /// /// The radio list. /// - public IEnumerable Items { get; set; } + public IEnumerable Items { get; set; } = null!; /// /// The name of the provider that will provide the select list. diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs index ace833be9..a0274cfb6 100644 --- a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs +++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs @@ -8,7 +8,7 @@ public class RadioList /// /// The radio list. /// - public IEnumerable Items { get; set; } + public IEnumerable Items { get; set; } = null!; /// /// The name of the provider that will provide the select list. diff --git a/src/modules/Elsa.Workflows.Core/VariableStorageDrivers/WorkflowInstanceStorageDriver.cs b/src/modules/Elsa.Workflows.Core/VariableStorageDrivers/WorkflowInstanceStorageDriver.cs index bb93720e3..469969041 100644 --- a/src/modules/Elsa.Workflows.Core/VariableStorageDrivers/WorkflowInstanceStorageDriver.cs +++ b/src/modules/Elsa.Workflows.Core/VariableStorageDrivers/WorkflowInstanceStorageDriver.cs @@ -33,7 +33,7 @@ public class WorkflowInstanceStorageDriver(IPayloadSerializer payloadSerializer, try { var node = JsonSerializer.SerializeToNode(value); - dictionary[id] = node; + dictionary[id] = node!; } catch (Exception ex) when (ex is JsonException or NotSupportedException or ObjectDisposedException) { diff --git a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivity.cs b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivity.cs index e5f69bb47..e618ad467 100644 --- a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivity.cs +++ b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivity.cs @@ -125,6 +125,13 @@ public class WorkflowDefinitionActivity : Composite, IInitializable var serviceProvider = context.GetRequiredService(); var activityDescriptor = await FindActivityDescriptorAsync(serviceProvider); + if (activityDescriptor == null) + { + var logger = serviceProvider.GetRequiredService>(); + logger.LogWarning("Could not find activity descriptor for activity type {ActivityType}", Type); + return; + } + DeclareInputAsVariables(activityDescriptor, (descriptor, variable) => { var inputName = descriptor.Name; diff --git a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityDescriptorFactory.cs b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityDescriptorFactory.cs index cb468def0..be3504c5e 100644 --- a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityDescriptorFactory.cs +++ b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityDescriptorFactory.cs @@ -5,7 +5,7 @@ using Humanizer; namespace Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity; -public class WorkflowDefinitionActivityDescriptorFactory(IActivityFactory activityFactory) +public class WorkflowDefinitionActivityDescriptorFactory { public ActivityDescriptor CreateDescriptor(WorkflowDefinition definition, WorkflowDefinition? latestPublishedDefinition = null) { @@ -32,6 +32,7 @@ public class WorkflowDefinitionActivityDescriptorFactory(IActivityFactory activi return new() { TypeName = typeName, + ClrType = typeof(WorkflowDefinitionActivity), Name = typeName, Version = definition.Version, DisplayName = definition.Name, @@ -56,7 +57,7 @@ public class WorkflowDefinitionActivityDescriptorFactory(IActivityFactory activi }, Constructor = context => { - var activity = (WorkflowDefinitionActivity)activityFactory.Create(typeof(WorkflowDefinitionActivity), context); + var activity = context.CreateActivity(); activity.Type = typeName; activity.WorkflowDefinitionId = definition.DefinitionId; activity.WorkflowDefinitionVersionId = definition.Id; diff --git a/src/modules/Elsa.Workflows.Management/Entities/WorkflowDefinition.cs b/src/modules/Elsa.Workflows.Management/Entities/WorkflowDefinition.cs index 5eb87a812..462476fbf 100644 --- a/src/modules/Elsa.Workflows.Management/Entities/WorkflowDefinition.cs +++ b/src/modules/Elsa.Workflows.Management/Entities/WorkflowDefinition.cs @@ -78,7 +78,14 @@ public class WorkflowDefinition : VersionedEntity /// A textual representation of the workflow. The data is to be interpreted by the configured materializer. /// public string? StringData { get; set; } - + + /// + /// The original source representation of the workflow (JSON, ElsaScript, YAML, etc.). + /// When present, materializers should prefer this over StringData for full round-trip fidelity. + /// This field enables symmetric materialization without requiring serialization round-trips. + /// + public string? OriginalSource { get; set; } + /// /// A binary representation of the workflow. The data is to be interpreted by the configured materializer. /// diff --git a/src/modules/Elsa.Workflows.Management/Extensions/ActivityDescriberExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/ActivityDescriberExtensions.cs index e32ca0ae3..bd13f975c 100644 --- a/src/modules/Elsa.Workflows.Management/Extensions/ActivityDescriberExtensions.cs +++ b/src/modules/Elsa.Workflows.Management/Extensions/ActivityDescriberExtensions.cs @@ -11,33 +11,35 @@ namespace Elsa.Workflows.Management; [PublicAPI] public static class ActivityDescriberExtensions { - /// - /// Describes an output property. - /// /// The activity describer. - /// The property expression. - /// The cancellation token. - /// The type of the activity. - /// The type of the property. - /// The output descriptor. - public static async Task DescribeOutputProperty(this IActivityDescriber activityDescriber, Expression> expression, CancellationToken cancellationToken = default) + extension(IActivityDescriber activityDescriber) { - var propertyInfo = expression.GetProperty()!; - return await activityDescriber.DescribeOutputPropertyAsync(propertyInfo, cancellationToken); - } + /// + /// Describes an output property. + /// + /// The property expression. + /// The cancellation token. + /// The type of the activity. + /// The type of the property. + /// The output descriptor. + public async Task DescribeOutputProperty(Expression> expression, CancellationToken cancellationToken = default) + { + var propertyInfo = expression.GetProperty()!; + return await activityDescriber.DescribeOutputPropertyAsync(propertyInfo, cancellationToken); + } - /// - /// Describes an input property. - /// - /// The activity describer. - /// The property expression. - /// The cancellation token. - /// The type of the activity. - /// The type of the property. - /// The input descriptor. - public static async Task DescribeInputPropertyAsync(this IActivityDescriber activityDescriber, Expression> expression, CancellationToken cancellationToken = default) - { - var propertyInfo = expression.GetProperty()!; - return await activityDescriber.DescribeInputPropertyAsync(propertyInfo, cancellationToken); + /// + /// Describes an input property. + /// + /// The property expression. + /// The cancellation token. + /// The type of the activity. + /// The type of the property. + /// The input descriptor. + public async Task DescribeInputPropertyAsync(Expression> expression, CancellationToken cancellationToken = default) + { + var propertyInfo = expression.GetProperty()!; + return await activityDescriber.DescribeInputPropertyAsync(propertyInfo, cancellationToken); + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Extensions/ActivityFactoryExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/ActivityFactoryExtensions.cs deleted file mode 100644 index a8ad2960b..000000000 --- a/src/modules/Elsa.Workflows.Management/Extensions/ActivityFactoryExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Elsa.Workflows; -using Elsa.Workflows.Models; - -// ReSharper disable once CheckNamespace -namespace Elsa.Extensions; - -/// -/// Provides a set of extension methods for . -/// -public static class ActivityFactoryExtensions -{ - /// - /// Creates an activity of the specified type. - /// - /// The activity factory. - /// The activity constructor context. - /// The type of the activity to create. - /// The created activity. - public static T Create(this IActivityFactory activityFactory, ActivityConstructorContext context) => (T)activityFactory.Create(typeof(T), context); -} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs b/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs index c1c3e491c..cd0bc53b7 100644 --- a/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs +++ b/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs @@ -81,7 +81,7 @@ public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegist var isArray = valueType.IsArray; var isCollection = valueType.IsCollectionType(); - var elementValueType = isArray ? valueType.GetElementType() : isCollection ? valueType.GenericTypeArguments[0] : valueType; + var elementValueType = isArray ? valueType.GetElementType()! : isCollection ? valueType.GenericTypeArguments[0] : valueType; var elementTypeAlias = wellKnownTypeRegistry.GetAliasOrDefault(elementValueType); return new(source.Id, source.Name, elementTypeAlias, isArray, serializedValue, storageDriverTypeName); diff --git a/src/modules/Elsa.Workflows.Management/Mappers/WorkflowDefinitionMapper.cs b/src/modules/Elsa.Workflows.Management/Mappers/WorkflowDefinitionMapper.cs index cd22cc0a4..dda7732b6 100644 --- a/src/modules/Elsa.Workflows.Management/Mappers/WorkflowDefinitionMapper.cs +++ b/src/modules/Elsa.Workflows.Management/Mappers/WorkflowDefinitionMapper.cs @@ -32,6 +32,15 @@ public class WorkflowDefinitionMapper /// The mapped . public Workflow Map(WorkflowDefinition source) { + // NEW WAY: If OriginalSource is present, deserialize the full WorkflowDefinitionModel + // This enables symmetric round-trip without re-serialization + if (!string.IsNullOrEmpty(source.OriginalSource)) + { + var model = _activitySerializer.Deserialize(source.OriginalSource); + return MapFromModelWithIdentity(model, source); + } + + // OLD WAY: Deserialize from StringData (backwards compatibility) var root = _activitySerializer.Deserialize(source.StringData!); return new( @@ -49,6 +58,30 @@ public class WorkflowDefinitionMapper source.IsSystem); } + /// + /// Maps a to a , using identity from stored definition. + /// + private Workflow MapFromModelWithIdentity(WorkflowDefinitionModel model, WorkflowDefinition storedDefinition) + { + var root = model.Root!; + var variables = _variableDefinitionMapper.Map(model.Variables).ToList(); + var options = model.Options ?? new WorkflowOptions(); + + return new( + new(storedDefinition.DefinitionId, storedDefinition.Version, storedDefinition.Id, storedDefinition.TenantId), + new(storedDefinition.IsLatest, storedDefinition.IsPublished), + new(model.Name, model.Description, model.CreatedAt, model.ToolVersion), + options, + root, + variables, + model.Inputs ?? new List(), + model.Outputs ?? new List(), + model.Outcomes ?? new List(), + model.CustomProperties ?? new Dictionary(), + storedDefinition.IsReadonly, + storedDefinition.IsSystem); + } + /// /// Maps a to a . /// diff --git a/src/modules/Elsa.Workflows.Management/Models/WorkflowDefinitionModel.cs b/src/modules/Elsa.Workflows.Management/Models/WorkflowDefinitionModel.cs index 598155655..b9badee90 100644 --- a/src/modules/Elsa.Workflows.Management/Models/WorkflowDefinitionModel.cs +++ b/src/modules/Elsa.Workflows.Management/Models/WorkflowDefinitionModel.cs @@ -60,8 +60,8 @@ public class WorkflowDefinitionModel Root = root; } - public string Id { get; set; } - public string DefinitionId { get; set; } + public string Id { get; set; } = null!; + public string DefinitionId { get; set; } = null!; public string? TenantId { get; set; } public string? Name { get; set; } public string? Description { get; set; } diff --git a/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionPublished.cs b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionPublished.cs index 62c84c96c..eed280722 100644 --- a/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionPublished.cs +++ b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionPublished.cs @@ -9,5 +9,6 @@ namespace Elsa.Workflows.Management.Notifications; /// A notification that is sent when a workflow definition is published. /// /// The workflow definition. +/// The affected workflows. [PublicAPI] public record WorkflowDefinitionPublished(WorkflowDefinition WorkflowDefinition, AffectedWorkflows AffectedWorkflows) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionValidating.cs b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionValidating.cs index 967c56686..2da147834 100644 --- a/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionValidating.cs +++ b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionValidating.cs @@ -8,4 +8,5 @@ namespace Elsa.Workflows.Management.Notifications; /// A request to validate a workflow definition. /// /// The workflow materialized from the definition. +/// The collection of validation errors. public record WorkflowDefinitionValidating(Workflow Workflow, ICollection ValidationErrors) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs b/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs index c8986e2d6..3ebba7b16 100644 --- a/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs +++ b/src/modules/Elsa.Workflows.Management/Providers/TypedActivityProvider.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using Elsa.Workflows.Management.Options; using Elsa.Workflows.Models; +using JetBrains.Annotations; using Microsoft.Extensions.Options; namespace Elsa.Workflows.Management.Providers; @@ -8,6 +9,7 @@ namespace Elsa.Workflows.Management.Providers; /// /// Provides activity descriptors based on a list of activity types registered in the . /// +[UsedImplicitly] public class TypedActivityProvider : IActivityProvider { private readonly IActivityDescriber _activityDescriber; diff --git a/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs b/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs index 251ac52dd..80aa11d5a 100644 --- a/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs +++ b/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs @@ -75,6 +75,12 @@ public class DistributedWorkflowClient( return await _localWorkflowClient.InstanceExistsAsync(cancellationToken); } + public async Task DeleteAsync(CancellationToken cancellationToken = default) + { + // Use the same distributed lock as for execution to prevent concurrent DB writes + return await WithLockAsync(async () => await _localWorkflowClient.DeleteAsync(cancellationToken)); + } + private async Task WithLockAsync(Func> func) { var lockKey = $"workflow-instance:{WorkflowInstanceId}"; diff --git a/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs b/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs index 9619e1d73..22046821f 100644 --- a/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs +++ b/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs @@ -59,7 +59,7 @@ public class DispatchWorkflow : Activity /// Indicates whether a new trace context should be started for the workflow execution. /// [Input(Description = "Start a new trace context when using Open Telemetry.", Category = "Open Telemetry")] - public Input StartNewTrace { get; set; } + public Input StartNewTrace { get; set; } = null!; /// /// The channel to dispatch the workflow to. diff --git a/src/modules/Elsa.Workflows.Runtime/Activities/PublishEvent.cs b/src/modules/Elsa.Workflows.Runtime/Activities/PublishEvent.cs index 32478f736..9f6542bbd 100644 --- a/src/modules/Elsa.Workflows.Runtime/Activities/PublishEvent.cs +++ b/src/modules/Elsa.Workflows.Runtime/Activities/PublishEvent.cs @@ -41,7 +41,7 @@ public class PublishEvent([CallerFilePath] string? source = null, [CallerLineNum protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { var eventName = EventName.Get(context); - var correlationId = CorrelationId.GetOrDefault(context).NullIfEmpty(); + var correlationId = CorrelationId.GetOrDefault(context).NullIfWhiteSpace(); var isLocalEvent = IsLocalEvent.GetOrDefault(context); var workflowInstanceId = isLocalEvent ? context.WorkflowExecutionContext.Id : null; var payload = Payload.GetOrDefault(context); diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs index 8753a76bd..9e22e0522 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs @@ -42,6 +42,11 @@ public interface IWorkflowClient /// Imports the specified . /// Task ImportStateAsync(WorkflowState workflowState, CancellationToken cancellationToken = default); - + Task InstanceExistsAsync(CancellationToken cancellationToken = default); + + /// + /// Deletes the workflow instance. This method ensures that the instance is properly stopped before deletion. + /// + Task DeleteAsync(CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDefinitionStorePopulator.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDefinitionStorePopulator.cs index 1eabb75bd..389ec0630 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDefinitionStorePopulator.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDefinitionStorePopulator.cs @@ -15,7 +15,7 @@ public interface IWorkflowDefinitionStorePopulator Task> PopulateStoreAsync(CancellationToken cancellationToken = default); /// - /// Populates the with workflow definitions provided from implementations. + /// Populates the with workflow definitions provided from implementations. /// /// Whether to index triggers. /// The cancellation token. diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDispatcher.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDispatcher.cs index 0a99360f1..19aca5c02 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDispatcher.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDispatcher.cs @@ -11,20 +11,20 @@ public interface IWorkflowDispatcher /// /// Dispatches a request to execute the specified workflow definition. /// - Task DispatchAsync(DispatchWorkflowDefinitionRequest request, DispatchWorkflowOptions options, CancellationToken cancellationToken = default); + Task DispatchAsync(DispatchWorkflowDefinitionRequest request, DispatchWorkflowOptions? options, CancellationToken cancellationToken = default); /// /// Dispatches a request to execute the specified workflow instance. /// - Task DispatchAsync(DispatchWorkflowInstanceRequest request, DispatchWorkflowOptions options, CancellationToken cancellationToken = default); + Task DispatchAsync(DispatchWorkflowInstanceRequest request, DispatchWorkflowOptions? options, CancellationToken cancellationToken = default); /// /// Starts all workflows and resumes existing workflow instances based on the specified activity type and bookmark payload. /// - Task DispatchAsync(DispatchTriggerWorkflowsRequest request, DispatchWorkflowOptions options, CancellationToken cancellationToken = default); + Task DispatchAsync(DispatchTriggerWorkflowsRequest request, DispatchWorkflowOptions? options, CancellationToken cancellationToken = default); /// /// Resumes the workflow waiting for the specified bookmark. /// - Task DispatchAsync(DispatchResumeWorkflowsRequest request, DispatchWorkflowOptions options, CancellationToken cancellationToken = default); + Task DispatchAsync(DispatchResumeWorkflowsRequest request, DispatchWorkflowOptions? options, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionLogStore.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionLogStore.cs index 08bbe25cc..50a4767a0 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionLogStore.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionLogStore.cs @@ -15,11 +15,6 @@ public interface IWorkflowExecutionLogStore : ILogRecordStore Task AddAsync(WorkflowExecutionLogRecord record, CancellationToken cancellationToken = default); - /// - /// Adds the specified set of objects to te persistence store. - /// - Task AddManyAsync(IEnumerable records, CancellationToken cancellationToken = default); - /// /// Adds or updates the specified in the persistence store. /// diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index d7402bbfe..501a67276 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -17,7 +17,6 @@ using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Handlers; using Elsa.Workflows.Runtime.Options; using Elsa.Workflows.Runtime.Providers; -using Elsa.Workflows.Runtime.Services; using Elsa.Workflows.Runtime.Stores; using Elsa.Workflows.Runtime.Tasks; using Elsa.Workflows.Runtime.UIHints; @@ -105,12 +104,12 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) public Func BackgroundActivityScheduler { get; set; } = sp => ActivatorUtilities.CreateInstance(sp); /// - /// A factory that instantiates an for an . + /// A factory that instantiates a log record sink for an . /// public Func> ActivityExecutionLogSink { get; set; } = sp => sp.GetRequiredService(); /// - /// A factory that instantiates an for an . + /// A factory that instantiates a log record sink for an . /// public Func> WorkflowExecutionLogSink { get; set; } = sp => sp.GetRequiredService(); diff --git a/src/modules/Elsa.Workflows.Runtime/Filters/TriggerFilter.cs b/src/modules/Elsa.Workflows.Runtime/Filters/TriggerFilter.cs index cab0d7536..8435349aa 100644 --- a/src/modules/Elsa.Workflows.Runtime/Filters/TriggerFilter.cs +++ b/src/modules/Elsa.Workflows.Runtime/Filters/TriggerFilter.cs @@ -69,7 +69,7 @@ public class TriggerFilter if (WorkflowDefinitionVersionId != null) queryable = queryable.Where(x => x.WorkflowDefinitionVersionId == WorkflowDefinitionVersionId); if (WorkflowDefinitionVersionIds != null) queryable = queryable.Where(x => WorkflowDefinitionVersionIds.Contains(x.WorkflowDefinitionVersionId)); if (Name != null) queryable = queryable.Where(x => x.Name == Name); - if (Names != null) queryable = queryable.Where(x => Names.Contains(x.Name)); + if (Names != null) queryable = queryable.Where(x => Names.Contains(x.Name!)); if (Hash != null) queryable = queryable.Where(x => x.Hash == Hash); return queryable; } diff --git a/src/modules/Elsa.Workflows.Runtime/Handlers/ResumeDispatchWorkflowActivity.cs b/src/modules/Elsa.Workflows.Runtime/Handlers/ResumeDispatchWorkflowActivity.cs index 37b95583a..0b5046134 100644 --- a/src/modules/Elsa.Workflows.Runtime/Handlers/ResumeDispatchWorkflowActivity.cs +++ b/src/modules/Elsa.Workflows.Runtime/Handlers/ResumeDispatchWorkflowActivity.cs @@ -4,7 +4,6 @@ using Elsa.Workflows.Notifications; using Elsa.Workflows.Runtime.Activities; using Elsa.Workflows.Runtime.Stimuli; using JetBrains.Annotations; -using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Runtime.Handlers; @@ -12,7 +11,7 @@ namespace Elsa.Workflows.Runtime.Handlers; /// Resumes any blocking activities when its child workflow completes. /// [PublicAPI] -internal class ResumeDispatchWorkflowActivity(IBookmarkQueue bookmarkQueue, IStimulusHasher stimulusHasher, ILogger logger) : INotificationHandler +internal class ResumeDispatchWorkflowActivity(IBookmarkQueue bookmarkQueue, IStimulusHasher stimulusHasher) : INotificationHandler { private static readonly string ActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); diff --git a/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs b/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs index 4feafd362..4d419196d 100644 --- a/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs +++ b/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs @@ -9,4 +9,5 @@ namespace Elsa.Workflows.Runtime; /// The name of the provider that provided the workflow definition. /// The name of the materializer that materialized the workflow. /// The context of the materializer that materialized the workflow. -public record MaterializedWorkflow(Workflow Workflow, string ProviderName, string MaterializerName, object? MaterializerContext = null); \ No newline at end of file +/// The original source representation (JSON, ElsaScript, YAML, etc.). When present, enables symmetric round-trip preservation without re-serialization. +public record MaterializedWorkflow(Workflow Workflow, string ProviderName, string MaterializerName, object? MaterializerContext = null, string? OriginalSource = null); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/IndexedWorkflowBookmarks.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/IndexedWorkflowBookmarks.cs index 500564992..f8861ab3c 100644 --- a/src/modules/Elsa.Workflows.Runtime/Notifications/IndexedWorkflowBookmarks.cs +++ b/src/modules/Elsa.Workflows.Runtime/Notifications/IndexedWorkflowBookmarks.cs @@ -3,14 +3,14 @@ using Elsa.Workflows.Models; namespace Elsa.Workflows.Runtime.Notifications; /// -/// Contains the bookmarks that were added, removed, or unchanged. +/// Contains the bookmarks that were added, removed, or unchanged. /// -/// The workflow instance ID. +/// The workflow execution context. /// The bookmarks that were added. /// The bookmarks that were removed. /// The bookmarks that were unchanged. public record IndexedWorkflowBookmarks( WorkflowExecutionContext WorkflowExecutionContext, - ICollection AddedBookmarks, - ICollection RemovedBookmarks, + ICollection AddedBookmarks, + ICollection RemovedBookmarks, ICollection UnchangedBookmarks); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/RunTaskRequest.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/RunTaskRequest.cs index 2aaddb867..10e73c572 100644 --- a/src/modules/Elsa.Workflows.Runtime/Notifications/RunTaskRequest.cs +++ b/src/modules/Elsa.Workflows.Runtime/Notifications/RunTaskRequest.cs @@ -5,7 +5,7 @@ namespace Elsa.Workflows.Runtime.Notifications; /// /// A domain event that applications can subscribe to in order to handle tasks requested by a workflow. /// -/// name="ActivityExecutionContext">The context of the activity that requested the task to run. +/// The context of the activity that requested the task to run. /// A unique identifier for an individual task request. /// The name of the task requested to run. /// Any additional parameters to send to the task. diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs index 6303effd1..fe1ddd4dc 100644 --- a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs +++ b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs @@ -5,7 +5,6 @@ namespace Elsa.Workflows.Runtime.Notifications; /// /// A notification that is sent when the bookmarks of a workflow instance have been indexed. /// -/// The workflow execution context. /// The bookmarks that were added, removed, or unchanged. public record WorkflowBookmarksIndexed(IndexedWorkflowBookmarks IndexedWorkflowBookmarks) : INotification; diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs index b5b5f2fb2..2d176c39b 100644 --- a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs +++ b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs @@ -7,6 +7,5 @@ namespace Elsa.Workflows.Runtime.Notifications; /// /// Published when the bookmarks of a workflow instance have been persisted. /// -/// The workflow execution context. /// The bookmarks that were added, removed, or unchanged. public record WorkflowBookmarksPersisted(Diff Diff) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Requests/UpdateBookmarksRequest.cs b/src/modules/Elsa.Workflows.Runtime/Requests/UpdateBookmarksRequest.cs index b6e6b145d..44e7e377b 100644 --- a/src/modules/Elsa.Workflows.Runtime/Requests/UpdateBookmarksRequest.cs +++ b/src/modules/Elsa.Workflows.Runtime/Requests/UpdateBookmarksRequest.cs @@ -6,7 +6,7 @@ namespace Elsa.Workflows.Runtime.Requests; /// /// Published when bookmarks needs to be updated. /// -/// The workflow instance ID. +/// The workflow execution context. /// A diff of the bookmarks. /// The correlation ID, if any. public record UpdateBookmarksRequest(WorkflowExecutionContext WorkflowExecutionContext, Diff Diff, string? CorrelationId = default); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs index 9fb839321..334b0d035 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs @@ -120,8 +120,25 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP var materializerContext = materializedWorkflow.MaterializerContext; var materializerContextJson = materializerContext != null ? _payloadSerializer.Serialize(materializerContext) : null; - // Serialize the workflow root. - var workflowJson = _activitySerializer.Serialize(workflow.Root); + // Determine StringData and OriginalSource based on what's provided + string? stringData; + var originalSource = materializedWorkflow.OriginalSource; + + if (originalSource != null) + { + // NEW WAY: OriginalSource is provided + // For JSON workflows, we still need to populate StringData with the serialized root for backwards compatibility + stringData = materializedWorkflow.MaterializerName == "Json" ? _activitySerializer.Serialize(workflow.Root) : + // For new formats (ElsaScript, YAML, etc.), only OriginalSource is needed + // StringData can be null as these materializers only use OriginalSource + null; + } + else + { + // OLD WAY: No OriginalSource provided (backwards compatibility for existing workflows) + // Serialize the workflow root as before + stringData = _activitySerializer.Serialize(workflow.Root); + } // Check if there's already a workflow definition stored with this definition ID and version. var specificVersionFilter = new WorkflowDefinitionFilter @@ -171,7 +188,8 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP workflowDefinition.Inputs = workflow.Inputs; workflowDefinition.Outputs = workflow.Outputs; workflowDefinition.Outcomes = workflow.Outcomes; - workflowDefinition.StringData = workflowJson; + workflowDefinition.StringData = stringData; + workflowDefinition.OriginalSource = originalSource; workflowDefinition.CreatedAt = workflow.WorkflowMetadata.CreatedAt == default ? _systemClock.UtcNow : workflow.WorkflowMetadata.CreatedAt; workflowDefinition.Options = workflow.Options; workflowDefinition.ProviderName = materializedWorkflow.ProviderName; diff --git a/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs b/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs index b72871f69..3fc283d3e 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs @@ -1,5 +1,6 @@ using Elsa.Workflows.Management; using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Management.Mappers; using Elsa.Workflows.Management.Options; using Elsa.Workflows.Models; @@ -81,6 +82,11 @@ public class LocalWorkflowClient( public async Task CancelAsync(CancellationToken cancellationToken = default) { var workflowInstance = await GetWorkflowInstanceAsync(cancellationToken); + await CancelAsync(workflowInstance, cancellationToken); + } + + private async Task CancelAsync(WorkflowInstance workflowInstance, CancellationToken cancellationToken) + { if (workflowInstance.Status != WorkflowStatus.Running) return; var workflowGraph = await GetWorkflowGraphAsync(workflowInstance, cancellationToken); var workflowState = await workflowCanceler.CancelWorkflowAsync(workflowGraph, workflowInstance.WorkflowState, cancellationToken); @@ -106,6 +112,22 @@ public class LocalWorkflowClient( return workflowInstanceManager.ExistsAsync(workflowInstanceId, cancellationToken); } + /// + public async Task DeleteAsync(CancellationToken cancellationToken = default) + { + // Load the workflow instance (single DB call) + var workflowInstance = await TryGetWorkflowInstanceAsync(cancellationToken); + if (workflowInstance == null) + return false; + + await CancelAsync(workflowInstance, cancellationToken); + + // Delete the workflow instance + var filter = new WorkflowInstanceFilter { Id = workflowInstanceId }; + await workflowInstanceManager.DeleteAsync(filter, cancellationToken); + return true; + } + public async Task RunInstanceAsync(WorkflowInstance workflowInstance, RunWorkflowInstanceRequest request, CancellationToken cancellationToken = default) { var workflowState = workflowInstance.WorkflowState; @@ -167,11 +189,16 @@ public class LocalWorkflowClient( private async Task GetWorkflowInstanceAsync(CancellationToken cancellationToken) { - var workflowInstance = await workflowInstanceManager.FindByIdAsync(WorkflowInstanceId, cancellationToken); - if (workflowInstance == null) throw new WorkflowInstanceNotFoundException($"Workflow instance not found.", WorkflowInstanceId); + var workflowInstance = await TryGetWorkflowInstanceAsync(cancellationToken); + if (workflowInstance == null) throw new WorkflowInstanceNotFoundException("Workflow instance not found.", WorkflowInstanceId); return workflowInstance; } + private Task TryGetWorkflowInstanceAsync(CancellationToken cancellationToken) + { + return workflowInstanceManager.FindByIdAsync(WorkflowInstanceId, cancellationToken); + } + private async Task GetWorkflowGraphAsync(WorkflowInstance workflowInstance, CancellationToken cancellationToken) { var handle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowInstance.DefinitionVersionId); @@ -181,7 +208,7 @@ public class LocalWorkflowClient( private async Task GetWorkflowGraphAsync(WorkflowDefinitionHandle definitionHandle, CancellationToken cancellationToken) { var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(definitionHandle, cancellationToken); - if (workflowGraph == null) throw new WorkflowGraphNotFoundException($"Workflow graph not found.", definitionHandle); + if (workflowGraph == null) throw new WorkflowGraphNotFoundException("Workflow graph not found.", definitionHandle); return workflowGraph; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs b/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs index 33cafbe82..f317b7403 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs @@ -1,3 +1,4 @@ + using Elsa.Common.Models; using Elsa.Workflows.Management; using Elsa.Workflows.Management.Filters; @@ -66,7 +67,7 @@ public class ObsoleteWorkflowRuntime( TriggerActivityId = options?.TriggerActivityId }; var response = await client.CreateAndRunInstanceAsync(createRequest, cancellationToken); - return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents, null, null); + return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents, null, new Dictionary()); } public async Task> StartWorkflowsAsync(string activityTypeName, object bookmarkPayload, TriggerWorkflowsOptions? options = null) @@ -81,7 +82,7 @@ public class ObsoleteWorkflowRuntime( Input = options?.Input }; var result = await stimulusSender.SendAsync(activityTypeName, bookmarkPayload, metadata, cancellationToken); - var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, null)).ToList(); + var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, new Dictionary())).ToList(); return results; } @@ -109,7 +110,7 @@ public class ObsoleteWorkflowRuntime( var response = await workflowClient.RunInstanceAsync(runWorkflowRequest, cancellationToken); - return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents,null, null); + return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents,null, new Dictionary()); } public async Task> ResumeWorkflowsAsync(string activityTypeName, object bookmarkPayload, TriggerWorkflowsOptions? options = null) @@ -124,7 +125,7 @@ public class ObsoleteWorkflowRuntime( Input = options?.Input }; var result = await stimulusSender.SendAsync(activityTypeName, bookmarkPayload, metadata, cancellationToken); - var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, null)).ToList(); + var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, new Dictionary())).ToList(); return results; } @@ -140,7 +141,7 @@ public class ObsoleteWorkflowRuntime( Input = options?.Input }; var result = await stimulusSender.SendAsync(activityTypeName, bookmarkPayload, metadata, cancellationToken); - var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, null)).ToList(); + var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, new Dictionary())).ToList(); return new(results); } diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs b/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs index 9a16af564..3a5d233aa 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs @@ -182,7 +182,7 @@ public class StimulusProxyWorkflowInbox( new List(), response.Incidents, null, - null) + new Dictionary()) ); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs b/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs index 72325955b..b3abf9795 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs @@ -3,14 +3,13 @@ using Elsa.Mediator.Contracts; using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Notifications; -namespace Elsa.Workflows.Runtime.Services; +namespace Elsa.Workflows.Runtime; /// /// This implementation saves directly through the store. /// public class StoreActivityExecutionLogSink( IActivityExecutionStore activityExecutionStore, - IActivityExecutionMapper mapper, INotificationSender notificationSender) : ILogRecordSink { diff --git a/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs b/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs index 474dff551..008d75e30 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs @@ -62,14 +62,21 @@ public class TriggerIndexer : ITriggerIndexer var triggers = (await _triggerStore.FindManyAsync(filter, cancellationToken)).ToList(); var workflowDefinitionVersionIds = triggers.Select(x => x.WorkflowDefinitionVersionId).Distinct().ToList(); - foreach (string workflowDefinitionVersionId in workflowDefinitionVersionIds) + foreach (var workflowDefinitionVersionId in workflowDefinitionVersionIds) { - var workflowGraph = await _workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionVersionId, cancellationToken); + try + { + var workflowGraph = await _workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionVersionId, cancellationToken); - if (workflowGraph == null) - continue; - - await DeleteTriggersAsync(workflowGraph.Workflow, cancellationToken); + if (workflowGraph == null) + continue; + + await DeleteTriggersAsync(workflowGraph.Workflow, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load workflow graph for workflow definition version {WorkflowDefinitionVersionId}. Skipping trigger deletion for this workflow.", workflowDefinitionVersionId); + } } } diff --git a/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs b/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs index 6a9c448a1..41361af8b 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs @@ -9,12 +9,13 @@ namespace Elsa.Workflows.Runtime; /// Validates the workflow request before dispatching it to the workflow dispatcher. /// /// The workflow dispatcher to decorate. +/// The workflow dispatcher options. public class ValidatingWorkflowDispatcher(IWorkflowDispatcher decoratedService, IOptions dispatcherOptions) : IWorkflowDispatcher { private IWorkflowDispatcher DecoratedService { get; set; } = decoratedService; /// - public async Task DispatchAsync(DispatchWorkflowDefinitionRequest request, DispatchWorkflowOptions? options = default, CancellationToken cancellationToken = default) + public async Task DispatchAsync(DispatchWorkflowDefinitionRequest request, DispatchWorkflowOptions? options = null, CancellationToken cancellationToken = default) { if (!ValidateChannel(options?.Channel)) return DispatchWorkflowResponse.UnknownChannel(); @@ -23,7 +24,7 @@ public class ValidatingWorkflowDispatcher(IWorkflowDispatcher decoratedService, } /// - public async Task DispatchAsync(DispatchWorkflowInstanceRequest request, DispatchWorkflowOptions? options = default, CancellationToken cancellationToken = default) + public async Task DispatchAsync(DispatchWorkflowInstanceRequest request, DispatchWorkflowOptions? options = null, CancellationToken cancellationToken = default) { if (!ValidateChannel(options?.Channel)) return DispatchWorkflowResponse.UnknownChannel(); @@ -32,7 +33,7 @@ public class ValidatingWorkflowDispatcher(IWorkflowDispatcher decoratedService, } /// - public async Task DispatchAsync(DispatchTriggerWorkflowsRequest request, DispatchWorkflowOptions? options = default, CancellationToken cancellationToken = default) + public async Task DispatchAsync(DispatchTriggerWorkflowsRequest request, DispatchWorkflowOptions? options = null, CancellationToken cancellationToken = default) { if (!ValidateChannel(options?.Channel)) return DispatchWorkflowResponse.UnknownChannel(); @@ -41,7 +42,7 @@ public class ValidatingWorkflowDispatcher(IWorkflowDispatcher decoratedService, } /// - public async Task DispatchAsync(DispatchResumeWorkflowsRequest request, DispatchWorkflowOptions? options = default, CancellationToken cancellationToken = default) + public async Task DispatchAsync(DispatchResumeWorkflowsRequest request, DispatchWorkflowOptions? options = null, CancellationToken cancellationToken = default) { if (!ValidateChannel(options?.Channel)) return DispatchWorkflowResponse.UnknownChannel(); diff --git a/tempvalidator/Program.cs b/tempvalidator/Program.cs deleted file mode 100644 index 3751555cb..000000000 --- a/tempvalidator/Program.cs +++ /dev/null @@ -1,2 +0,0 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); diff --git a/tempvalidator/tempvalidator.csproj b/tempvalidator/tempvalidator.csproj deleted file mode 100644 index 2150e3797..000000000 --- a/tempvalidator/tempvalidator.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - - Exe - net8.0 - enable - enable - - - diff --git a/test-flowchart.txt b/test-flowchart.txt new file mode 100644 index 000000000..39439a8da --- /dev/null +++ b/test-flowchart.txt @@ -0,0 +1,16 @@ +use Elsa.Activities.Console; + +workflow FlowchartTest { + flowchart { + var counter = 0; + + entry Start; + + Start: WriteLine("Starting flowchart"); + CheckCondition: WriteLine("Checking condition"); + EndNode: WriteLine("Done!"); + + Start -> CheckCondition; + CheckCondition.Done -> EndNode; + } +} diff --git a/test-simple-flowchart.elsa b/test-simple-flowchart.elsa new file mode 100644 index 000000000..08e576f1e --- /dev/null +++ b/test-simple-flowchart.elsa @@ -0,0 +1,10 @@ +use Elsa.Activities.Console; + +workflow SimpleFlowchart { + flowchart { + entry A; + A: WriteLine("Test"); + A -> B; + B: WriteLine("Done"); + } +} diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 108bef4c9..42bf5fb48 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -3,7 +3,7 @@ - net9.0 + net10.0 enable enable false @@ -27,6 +27,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj index 97147d456..7d4751127 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj +++ b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj @@ -1,12 +1,12 @@ - net9.0 enable enable false true + 36 diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Activities/TriggerSignal.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Activities/TriggerSignal.cs index 66e936878..75efc701a 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Activities/TriggerSignal.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Activities/TriggerSignal.cs @@ -45,7 +45,7 @@ public class TriggerSignal : CodeActivity /// public TriggerSignal(Input eventName, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : this(source, line) => EventName = eventName; - public Input EventName { get; set; } + public Input EventName { get; set; } = null!; protected override void Execute(ActivityExecutionContext context) { diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs index 448fed7a8..34af7f849 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs @@ -127,7 +127,7 @@ public class BulkDispatchWorkflowsTests : AppComponentTest Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowExecutionContext.SubStatus); } - private async Task GetWorkflowVariableAsync(TestWorkflowExecutionResult result, string variableName) + private async Task GetWorkflowVariableAsync(TestWorkflowExecutionResult result, string variableName) { var variableManager = Scope.ServiceProvider.GetRequiredService(); var variables = await variableManager.GetVariablesAsync(result.WorkflowExecutionContext); diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/EventTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/EventTests.cs new file mode 100644 index 000000000..58149ef6f --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/EventTests.cs @@ -0,0 +1,63 @@ +using Elsa.Common.Models; +using Elsa.Testing.Shared.Services; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Elsa.Workflows.ComponentTests.Scenarios.Activities.Primitives.Event.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.Primitives.Event; + +public class EventTests : AppComponentTest +{ + private readonly AsyncWorkflowRunner _workflowRunner; + private readonly IEventPublisher _eventPublisher; + + public EventTests(App app) : base(app) + { + _workflowRunner = Scope.ServiceProvider.GetRequiredService(); + _eventPublisher = Scope.ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task PublishingEventToBlockingEventWorkflow_ShouldCompleteWorkflow() + { + // Start the workflow - it will block at the Event activity + var workflowTask = _workflowRunner.RunAndAwaitWorkflowCompletionAsync( + WorkflowDefinitionHandle.ByDefinitionId(BlockingEventWorkflow.DefinitionId, VersionOptions.Published)); + + // Publish the event to resume the workflow + await _eventPublisher.PublishAsync("Order Shipped"); + + // Wait for the workflow to complete + var result = await workflowTask; + + // Assert the workflow completed successfully + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowExecutionContext.SubStatus); + } + + [Fact] + public async Task PublishingEventToEventAsTriggerWorkflow_ShouldStartAndCompleteWorkflow() + { + // Publish the event - this should trigger the workflow to start + var correlationId = Guid.NewGuid().ToString(); + await _eventPublisher.PublishAsync("Order Shipped", correlationId); + + // The workflow should have been triggered and completed + // We can verify this by checking that the workflow was executed + var workflowInstances = Scope.ServiceProvider.GetRequiredService(); + var filter = new WorkflowInstanceFilter + { + DefinitionId = TriggerEventWorkflow.DefinitionId, + CorrelationId = correlationId + }; + + var instances = await workflowInstances.FindManyAsync(filter); + var instance = Assert.Single(instances); + Assert.Equal(WorkflowStatus.Finished, instance.Status); + Assert.Equal(WorkflowSubStatus.Finished, instance.SubStatus); + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs new file mode 100644 index 000000000..96e1f5cc2 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using Elsa.Common.Models; +using Elsa.Testing.Shared; +using Elsa.Testing.Shared.Services; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Elsa.Workflows.ComponentTests.Scenarios.Activities.Primitives.Event.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Messages; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.Primitives.Event; + +public class PublishEventTests : AppComponentTest +{ + private readonly AsyncWorkflowRunner _workflowRunner; + private readonly IWorkflowInstanceStore _workflowInstanceStore; + private readonly IWorkflowRuntime _workflowRuntime; + private readonly WorkflowEvents _workflowEvents; + + public PublishEventTests(App app) : base(app) + { + _workflowRunner = Scope.ServiceProvider.GetRequiredService(); + _workflowInstanceStore = Scope.ServiceProvider.GetRequiredService(); + _workflowRuntime = Scope.ServiceProvider.GetRequiredService(); + _workflowEvents = Scope.ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task PublishEvent_LocalEvent_CompletesWorkflow() + { + // Act + var result = await _workflowRunner.RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle.ByDefinitionId(PublishAndConsumeEventWorkflow.DefinitionId, VersionOptions.Published)); + + // Assert + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowExecutionContext.SubStatus); + } + + [Fact] + public async Task PublishEvent_GlobalEvent_TriggersConsumerWorkflow() + { + // Arrange + var correlationId = Guid.NewGuid().ToString(); + + // Act - Publish global event + await RunWorkflowAsync(PublishGlobalEventWorkflow.DefinitionId, correlationId); + + // Assert - Consumer workflow was triggered and completed + var consumerInstance = await GetSingleWorkflowInstanceAsync(ConsumerWorkflow.DefinitionId, correlationId); + Assert.Equal(WorkflowStatus.Finished, consumerInstance.Status); + Assert.Equal(WorkflowSubStatus.Finished, consumerInstance.SubStatus); + } + + [Fact] + public async Task PublishEvent_WithPayload_TransmitsPayloadToConsumer() + { + // Arrange + var correlationId = Guid.NewGuid().ToString(); + + // Act - Publish global event with payload + await RunWorkflowAsync(PublishGlobalEventWorkflow.DefinitionId, correlationId); + + // Assert - Consumer workflow received the payload + var consumerInstance = await GetSingleWorkflowInstanceAsync(ConsumerWorkflow.DefinitionId, correlationId); + Assert.Equal(WorkflowStatus.Finished, consumerInstance.Status); + Assert.Equal(WorkflowSubStatus.Finished, consumerInstance.SubStatus); + + // Verify the payload was captured in the output + Assert.True(consumerInstance.WorkflowState.Output.TryGetValue("ReceivedPayload", out var receivedPayload), "Consumer workflow should have ReceivedPayload output"); + Assert.NotNull(receivedPayload); + + // Verify the payload structure and content + var payloadJson = JsonSerializer.Serialize(receivedPayload); + Assert.Contains("\"Status\"", payloadJson); + Assert.Contains("\"Shipped\"", payloadJson); + } + + private async Task RunWorkflowAsync(string definitionId, string? correlationId = null) + { + var workflowClient = await _workflowRuntime.CreateClientAsync(); + await workflowClient.CreateInstanceAsync(new() + { + WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Published), + CorrelationId = correlationId + }); + await workflowClient.RunInstanceAsync(RunWorkflowInstanceRequest.Empty); + } + + private async Task GetSingleWorkflowInstanceAsync(string definitionId, string correlationId, int timeoutMs = 5000) + { + var tcs = new TaskCompletionSource(); + var cts = new CancellationTokenSource(timeoutMs); + + // Register cancellation to fail the task on timeout + cts.Token.Register(() => tcs.TrySetException(new TimeoutException($"Workflow instance with DefinitionId '{definitionId}' and CorrelationId '{correlationId}' was not saved within {timeoutMs}ms"))); + + // Subscribe to the WorkflowInstanceSaved event + void OnWorkflowInstanceSaved(object? sender, WorkflowInstanceSavedEventArgs args) + { + if (args.WorkflowInstance.DefinitionId == definitionId && args.WorkflowInstance.CorrelationId == correlationId) + { + tcs.TrySetResult(args.WorkflowInstance); + } + } + + _workflowEvents.WorkflowInstanceSaved += OnWorkflowInstanceSaved; + + try + { + // Check if the instance already exists in the database + var existingInstances = (await _workflowInstanceStore.FindManyAsync(new() + { + DefinitionId = definitionId, + CorrelationId = correlationId + }, cts.Token)).ToList(); + + if (existingInstances.Any()) + return Assert.Single(existingInstances); + + // Wait for the event to be raised + return await tcs.Task; + } + finally + { + _workflowEvents.WorkflowInstanceSaved -= OnWorkflowInstanceSaved; + cts.Dispose(); + } + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/BlockingEventWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/BlockingEventWorkflow.cs new file mode 100644 index 000000000..91047abb7 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/BlockingEventWorkflow.cs @@ -0,0 +1,21 @@ +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.Primitives.Event.Workflows; + +public class BlockingEventWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + workflow.Root = new Sequence + { + Activities = + { + new Start(), + new Runtime.Activities.Event("Order Shipped"), + new End() + } + }; + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/ConsumerWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/ConsumerWorkflow.cs new file mode 100644 index 000000000..547460c07 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/ConsumerWorkflow.cs @@ -0,0 +1,42 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management.Activities.SetOutput; +using Elsa.Workflows.Memory; + +namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.Primitives.Event.Workflows; + +/// +/// A workflow that listens for global events as a trigger and captures the payload. +/// +public class ConsumerWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + var eventPayload = new Variable(); + + workflow.WithDefinitionId(DefinitionId); + workflow.WithVariable(eventPayload); + workflow.WithOutput("ReceivedPayload"); // Declare the output + + var eventActivity = new Runtime.Activities.Event("GlobalOrderEvent") + { + CanStartWorkflow = true, + Result = new(eventPayload) + }; + + workflow.Root = new Sequence + { + Activities = + { + eventActivity, + new SetOutput + { + OutputName = new("ReceivedPayload"), + OutputValue = new(eventPayload) + }, + new End() + } + }; + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/PublishAndConsumeEventWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/PublishAndConsumeEventWorkflow.cs new file mode 100644 index 000000000..b51d53764 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/PublishAndConsumeEventWorkflow.cs @@ -0,0 +1,34 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Runtime.Activities; + +namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.Primitives.Event.Workflows; + +/// +/// A workflow that publishes an event to itself (local event). +/// +public class PublishAndConsumeEventWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + workflow.Root = new Sequence + { + Activities = + { + new Start(), + // Publish a local event + new PublishEvent + { + EventName = new("LocalOrderEvent"), + IsLocalEvent = new(true), + Payload = new(new { OrderId = 123 }) + }, + // Wait for the local event + new Elsa.Workflows.Runtime.Activities.Event("LocalOrderEvent"), + new End() + } + }; + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/PublishGlobalEventWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/PublishGlobalEventWorkflow.cs new file mode 100644 index 000000000..4fbbb7824 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/PublishGlobalEventWorkflow.cs @@ -0,0 +1,33 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Runtime.Activities; + +namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.Primitives.Event.Workflows; + +/// +/// A workflow that publishes a global event with the workflow's correlation ID. +/// +public class PublishGlobalEventWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + workflow.Root = new Sequence + { + Activities = + { + new Start(), + new PublishEvent + { + EventName = new("GlobalOrderEvent"), + CorrelationId = new(context => context.GetWorkflowExecutionContext().CorrelationId), + IsLocalEvent = new(false), + Payload = new(new { Status = "Shipped" }) + }, + new End() + } + }; + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/TriggerEventWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/TriggerEventWorkflow.cs new file mode 100644 index 000000000..ceaa12bd5 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/Workflows/TriggerEventWorkflow.cs @@ -0,0 +1,23 @@ +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.Primitives.Event.Workflows; + +public class TriggerEventWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + workflow.Root = new Sequence + { + Activities = + { + new Runtime.Activities.Event("Order Shipped") + { + CanStartWorkflow = true + }, + new End() + } + }; + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Activities/RemoveTopElementStep.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Activities/RemoveTopElementStep.cs new file mode 100644 index 000000000..7f6a507fe --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Activities/RemoveTopElementStep.cs @@ -0,0 +1,13 @@ +using Elsa.Extensions; + +namespace Elsa.Workflows.ComponentTests.Scenarios.VariablesArray.Activities; + +public class RemoveTopElementStep : Activity +{ + protected override void Execute(ActivityExecutionContext context) + { + var elements = context.GetVariable("Elements"); + context.SetVariable("Elements", elements!.Skip(1).ToArray()); + context.CreateBookmark(); + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/VariablesArrayWorkflowTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/VariablesArrayWorkflowTests.cs new file mode 100644 index 000000000..a54ca4b5f --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/VariablesArrayWorkflowTests.cs @@ -0,0 +1,79 @@ +using Elsa.Common.Models; +using Elsa.Expressions.Helpers; +using Elsa.Extensions; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Elsa.Workflows.ComponentTests.Scenarios.VariablesArray.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Messages; +using Elsa.Workflows.State; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.VariablesArray; + +public class VariablesArrayWorkflowTests(App app) : AppComponentTest(app) +{ + [Fact(DisplayName = "Array variable is persisted across workflow runs")] + public async Task VariableIsPersistedAcrossWorkflowRuns() + { + var workflowRuntime = Scope.ServiceProvider.GetRequiredService(); + var workflowClient = await workflowRuntime.CreateClientAsync(); + var workflowInstanceStore = Scope.ServiceProvider.GetRequiredService(); + var workflowDefinitionStore = Scope.ServiceProvider.GetRequiredService(); + var bookmarkStore = Scope.ServiceProvider.GetRequiredService(); + var runAndCreateRequest = new CreateAndRunWorkflowInstanceRequest + { + WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(VariableArrayWorkflow.DefinitionId, VersionOptions.Latest), + }; + var runResponse = await workflowClient.CreateAndRunInstanceAsync(runAndCreateRequest); + var workflowInstanceId = runResponse.WorkflowInstanceId; + var createdBookmarks = await bookmarkStore.FindManyAsync(new() + { + WorkflowInstanceId = workflowInstanceId + }); + var bookmarks = new Stack(createdBookmarks); + var expectedElementLength = 3; + + var result = await workflowDefinitionStore.FindLastVersionAsync(new Management.Filters.WorkflowDefinitionFilter() + { + DefinitionId = VariableArrayWorkflow.DefinitionId + }, default); + + Assert.Equal(["Element 1", "Element 2", "Element 3"], result?.Variables.FirstOrDefault(v => v.Id == "elementsVariable")?.Value as IEnumerable); + + while (bookmarks.Any()) + { + var workflowInstance = await workflowInstanceStore.FindAsync(workflowInstanceId); + var workflowState = workflowInstance!.WorkflowState; + var rootWorkflowActivityExecutionContext = workflowState.ActivityExecutionContexts.Single(x => x.ParentContextId == null); + var variables = GetVariablesDictionary(rootWorkflowActivityExecutionContext); + var actualElements = variables["elementsVariable"].ConvertTo(); + Assert.Equal(--expectedElementLength, actualElements?.Length); + + var bookmark = bookmarks.Pop(); + var runRequest = new RunWorkflowInstanceRequest + { + BookmarkId = bookmark.Id, + }; + + await workflowClient.RunInstanceAsync(runRequest); + + createdBookmarks = await bookmarkStore.FindManyAsync(new() + { + WorkflowInstanceId = workflowInstanceId + }); + + foreach (var newBookmark in createdBookmarks) + bookmarks.Push(newBookmark); + } + Assert.Equal(0, expectedElementLength); + } + + private VariablesDictionary GetVariablesDictionary(ActivityExecutionContextState context) + { + return context.Properties.GetOrAdd(WorkflowInstanceStorageDriver.VariablesDictionaryStateKey, () => new VariablesDictionary()); + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Workflows/VariableArrayWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Workflows/VariableArrayWorkflow.cs new file mode 100644 index 000000000..e82046ae4 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Workflows/VariableArrayWorkflow.cs @@ -0,0 +1,34 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.ComponentTests.Scenarios.VariablesArray.Activities; + +namespace Elsa.Workflows.ComponentTests.Scenarios.VariablesArray.Workflows; + +public class VariableArrayWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder builder) + { + builder.WithDefinitionId(DefinitionId); + var elements = builder.WithVariable("Elements", ["Element 1", "Element 2", "Element 3"]).WithWorkflowStorage(); + + builder.Root = new Sequence + { + Activities = + { + new While(context => elements.Get(context)!.Length > 0) + { + Body = new Sequence + { + Activities = + { + new WriteLine(context => $"Top Element: {elements.Get(context)![0]}"), + new RemoveTopElementStep() + } + } + } + } + }; + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Activities/LongRunningActivity.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Activities/LongRunningActivity.cs new file mode 100644 index 000000000..e768d7586 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Activities/LongRunningActivity.cs @@ -0,0 +1,30 @@ +using Elsa.Testing.Shared.Services; +using Elsa.Workflows.Attributes; + +namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Activities; + +/// +/// A custom activity that simulates a long-running operation to test workflow deletion during execution. +/// This activity sends a signal when it starts, then delays for a specified duration. +/// +[Activity("Elsa.Workflows.ComponentTests", "LongRunningActivity", "A long-running activity for testing")] +public class LongRunningActivity : CodeActivity +{ + /// + /// The duration to delay for (in milliseconds) + /// + public int DelayMilliseconds { get; set; } = 3000; + + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var signalManager = context.GetRequiredService(); + var workflowInstanceId = context.WorkflowExecutionContext.Id; + + // Send a signal to notify the test that the activity has started + signalManager.Trigger("workflow-started", workflowInstanceId); + + // Simulate a long-running operation by using Task.Delay + // This keeps the workflow in memory and executing + await Task.Delay(DelayMilliseconds, context.CancellationToken); + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/RelatedRecordsDeletionTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/RelatedRecordsDeletionTests.cs new file mode 100644 index 000000000..83e5f8333 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/RelatedRecordsDeletionTests.cs @@ -0,0 +1,144 @@ +using Elsa.Api.Client.Resources.WorkflowInstances.Contracts; +using Elsa.Common.Models; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion; + +/// +/// Tests that verify workflow instance deletion also deletes all related records +/// (execution logs, activity execution records, bookmarks, etc.) +/// +public class RelatedRecordsDeletionTests(App app) : AppComponentTest(app) +{ + private IWorkflowRuntime WorkflowRuntime => Scope.ServiceProvider.GetRequiredService(); + private IWorkflowInstanceStore WorkflowInstanceStore => Scope.ServiceProvider.GetRequiredService(); + private IBookmarkStore BookmarkStore => Scope.ServiceProvider.GetRequiredService(); + private IActivityExecutionStore ActivityExecutionStore => Scope.ServiceProvider.GetRequiredService(); + private IWorkflowExecutionLogStore WorkflowExecutionLogStore => Scope.ServiceProvider.GetRequiredService(); + + [Fact(DisplayName = "Delete via API endpoint should remove all related records")] + public async Task DeleteViaApiEndpoint_ShouldRemoveAllRelatedRecords() + { + // Arrange + var workflowInstanceId = await CreateWorkflowWithRelatedRecordsAsync(); + await AssertRelatedRecordsExistAsync(workflowInstanceId); + + // Act + var deleteClient = WorkflowServer.CreateApiClient(); + await deleteClient.DeleteAsync(workflowInstanceId); + + // Assert + await AssertAllRecordsDeletedAsync(workflowInstanceId); + } + + [Fact(DisplayName = "Bulk delete via API endpoint should remove all related records")] + public async Task BulkDeleteViaApiEndpoint_ShouldRemoveAllRelatedRecords() + { + // Arrange - Create 2 workflow instances + var workflowInstanceId1 = await CreateWorkflowWithRelatedRecordsAsync(); + var workflowInstanceId2 = await CreateWorkflowWithRelatedRecordsAsync(); + + await AssertRelatedRecordsExistAsync(workflowInstanceId1); + await AssertRelatedRecordsExistAsync(workflowInstanceId2); + + var allInstanceIds = new[] + { + workflowInstanceId1, workflowInstanceId2 + }; + + // Act + var bulkDeleteClient = WorkflowServer.CreateApiClient(); + await bulkDeleteClient.BulkDeleteAsync(new() + { + Ids = allInstanceIds + }, CancellationToken.None); + + // Assert + foreach (var instanceId in allInstanceIds) + { + await AssertAllRecordsDeletedAsync(instanceId); + } + } + + [Fact(DisplayName = "Delete via runtime client should remove all related records")] + public async Task DeleteViaRuntimeClient_ShouldRemoveAllRelatedRecords() + { + // Arrange + var workflowInstanceId = await CreateWorkflowWithRelatedRecordsAsync(); + await AssertRelatedRecordsExistAsync(workflowInstanceId); + + // Act + var instanceClient = await WorkflowRuntime.CreateClientAsync(workflowInstanceId); + var deleted = await instanceClient.DeleteAsync(); + + Assert.True(deleted); + + // Assert + await AssertAllRecordsDeletedAsync(workflowInstanceId); + } + + private async Task CreateWorkflowWithRelatedRecordsAsync() + { + var workflowClient = await WorkflowRuntime.CreateClientAsync(); + var runResponse = await workflowClient.CreateAndRunInstanceAsync(new() + { + WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId( + WorkflowWithRelatedRecords.DefinitionId, + VersionOptions.Published) + }); + return runResponse.WorkflowInstanceId; + } + + private async Task AssertRelatedRecordsExistAsync(string workflowInstanceId) + { + var bookmarks = await BookmarkStore.FindManyAsync(new() + { + WorkflowInstanceId = workflowInstanceId + }); + var activityExecutions = await ActivityExecutionStore.FindManyAsync(new() + { + WorkflowInstanceId = workflowInstanceId + }); + var executionLogs = await WorkflowExecutionLogStore.FindManyAsync(new() + { + WorkflowInstanceId = workflowInstanceId + }, PageArgs.All); + + Assert.NotEmpty(bookmarks); + Assert.NotEmpty(activityExecutions); + Assert.NotEmpty(executionLogs.Items); + } + + private async Task AssertAllRecordsDeletedAsync(string workflowInstanceId) + { + var workflowInstance = await WorkflowInstanceStore.FindAsync(new() + { + Id = workflowInstanceId + }); + Assert.Null(workflowInstance); + + var remainingBookmarks = await BookmarkStore.FindManyAsync(new() + { + WorkflowInstanceId = workflowInstanceId + }); + Assert.Empty(remainingBookmarks); + + var remainingActivityExecutions = await ActivityExecutionStore.FindManyAsync(new() + { + WorkflowInstanceId = workflowInstanceId + }); + Assert.Empty(remainingActivityExecutions); + + var remainingExecutionLogs = await WorkflowExecutionLogStore.FindManyAsync(new() + { + WorkflowInstanceId = workflowInstanceId + }, PageArgs.All); + Assert.Empty(remainingExecutionLogs.Items); + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/WorkflowInstanceDeletionTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/WorkflowInstanceDeletionTests.cs new file mode 100644 index 000000000..931913139 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/WorkflowInstanceDeletionTests.cs @@ -0,0 +1,151 @@ +using Elsa.Api.Client.Resources.WorkflowInstances.Contracts; +using Elsa.Common.Models; +using Elsa.Testing.Shared.Services; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion; + +/// +/// Tests that workflow instances can be deleted reliably, even when they are running or being persisted. +/// This addresses issue #7077 where in-memory instances could be written back to the DB after deletion. +/// +public class WorkflowInstanceDeletionTests(App app) : AppComponentTest(app) +{ + private const string WorkflowStartedSignal = "workflow-started"; + + private IWorkflowRuntime WorkflowRuntime => Scope.ServiceProvider.GetRequiredService(); + private SignalManager SignalManager => Scope.ServiceProvider.GetRequiredService(); + private IWorkflowInstanceStore WorkflowInstanceStore => Scope.ServiceProvider.GetRequiredService(); + + private async Task StartRunningWorkflowAsync() + { + var workflowClient = await WorkflowRuntime.CreateClientAsync(); + await workflowClient.CreateAndRunInstanceAsync(new() + { + WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(SuspendingWorkflow.DefinitionId, VersionOptions.Published) + }); + + // Wait for the workflow to signal it has started + return await SignalManager.WaitAsync(WorkflowStartedSignal); + } + + private async Task AssertWorkflowInstanceDeletedAsync(string workflowInstanceId) + { + var instance = await WorkflowInstanceStore.FindAsync(new() { Id = workflowInstanceId }); + Assert.Null(instance); + } + + [Fact(DisplayName = "Delete running workflow instance should cancel and remove it")] + public async Task DeleteRunningWorkflowInstance_ShouldCancelAndRemove() + { + // Arrange - Start a workflow that will be running in memory + var workflowInstanceId = await StartRunningWorkflowAsync(); + + // Act - Delete the running workflow instance using the API + var deleteClient = WorkflowServer.CreateApiClient(); + await deleteClient.DeleteAsync(workflowInstanceId); + + // Assert - Instance should be deleted + await AssertWorkflowInstanceDeletedAsync(workflowInstanceId); + } + + [Fact(DisplayName = "Delete finished workflow instance should remove it")] + public async Task DeleteFinishedWorkflowInstance_ShouldRemove() + { + // Arrange - Create and run a workflow that completes immediately + var workflowClient = await WorkflowRuntime.CreateClientAsync(); + var runResponse = await workflowClient.CreateAndRunInstanceAsync(new() + { + WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(CompletingWorkflow.DefinitionId, VersionOptions.Published) + }); + var workflowInstanceId = runResponse.WorkflowInstanceId; + + // Verify the workflow finished + var finishedInstance = await WorkflowInstanceStore.FindAsync(new() { Id = workflowInstanceId }); + Assert.NotNull(finishedInstance); + Assert.Equal(WorkflowStatus.Finished, finishedInstance.WorkflowState.Status); + + // Act - Delete the finished workflow instance + var deleteClient = WorkflowServer.CreateApiClient(); + await deleteClient.DeleteAsync(workflowInstanceId); + + // Assert - Instance should be deleted + await AssertWorkflowInstanceDeletedAsync(workflowInstanceId); + } + + [Fact(DisplayName = "Delete non-existent workflow instance should throw exception")] + public async Task DeleteNonExistentWorkflowInstance_ShouldThrowException() + { + // Arrange - Use a non-existent workflow instance ID + var nonExistentId = "non-existent-workflow-instance-id"; + + // Act & Assert - Should throw an ApiException for non-existent instance (404 Not Found) + var deleteClient = WorkflowServer.CreateApiClient(); + await Assert.ThrowsAsync(async () => await deleteClient.DeleteAsync(nonExistentId)); + } + + [Fact(DisplayName = "Bulk delete mixed running and finished instances should delete all")] + public async Task BulkDeleteMixedInstances_ShouldDeleteAll() + { + // Arrange - Create 2 running and 2 finished workflow instances + var workflowClient = await WorkflowRuntime.CreateClientAsync(); + + // Start 2 running instances + var runningId1 = await StartRunningWorkflowAsync(); + var runningId2 = await StartRunningWorkflowAsync(); + + // Start 2 finished instances + var finishedResult1 = await workflowClient.CreateAndRunInstanceAsync(new() + { + WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(CompletingWorkflow.DefinitionId, VersionOptions.Published) + }); + var finishedResult2 = await workflowClient.CreateAndRunInstanceAsync(new() + { + WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(CompletingWorkflow.DefinitionId, VersionOptions.Published) + }); + + var allInstanceIds = new[] { runningId1, runningId2, finishedResult1.WorkflowInstanceId, finishedResult2.WorkflowInstanceId }; + + // Act - Bulk delete all instances + var bulkDeleteClient = WorkflowServer.CreateApiClient(); + await bulkDeleteClient.BulkDeleteAsync(new() { Ids = allInstanceIds }, CancellationToken.None); + + // Assert - All instances should be deleted + foreach (var instanceId in allInstanceIds) + { + await AssertWorkflowInstanceDeletedAsync(instanceId); + } + } + + [Fact(DisplayName = "Delete workflow instance should not be resurrected by persistence")] + public async Task DeleteWorkflowInstance_ShouldNotBeResurrectedByPersistence() + { + // This test addresses the core issue in #7077: ensuring that a deleted workflow instance + // is not written back to the database by the persistence mechanism while it's still executing. + // + // The race condition occurs when: + // 1. Workflow starts executing (stays in memory) + // 2. Delete request comes in + // 3. Without the fix: deletion happens, but workflow continues and persists itself again + // 4. With the fix: deletion blocks until workflow completes, using distributed locking + + // Arrange - Start a workflow that will be running in memory + var workflowInstanceId = await StartRunningWorkflowAsync(); + + // Act - Attempt to delete using the runtime client while workflow is executing + // With the fix, this will block until the workflow completes (about 3 seconds) + var instanceClient = await WorkflowRuntime.CreateClientAsync(workflowInstanceId); + var deleted = await instanceClient.DeleteAsync(); + + Assert.True(deleted); + + // Assert - Instance should be deleted and NOT resurrected + await AssertWorkflowInstanceDeletedAsync(workflowInstanceId); + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/CompletingWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/CompletingWorkflow.cs new file mode 100644 index 000000000..2a256ac17 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/CompletingWorkflow.cs @@ -0,0 +1,22 @@ +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Workflows; + +public class CompletingWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = "completing-workflow-test"; + + protected override void Build(IWorkflowBuilder builder) + { + builder.WithDefinitionId(DefinitionId); + + builder.Root = new Sequence + { + Activities = + { + new WriteLine("Starting workflow"), + new WriteLine("Completing immediately") + } + }; + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/SuspendingWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/SuspendingWorkflow.cs new file mode 100644 index 000000000..ffb329ee3 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/SuspendingWorkflow.cs @@ -0,0 +1,28 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Activities; + +namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Workflows; + +/// +/// A workflow that uses a long-running activity to test deletion during active execution. +/// This simulates the race condition where a workflow instance is deleted while still executing in memory. +/// +public class SuspendingWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = "suspending-workflow-test"; + + protected override void Build(IWorkflowBuilder builder) + { + builder.WithDefinitionId(DefinitionId); + + builder.Root = new Sequence + { + Activities = + { + new WriteLine("Starting workflow"), + new LongRunningActivity { DelayMilliseconds = 3000 }, // Runs for 3 seconds in memory + new WriteLine("Workflow completed") + } + }; + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/WorkflowWithRelatedRecords.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/WorkflowWithRelatedRecords.cs new file mode 100644 index 000000000..b0e97a14c --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/WorkflowWithRelatedRecords.cs @@ -0,0 +1,33 @@ +using Elsa.Scheduling.Activities; +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Workflows; + +/// +/// A workflow that generates related records: execution logs, activity execution records, and bookmarks. +/// This is used to test that all related records are properly deleted when the workflow instance is deleted. +/// +public class WorkflowWithRelatedRecords : WorkflowBase +{ + public static readonly string DefinitionId = "workflow-with-related-records-test"; + + protected override void Build(IWorkflowBuilder builder) + { + builder.WithDefinitionId(DefinitionId); + + builder.Root = new Sequence + { + Activities = + { + // This will create activity execution records and execution logs + new WriteLine("Step 1: Starting workflow"), + new WriteLine("Step 2: About to delay"), + + // This will create a bookmark (suspends the workflow) + new Delay(TimeSpan.FromHours(1)), + + new WriteLine("Step 3: This should never execute") + } + }; + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Branching/FlowDecisionTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Branching/FlowDecisionTests.cs new file mode 100644 index 000000000..1853ca250 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Branching/FlowDecisionTests.cs @@ -0,0 +1,206 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Activities.Flowchart.Activities; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Branching; + +/// +/// Integration tests for FlowDecision activity in flowchart scenarios. +/// +[Collection("FlowchartTests")] +public class FlowDecisionTests(ITestOutputHelper testOutputHelper) : IDisposable +{ + private readonly WorkflowTestFixture _fixture = new(testOutputHelper); + private readonly bool _originalFlowMode = Flowchart.UseTokenFlow; + + public void Dispose() + { + Flowchart.UseTokenFlow = _originalFlowMode; + } + + [Theory(DisplayName = "FlowDecision follows correct path based on condition")] + [MemberData(nameof(BasicPathTestCases))] + public async Task Should_Follow_Correct_Path_Based_On_Condition(bool useTokenFlow, bool condition, string[] expectedOutputs, string[] unexpectedOutputs) + { + // Arrange + Flowchart.UseTokenFlow = useTokenFlow; + + var start = new WriteLine("Start"); + var decision = new FlowDecision(ctx => condition); + var truePath = new WriteLine("TruePath"); + var falsePath = new WriteLine("FalsePath"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, decision, truePath, falsePath }, + Connections = + { + new() { Source = new(start, "Done"), Target = new(decision) }, + new() { Source = new(decision, "True"), Target = new(truePath) }, + new() { Source = new(decision, "False"), Target = new(falsePath) } + } + }; + + // Act + await _fixture.RunActivityAsync(flowchart); + + // Assert + AssertOutputs(expectedOutputs, unexpectedOutputs); + } + + public static IEnumerable BasicPathTestCases() + { + // useTokenFlow, condition, expectedOutputs, unexpectedOutputs + yield return [true, true, new[] { "Start", "TruePath" }, new[] { "FalsePath" }]; + yield return [true, false, new[] { "Start", "FalsePath" }, new[] { "TruePath" }]; + yield return [false, true, new[] { "Start", "TruePath" }, new[] { "FalsePath" }]; + yield return [false, false, new[] { "Start", "FalsePath" }, new[] { "TruePath" }]; + } + + [Theory(DisplayName = "FlowDecision handles nested decisions")] + [MemberData(nameof(NestedDecisionTestCases))] + public async Task Should_Handle_Nested_Decisions(bool useTokenFlow, bool outerCondition, bool innerCondition, string[] expectedOutputs, string[] unexpectedOutputs) + { + // Arrange + Flowchart.UseTokenFlow = useTokenFlow; + + var start = new WriteLine("Start"); + var outerDecision = new FlowDecision(ctx => outerCondition); + var innerDecision = new FlowDecision(ctx => innerCondition); + var innerTrue = new WriteLine("InnerTrue"); + var innerFalse = new WriteLine("InnerFalse"); + var outerFalse = new WriteLine("OuterFalse"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, outerDecision, innerDecision, innerTrue, innerFalse, outerFalse }, + Connections = + { + new() { Source = new(start, "Done"), Target = new(outerDecision) }, + new() { Source = new(outerDecision, "True"), Target = new(innerDecision) }, + new() { Source = new(outerDecision, "False"), Target = new(outerFalse) }, + new() { Source = new(innerDecision, "True"), Target = new(innerTrue) }, + new() { Source = new(innerDecision, "False"), Target = new(innerFalse) } + } + }; + + // Act + await _fixture.RunActivityAsync(flowchart); + + // Assert + AssertOutputs(expectedOutputs, unexpectedOutputs); + } + + public static IEnumerable NestedDecisionTestCases() + { + // useTokenFlow, outerCondition, innerCondition, expectedOutputs, unexpectedOutputs + yield return [true, true, true, new[] { "Start", "InnerTrue" }, new[] { "InnerFalse", "OuterFalse" }]; + yield return [true, true, false, new[] { "Start", "InnerFalse" }, new[] { "InnerTrue", "OuterFalse" }]; + yield return [true, false, true, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }]; + yield return [true, false, false, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }]; + yield return [false, true, true, new[] { "Start", "InnerTrue" }, new[] { "InnerFalse", "OuterFalse" }]; + yield return [false, true, false, new[] { "Start", "InnerFalse" }, new[] { "InnerTrue", "OuterFalse" }]; + yield return [false, false, true, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }]; + yield return [false, false, false, new[] { "Start", "OuterFalse" }, new[] { "InnerTrue", "InnerFalse" }]; + } + + [Theory(DisplayName = "FlowDecision works with only one path connected")] + [MemberData(nameof(OnePathConnectedTestCases))] + public async Task Should_Work_With_Only_One_Path_Connected(bool useTokenFlow, bool condition, string[] expectedOutputs, string[] unexpectedOutputs) + { + // Arrange + Flowchart.UseTokenFlow = useTokenFlow; + + var start = new WriteLine("Start"); + var decision = new FlowDecision(ctx => condition); + var truePath = new WriteLine("TruePath"); + var end = new WriteLine("End"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, decision, truePath, end }, + Connections = + { + new() { Source = new(start, "Done"), Target = new(decision) }, + new() { Source = new(decision, "True"), Target = new(truePath) }, + // False path not connected + new() { Source = new(truePath, "Done"), Target = new(end) } + } + }; + + // Act + await _fixture.RunActivityAsync(flowchart); + + // Assert + AssertOutputs(expectedOutputs, unexpectedOutputs); + } + + public static IEnumerable OnePathConnectedTestCases() + { + // useTokenFlow, condition, expectedOutputs, unexpectedOutputs + yield return [true, true, new[] { "Start", "TruePath", "End" }, Array.Empty()]; + yield return [true, false, new[] { "Start" }, new[] { "TruePath", "End" }]; + yield return [false, true, new[] { "Start", "TruePath", "End" }, Array.Empty()]; + yield return [false, false, new[] { "Start" }, new[] { "TruePath", "End" }]; + } + + [Theory(DisplayName = "FlowDecision converges paths correctly")] + [MemberData(nameof(ConvergePathsTestCases))] + public async Task Should_Converge_Paths_Correctly(bool useTokenFlow, bool condition, string[] expectedOutputs, string[] unexpectedOutputs) + { + // Arrange + Flowchart.UseTokenFlow = useTokenFlow; + + var start = new WriteLine("Start"); + var decision = new FlowDecision(ctx => condition); + var truePath = new WriteLine("TruePath"); + var falsePath = new WriteLine("FalsePath"); + var converge = new WriteLine("Converge"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, decision, truePath, falsePath, converge }, + Connections = + { + new() { Source = new(start, "Done"), Target = new(decision) }, + new() { Source = new(decision, "True"), Target = new(truePath) }, + new() { Source = new(decision, "False"), Target = new(falsePath) }, + new() { Source = new(truePath, "Done"), Target = new(converge) }, + new() { Source = new(falsePath, "Done"), Target = new(converge) } + } + }; + + // Act + await _fixture.RunActivityAsync(flowchart); + + // Assert + AssertOutputs(expectedOutputs, unexpectedOutputs); + } + + public static IEnumerable ConvergePathsTestCases() + { + // useTokenFlow, condition, expectedOutputs, unexpectedOutputs + yield return [true, true, new[] { "Start", "TruePath", "Converge" }, new[] { "FalsePath" }]; + yield return [true, false, new[] { "Start", "FalsePath", "Converge" }, new[] { "TruePath" }]; + yield return [false, true, new[] { "Start", "TruePath", "Converge" }, new[] { "FalsePath" }]; + yield return [false, false, new[] { "Start", "FalsePath", "Converge" }, new[] { "TruePath" }]; + } + + private void AssertOutputs(string[] expectedOutputs, string[] unexpectedOutputs) + { + foreach (var expected in expectedOutputs) + { + Assert.Contains(expected, _fixture.CapturingTextWriter.Lines); + } + + foreach (var unexpected in unexpectedOutputs) + { + Assert.DoesNotContain(unexpected, _fixture.CapturingTextWriter.Lines); + } + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Composition/Activities/InnerComposite.cs b/test/integration/Elsa.Activities.IntegrationTests/Composition/Activities/InnerComposite.cs new file mode 100644 index 000000000..3f3e737df --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Composition/Activities/InnerComposite.cs @@ -0,0 +1,32 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Composition.Activities; + +/// +/// Inner composite activity that uses Complete. +/// +public class InnerComposite : Composite +{ + private readonly WriteLine _completedMessage = new("Inner composite completed"); + + public InnerComposite() + { + Root = new Sequence + { + Activities = + { + new WriteLine("Inner composite started"), + new Complete(), + new WriteLine("This should not execute in inner"), + _completedMessage + } + }; + } + + protected override async ValueTask OnCompletedAsync(ActivityCompletedContext context) + { + // Schedule the completion message + await context.TargetContext.ScheduleActivityAsync(_completedMessage); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Composition/Activities/OuterComposite.cs b/test/integration/Elsa.Activities.IntegrationTests/Composition/Activities/OuterComposite.cs new file mode 100644 index 000000000..9de2de220 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Composition/Activities/OuterComposite.cs @@ -0,0 +1,21 @@ +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Composition.Activities; + +/// +/// Outer composite activity for nested composite testing. +/// +public class OuterComposite : Composite +{ + public OuterComposite() + { + Root = new Sequence + { + Activities = + { + new InnerComposite(), + new WriteLine("After inner composite") + } + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Composition/Activities/SimpleComposite.cs b/test/integration/Elsa.Activities.IntegrationTests/Composition/Activities/SimpleComposite.cs new file mode 100644 index 000000000..22f2be050 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Composition/Activities/SimpleComposite.cs @@ -0,0 +1,22 @@ +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Composition.Activities; + +/// +/// Simple composite activity that uses Complete to terminate early. +/// +public class SimpleComposite : Composite +{ + public SimpleComposite() + { + Root = new Sequence + { + Activities = + { + new WriteLine("Before Complete"), + new Complete(), + new WriteLine("This should not execute") + } + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Composition/CompleteTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Composition/CompleteTests.cs new file mode 100644 index 000000000..b0ea0651a --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Composition/CompleteTests.cs @@ -0,0 +1,47 @@ +using Elsa.Activities.IntegrationTests.Composition.Workflows; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Composition; + +/// +/// Integration tests for the activity. +/// +public class CompleteTests(ITestOutputHelper testOutputHelper) +{ + private readonly WorkflowTestFixture _fixture = new WorkflowTestFixture(testOutputHelper) + .AddWorkflow() + .AddWorkflow(); + + [Fact(DisplayName = "Complete terminates composite execution immediately")] + public async Task Complete_TerminatesCompositeExecutionImmediately() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(CompleteTerminatesCompositeWorkflow.DefinitionId); + + // Assert + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + Assert.Contains("Before Complete", lines); + Assert.DoesNotContain("This should not execute", lines); + Assert.Contains("After composite", lines); + } + + [Fact(DisplayName = "Complete in nested composite completes immediate parent only")] + public async Task Complete_InNestedComposite_CompletesImmediateParentOnly() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(CompleteInNestedCompositeWorkflow.DefinitionId); + + // Assert + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + Assert.Contains("Outer composite started", lines); + Assert.Contains("Inner composite started", lines); + Assert.Contains("Inner composite completed", lines); + Assert.Contains("Outer composite completed", lines); + Assert.DoesNotContain("This should not execute in inner", lines); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Composition/Workflows/CompleteInNestedCompositeWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Composition/Workflows/CompleteInNestedCompositeWorkflow.cs new file mode 100644 index 000000000..5f84b5e62 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Composition/Workflows/CompleteInNestedCompositeWorkflow.cs @@ -0,0 +1,27 @@ +using Elsa.Activities.IntegrationTests.Composition.Activities; +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Composition.Workflows; + +/// +/// Workflow with nested composite activities to verify Complete only affects immediate parent. +/// +public class CompleteInNestedCompositeWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Outer composite started"), + new OuterComposite(), + new WriteLine("Outer composite completed") + } + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Composition/Workflows/CompleteTerminatesCompositeWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Composition/Workflows/CompleteTerminatesCompositeWorkflow.cs new file mode 100644 index 000000000..af589932b --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Composition/Workflows/CompleteTerminatesCompositeWorkflow.cs @@ -0,0 +1,26 @@ +using Elsa.Activities.IntegrationTests.Composition.Activities; +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Composition.Workflows; + +/// +/// Workflow demonstrating that Complete terminates composite execution immediately. +/// +public class CompleteTerminatesCompositeWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + workflow.Root = new Sequence + { + Activities = + { + new SimpleComposite(), + new WriteLine("After composite") + } + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj b/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj index e8a382546..5f9082087 100644 --- a/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj +++ b/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj @@ -1,13 +1,16 @@ - net9.0 + [Elsa.Workflows.Core]* + 25 + + - + diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/EndTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/EndTests.cs new file mode 100644 index 000000000..933a4b64e --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/EndTests.cs @@ -0,0 +1,48 @@ +using Elsa.Activities.IntegrationTests.Flow.Workflows; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Flow; + +/// +/// Integration tests for the activity. +/// +public class EndTests(ITestOutputHelper testOutputHelper) +{ + private readonly WorkflowTestFixture _fixture = new WorkflowTestFixture(testOutputHelper) + .AddWorkflow() + .AddWorkflow(); + + [Fact(DisplayName = "End terminates sequence execution")] + public async Task End_TerminatesSequenceExecution() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(EndInSequenceWorkflow.DefinitionId); + + // Assert + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + Assert.Contains("Before End", lines); + Assert.DoesNotContain("After End", lines); + } + + [Fact(DisplayName = "End in flowchart terminates flowchart immediately")] + public async Task End_InFlowchart_TerminatesFlowchartImmediately() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(EndInFlowchartWorkflow.DefinitionId); + + // Assert + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + Assert.Contains("Start", lines); + Assert.Contains("Path A executed", lines); + // End is a terminal node - it terminates the flowchart immediately + // Path B should not execute because End completes the flowchart + Assert.DoesNotContain("Path B executed", lines); + // The outer sequence continues after the flowchart completes + Assert.Contains("After flowchart", lines); + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs index d38200035..0623897de 100644 --- a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs @@ -1,5 +1,4 @@ using Elsa.Testing.Shared; -using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Activities.Flowchart.Activities; using Elsa.Workflows.Activities.Flowchart.Models; diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs index d1fadbf85..b62d27882 100644 --- a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartTokenBasedTests.cs @@ -1,5 +1,4 @@ using Elsa.Testing.Shared; -using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Activities.Flowchart.Activities; using Elsa.Workflows.Activities.Flowchart.Extensions; @@ -239,7 +238,7 @@ public class FlowchartTokenBasedTests : IDisposable var branch1 = new WriteLine("Branch1"); var branch2 = new WriteLine("Branch2"); var noneMode = new WriteLine("NoneMode"); - noneMode.SetMergeMode(MergeMode.None); + noneMode.SetMergeMode(null); var flowchart = new Flowchart { diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/StartTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/StartTests.cs new file mode 100644 index 000000000..c9ce1da61 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/StartTests.cs @@ -0,0 +1,58 @@ +using Elsa.Activities.IntegrationTests.Flow.Workflows; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Flow; + +/// +/// Integration tests for the activity. +/// +public class StartTests(ITestOutputHelper testOutputHelper) +{ + private readonly WorkflowTestFixture _fixture = new WorkflowTestFixture(testOutputHelper) + .AddWorkflow() + .AddWorkflow() + .AddWorkflow(); + + [Fact(DisplayName = "Start completes successfully in sequence")] + public async Task Start_CompletesSuccessfullyInSequence() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(StartInSequenceWorkflow.DefinitionId); + + // Assert + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + Assert.Contains("Before Start", lines); + Assert.Contains("After Start", lines); + } + + [Fact(DisplayName = "Start activity is used as flowchart start when present")] + public async Task Start_IsUsedAsFlowchartStart_WhenPresent() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(StartInFlowchartAsExplicitStartWorkflow.DefinitionId); + + // Assert + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + Assert.Contains("Start activity", lines); + Assert.Contains("After Start", lines); + Assert.DoesNotContain("Should not execute", lines); + } + + [Fact(DisplayName = "Flowchart Start property takes precedence over explicit Start activity")] + public async Task FlowchartStartProperty_TakesPrecedenceOverExplicitStartActivity() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(StartInFlowchartWithStartPropertyWorkflow.DefinitionId); + + // Assert + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + Assert.Contains("Start property used", lines); + Assert.DoesNotContain("Start activity", lines); + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/EndInFlowchartWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/EndInFlowchartWorkflow.cs new file mode 100644 index 000000000..ca657cdd3 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/EndInFlowchartWorkflow.cs @@ -0,0 +1,50 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Activities.Flowchart.Activities; +using Elsa.Workflows.Activities.Flowchart.Models; + +namespace Elsa.Activities.IntegrationTests.Flow.Workflows; + +/// +/// Workflow demonstrating that End terminates a flowchart. +/// +public class EndInFlowchartWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + + var start = new WriteLine("Start"); + var pathA = new WriteLine("Path A executed"); + var end = new End(); + var pathB = new WriteLine("Path B executed"); + var afterFlowchart = new WriteLine("After flowchart"); + + workflow.Root = new Sequence + { + Activities = + { + new Flowchart + { + Start = start, + Activities = + { + start, + pathA, + end, + pathB + }, + Connections = + { + new Connection(start, pathA), + new Connection(pathA, end), + new Connection(end, pathB) + } + }, + afterFlowchart + } + }; + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/EndInSequenceWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/EndInSequenceWorkflow.cs new file mode 100644 index 000000000..8143bd484 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/EndInSequenceWorkflow.cs @@ -0,0 +1,26 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Flow.Workflows; + +/// +/// Workflow demonstrating that End activity completes successfully in a sequence. +/// +public class EndInSequenceWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Before End"), + new End(), + new WriteLine("After End") + } + }; + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/StartInFlowchartAsExplicitStartWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/StartInFlowchartAsExplicitStartWorkflow.cs new file mode 100644 index 000000000..631605d33 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/StartInFlowchartAsExplicitStartWorkflow.cs @@ -0,0 +1,40 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Activities.Flowchart.Activities; +using Elsa.Workflows.Activities.Flowchart.Models; + +namespace Elsa.Activities.IntegrationTests.Flow.Workflows; + +/// +/// Workflow demonstrating that an explicit Start activity in a flowchart is used as the starting point. +/// +public class StartInFlowchartAsExplicitStartWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + + var startActivity = new Start(); + var startMessage = new WriteLine("Start activity"); + var afterStart = new WriteLine("After Start"); + var shouldNotExecute = new WriteLine("Should not execute"); + + workflow.Root = new Flowchart + { + Activities = + { + startActivity, + startMessage, + afterStart, + shouldNotExecute + }, + Connections = + { + new Connection(startActivity, startMessage), + new Connection(startMessage, afterStart) + } + }; + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/StartInFlowchartWithStartPropertyWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/StartInFlowchartWithStartPropertyWorkflow.cs new file mode 100644 index 000000000..6e9277f62 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/StartInFlowchartWithStartPropertyWorkflow.cs @@ -0,0 +1,41 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Activities.Flowchart.Activities; +using Elsa.Workflows.Activities.Flowchart.Models; + +namespace Elsa.Activities.IntegrationTests.Flow.Workflows; + +/// +/// Workflow demonstrating that the Flowchart.Start property takes precedence over an explicit Start activity. +/// +public class StartInFlowchartWithStartPropertyWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + + var startActivity = new Start(); + var startActivityMessage = new WriteLine("Start activity"); + var startPropertyActivity = new WriteLine("Start property used"); + var afterStart = new WriteLine("After property start"); + + workflow.Root = new Flowchart + { + Start = startPropertyActivity, // This takes precedence + Activities = + { + startActivity, // This should be ignored + startActivityMessage, + startPropertyActivity, + afterStart + }, + Connections = + { + new Connection(startActivity, startActivityMessage), + new Connection(startPropertyActivity, afterStart) + } + }; + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/StartInSequenceWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/StartInSequenceWorkflow.cs new file mode 100644 index 000000000..e58545e81 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/Workflows/StartInSequenceWorkflow.cs @@ -0,0 +1,26 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Flow.Workflows; + +/// +/// Workflow demonstrating that Start activity completes successfully in a sequence. +/// +public class StartInSequenceWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Before Start"), + new Start(), + new WriteLine("After Start") + } + }; + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs new file mode 100644 index 000000000..5c5fc3cfb --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs @@ -0,0 +1,224 @@ +using System.Net; +using Elsa.Activities.UnitTests.Http.Helpers; +using Elsa.Extensions; +using Elsa.Http; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Http; + +public class DownloadHttpFileTests(ITestOutputHelper testOutputHelper) +{ + [Fact(DisplayName = "DownloadHttpFile downloads file successfully")] + public async Task DownloadsFile_Successfully() + { + // Arrange + var fileContent = "Test file content"u8.ToArray(); + var handler = CreateFileResponseHandler(fileContent, "document.pdf", "application/pdf"); + + // Act + var (workflowResult, activity) = await RunActivityAsync("https://example.com/file.pdf", handler); + + // Assert + var file = workflowResult.GetActivityOutput(activity); + Assert.NotNull(file); + Assert.Equal("document.pdf", file.Filename); + Assert.Equal("application/pdf", file.ContentType); + + var stream = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.ResponseContentStream)); + Assert.NotNull(stream); + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync(); + Assert.Equal("Test file content", content); + } + + [Fact(DisplayName = "DownloadHttpFile handles POST requests")] + public async Task HandlesPostRequest() + { + // Arrange + var fileContent = "Response content"u8.ToArray(); + var handler = CreateFileResponseHandler(fileContent); + + // Act + var (workflowResult, activity) = await RunActivityAsync( + "https://api.example.com/download", + handler, + method: "POST", + requestContent: new { id = 123 }, + requestContentType: "application/json"); + + // Assert + var statusCode = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.StatusCode)); + Assert.Equal(200, statusCode); + + var file = workflowResult.GetActivityOutput(activity); + Assert.NotNull(file); + } + + [Fact(DisplayName = "DownloadHttpFile includes authorization header")] + public async Task IncludesAuthorizationHeader() + { + // Arrange + var requestCapture = new HttpRequestMessage?[1]; + var fileContent = "Authorized content"u8.ToArray(); + var handler = CreateCapturingHandler(fileContent, requestCapture); + + // Act + await RunActivityAsync( + "https://example.com/secure/file.pdf", + handler, + authorization: "Bearer test-token"); + + // Assert + var capturedRequest = requestCapture[0]; + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Headers.Authorization); + Assert.Equal("Bearer test-token", capturedRequest.Headers.Authorization.ToString()); + } + + [Theory(DisplayName = "DownloadHttpFile extracts filename correctly")] + [InlineData("https://example.com/generate-report", "annual-report.xlsx", "annual-report.xlsx")] // From Content-Disposition + [InlineData("https://example.com/downloads/image.png", null, "image.png")] // From URL + public async Task ExtractsFilename(string url, string? contentDispositionFilename, string expectedFilename) + { + // Arrange + var fileContent = "File data"u8.ToArray(); + var handler = CreateFileResponseHandler(fileContent, contentDispositionFilename); + + // Act + var (workflowResult, activity) = await RunActivityAsync(url, handler); + + // Assert + var file = workflowResult.GetActivityOutput(activity); + Assert.NotNull(file); + Assert.Equal(expectedFilename, file.Filename); + } + + [Theory(DisplayName = "DownloadHttpFile handles various status codes")] + [InlineData(200)] + [InlineData(201)] + [InlineData(404)] + public async Task HandlesStatusCodes(int statusCode) + { + // Arrange + var handler = CreateFileResponseHandler(statusCode: (HttpStatusCode)statusCode); + + // Act + var (workflowResult, activity) = await RunActivityAsync( + "https://example.com/file.pdf", + handler, + expectedStatusCodes: [statusCode]); + + // Assert + var actualStatusCode = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.StatusCode)); + Assert.Equal(statusCode, actualStatusCode); + } + + [Fact(DisplayName = "DownloadHttpFile sets response headers")] + public async Task SetsResponseHeaders() + { + // Arrange + var additionalHeaders = new Dictionary + { + { "X-Rate-Limit", "100" }, + { "X-Request-Id", "abc123" } + }; + var handler = CreateFileResponseHandler(additionalHeaders: additionalHeaders); + + // Act + var (workflowResult, activity) = await RunActivityAsync("https://api.example.com/download", handler); + + // Assert + var responseHeaders = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.ResponseHeaders)); + Assert.NotNull(responseHeaders); + Assert.Contains(responseHeaders.Keys, k => k.Equals("X-Rate-Limit", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(responseHeaders.Keys, k => k.Equals("X-Request-Id", StringComparison.OrdinalIgnoreCase)); + } + + private async Task<(RunWorkflowResult Result, DownloadHttpFile Activity)> RunActivityAsync( + string url, + HttpMessageHandler handler, + string method = "GET", + object? requestContent = null, + string? requestContentType = null, + string? authorization = null, + int[]? expectedStatusCodes = null) + { + var fixture = CreateFixture(handler); + var activity = new DownloadHttpFile + { + Url = new(new Uri(url)), + Method = new(method), + ExpectedStatusCodes = new(expectedStatusCodes ?? [200]) + }; + + if (requestContent != null) + activity.RequestContent = new(requestContent); + + if (requestContentType != null) + activity.RequestContentType = new(requestContentType); + + if (authorization != null) + activity.Authorization = new(authorization); + + var result = await fixture.RunActivityAsync(activity); + return (result, activity); + } + + private WorkflowTestFixture CreateFixture(HttpMessageHandler handler) => + new WorkflowTestFixture(testOutputHelper) + .ConfigureElsa(elsa => elsa.UseHttp(http => + { + http.HttpClientBuilder = builder => builder.ConfigurePrimaryHttpMessageHandler(() => handler); + })); + + private static HttpMessageHandler CreateFileResponseHandler( + byte[]? content = null, + string? filename = null, + string? contentType = null, + HttpStatusCode statusCode = HttpStatusCode.OK, + Dictionary? additionalHeaders = null) => + new TestHttpMessageHandler((_, _) => + { + // Response ownership is transferred to the caller (HttpClient), which will dispose it + var response = new HttpResponseMessage(statusCode); + + // Always set Content (even if empty) because DownloadHttpFile accesses Content.Headers + var actualContent = content ?? (statusCode == HttpStatusCode.OK ? "Default file content"u8.ToArray() : []); + var actualContentType = contentType ?? "application/octet-stream"; + response.Content = new ByteArrayContent(actualContent); + response.Content.Headers.ContentType = new(actualContentType); + + if (filename != null) + { + response.Content.Headers.ContentDisposition = new("attachment") + { + FileName = filename + }; + } + + if (additionalHeaders != null) + foreach (var header in additionalHeaders) + response.Headers.Add(header.Key, header.Value); + + return Task.FromResult(response); + }); + + private static HttpMessageHandler CreateCapturingHandler(byte[] content, HttpRequestMessage?[] capture) => + new TestHttpMessageHandler((request, _) => + { + capture[0] = request; + + // Response ownership is transferred to the caller (HttpClient), which will dispose it + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(content) + }; + response.Content.Headers.ContentType = new("application/octet-stream"); + + return Task.FromResult(response); + }); +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/ParallelForEachTests.cs b/test/integration/Elsa.Activities.IntegrationTests/ParallelForEachTests.cs new file mode 100644 index 000000000..c7e6f972d --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/ParallelForEachTests.cs @@ -0,0 +1,135 @@ +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests; + +public class ParallelForEachTests(ITestOutputHelper testOutputHelper) +{ + private readonly WorkflowTestFixture _fixture = new(testOutputHelper); + private const string CurrentValueVar = "CurrentValue"; + private static readonly string[] ThreeItems = ["a", "b", "c"]; + + private CapturingTextWriter CapturingTextWriter => _fixture.CapturingTextWriter; + + [Theory(DisplayName = "ParallelForEach executes body for all items")] + [MemberData(nameof(ItemTestCases))] + public async Task ParallelForEach_ExecutesBody_ForAllItems(string[] items) + { + await ExecuteAndAssertAllItems(items); + } + + [Fact(DisplayName = "ParallelForEach completes when collection is empty")] + public async Task ParallelForEach_Completes_WhenCollectionEmpty() + { + await ExecuteAndAssertStatus([], ActivityStatus.Completed); + Assert.Empty(CapturingTextWriter.Lines); + } + + [Fact(DisplayName = "ParallelForEach completes when collection is null")] + public async Task ParallelForEach_Completes_WhenCollectionNull() + { + await ExecuteAndAssertStatus(null, ActivityStatus.Completed); + } + + [Fact(DisplayName = "ParallelForEach executes all items when one faults")] + public async Task ParallelForEach_ExecutesAllItems_WhenOneFaults() + { + var body = new Sequence + { + Activities = + [ + new If(context => context.GetVariable(CurrentValueVar) == "b") + { + Then = new Fault { Message = new("Faulted") }, + }, + WriteCurrentValue() + ] + }; + + await RunActivityAsync(ThreeItems, body); + + Assert.Contains("a", CapturingTextWriter.Lines); + Assert.DoesNotContain("b", CapturingTextWriter.Lines); + Assert.Contains("c", CapturingTextWriter.Lines); + } + + [Fact(DisplayName = "ParallelForEach executes body for different item types")] + public async Task ParallelForEach_ExecutesBody_ForDifferentItemTypes() + { + var items = new object?[] + { + "a", 2, null, new Foo() + }; + var parallelForEach = new ParallelForEach(items) + { + Body = new WriteLine(context => context.GetVariable(CurrentValueVar)?.ToString() ?? "") + }; + + await _fixture.RunActivityAsync(parallelForEach); + + Assert.Equal(items.Length, CapturingTextWriter.Lines.Count); + Assert.Contains("a", CapturingTextWriter.Lines); + Assert.Contains("2", CapturingTextWriter.Lines); + Assert.Contains("", CapturingTextWriter.Lines); + Assert.Contains("Baz", CapturingTextWriter.Lines); + } + + [Fact(DisplayName = "ParallelForEach provides CurrentIndex variable")] + public async Task ParallelForEach_ProvidesCurrentIndex_ForEachIteration() + { + var body = new WriteLine(context => context.GetVariable("CurrentIndex").ToString()); + + await RunActivityAsync(ThreeItems, body); + + Assert.Equal(ThreeItems.Length, CapturingTextWriter.Lines.Count); + Assert.Contains("0", CapturingTextWriter.Lines); + Assert.Contains("1", CapturingTextWriter.Lines); + Assert.Contains("2", CapturingTextWriter.Lines); + } + + [Fact(DisplayName = "ParallelForEach completes when all bodies complete")] + public async Task ParallelForEach_Completes_WhenAllBodiesComplete() + { + await ExecuteAndAssertStatus(ThreeItems, ActivityStatus.Completed); + } + + public static TheoryData ItemTestCases => + [ + ThreeItems, + ["single"] + ]; + + private async Task ExecuteAndAssertAllItems(string[] items) + { + await RunActivityAsync(items, WriteCurrentValue()); + + Assert.Equal(items.Length, CapturingTextWriter.Lines.Count); + Assert.All(items, item => Assert.Contains(item, CapturingTextWriter.Lines)); + } + + private async Task ExecuteAndAssertStatus(string[]? items, ActivityStatus expectedStatus) + { + var result = await RunActivityAsync(items, WriteCurrentValue()); + var context = GetParallelForEachContext(result); + Assert.Equal(expectedStatus, context.Status); + } + + private async Task RunActivityAsync(string[]? items, IActivity body) + { + var parallelForEach = new ParallelForEach(items!) { Body = body }; + return await _fixture.RunActivityAsync(parallelForEach); + } + + private static WriteLine WriteCurrentValue() => new(context => context.GetVariable(CurrentValueVar)); + + private static ActivityExecutionContext GetParallelForEachContext(RunWorkflowResult result) + { + var context = result.Journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is ParallelForEach); + Assert.NotNull(context); + return context; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs b/test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs index bfcec4cac..01e0d5d2d 100644 --- a/test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs +++ b/test/integration/Elsa.Activities.IntegrationTests/ParallelTests.cs @@ -1,7 +1,6 @@ using Elsa.Testing.Shared; using Elsa.Workflows; using Elsa.Workflows.Activities; -using Elsa.Workflows.Models; using Xunit.Abstractions; using Parallel = Elsa.Workflows.Activities.Parallel; diff --git a/test/integration/Elsa.Activities.IntegrationTests/Primitives/FaultTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Primitives/FaultTests.cs new file mode 100644 index 000000000..46d18ceeb --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Primitives/FaultTests.cs @@ -0,0 +1,84 @@ +using Elsa.Activities.IntegrationTests.Primitives.Workflows; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Exceptions; +using Elsa.Workflows.Models; +using Elsa.Workflows.State; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Primitives; + +/// +/// Integration tests for the activity. +/// +public class FaultTests(ITestOutputHelper testOutputHelper) +{ + private readonly WorkflowTestFixture _fixture = new WorkflowTestFixture(testOutputHelper) + .AddWorkflow() + .AddWorkflow() + .AddWorkflow() + .AddWorkflow(); + + [Fact(DisplayName = "Fault activity faults workflow execution")] + public async Task Fault_Activity_Faults_Workflow_Execution() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(FaultWorkflow.DefinitionId); + + // Assert + AssertWorkflowFaulted(workflowState); + var incident = GetSingleIncident(workflowState); + Assert.Equal("Test fault message", incident.Message); + } + + [Fact(DisplayName = "Fault activity with default values faults workflow")] + public async Task Fault_With_Default_Values_Faults_Workflow() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(FaultWithDefaultsWorkflow.DefinitionId); + + // Assert + AssertWorkflowFaulted(workflowState); + GetSingleIncident(workflowState); + } + + [Fact(DisplayName = "Fault in sequence stops subsequent activities")] + public async Task Fault_In_Sequence_Stops_Subsequent_Activities() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(FaultInSequenceWorkflow.DefinitionId); + + // Assert + AssertWorkflowFaulted(workflowState); + + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + Assert.Contains("Before fault", lines); + Assert.DoesNotContain("After fault - should not execute", lines); + } + + [Fact(DisplayName = "Fault with factory method creates correct exception")] + public async Task Fault_Created_With_Factory_Method() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(FaultViaFactoryWorkflow.DefinitionId); + + // Assert + AssertWorkflowFaulted(workflowState); + var incident = GetSingleIncident(workflowState); + Assert.Equal("Created via factory", incident.Message); + } + + private static void AssertWorkflowFaulted(WorkflowState workflowState) + { + Assert.Equal(WorkflowSubStatus.Faulted, workflowState.SubStatus); + } + + private static ActivityIncident GetSingleIncident(WorkflowState workflowState) + { + var incident = Assert.Single(workflowState.Incidents); + Assert.NotNull(incident.Exception); + Assert.Equal(typeof(FaultException), incident.Exception.Type); + return incident; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Primitives/FinishTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Primitives/FinishTests.cs new file mode 100644 index 000000000..398e29819 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Primitives/FinishTests.cs @@ -0,0 +1,36 @@ +using Elsa.Activities.IntegrationTests.Primitives.Workflows; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.State; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests.Primitives; + +/// +/// Integration tests for the activity. +/// +public class FinishTests(ITestOutputHelper testOutputHelper) +{ + private readonly WorkflowTestFixture _fixture = new WorkflowTestFixture(testOutputHelper) + .AddWorkflow(); + + [Fact(DisplayName = "Finish terminates workflow and prevents subsequent activities from executing")] + public async Task Finish_TerminatesWorkflowAndPreventsSubsequentActivities() + { + // Act + var workflowState = await _fixture.RunWorkflowAsync(FinishInSequenceWorkflow.DefinitionId); + + // Assert + AssertWorkflowFinished(workflowState); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + Assert.Contains("Before Finish", lines); + Assert.DoesNotContain("After Finish", lines); + } + + private static void AssertWorkflowFinished(WorkflowState workflowState) + { + Assert.Equal(WorkflowStatus.Finished, workflowState.Status); + Assert.Equal(WorkflowSubStatus.Finished, workflowState.SubStatus); + } +} diff --git a/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultInSequenceWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultInSequenceWorkflow.cs new file mode 100644 index 000000000..c19f21756 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultInSequenceWorkflow.cs @@ -0,0 +1,29 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Primitives.Workflows; + +public class FaultInSequenceWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = nameof(FaultInSequenceWorkflow); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.DefinitionId = DefinitionId; + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Before fault"), + new Fault + { + Code = new("SEQ_001"), + Category = new("Sequence"), + FaultType = new("Test"), + Message = new("Fault in sequence") + }, + new WriteLine("After fault - should not execute") + } + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultViaFactoryWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultViaFactoryWorkflow.cs new file mode 100644 index 000000000..efe5e0321 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultViaFactoryWorkflow.cs @@ -0,0 +1,15 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Primitives.Workflows; + +public class FaultViaFactoryWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = nameof(FaultViaFactoryWorkflow); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.DefinitionId = DefinitionId; + workflow.Root = Fault.Create("FACTORY_001", "Factory", "Integration", "Created via factory"); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultWithDefaultsWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultWithDefaultsWorkflow.cs new file mode 100644 index 000000000..9b7cb618a --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultWithDefaultsWorkflow.cs @@ -0,0 +1,21 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Primitives.Workflows; + +public class FaultWithDefaultsWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = nameof(FaultWithDefaultsWorkflow); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.DefinitionId = DefinitionId; + workflow.Root = new Fault + { + Code = new((string)null!), + Category = new((string)null!), + FaultType = new((string)null!), + Message = new((string?)null) + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultWorkflow.cs new file mode 100644 index 000000000..32053fab7 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FaultWorkflow.cs @@ -0,0 +1,21 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.IntegrationTests.Primitives.Workflows; + +public class FaultWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = nameof(FaultWorkflow); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.DefinitionId = DefinitionId; + workflow.Root = new Fault + { + Code = new("ERR_001"), + Category = new("Test"), + FaultType = new("Business"), + Message = new("Test fault message") + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FinishInSequenceWorkflow.cs b/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FinishInSequenceWorkflow.cs new file mode 100644 index 000000000..4963327ff --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/Primitives/Workflows/FinishInSequenceWorkflow.cs @@ -0,0 +1,24 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using System; + +namespace Elsa.Activities.IntegrationTests.Primitives.Workflows; + +public class FinishInSequenceWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + + protected override void Build(IWorkflowBuilder workflow) + { + workflow.WithDefinitionId(DefinitionId); + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Before Finish"), + new Finish(), + new WriteLine("After Finish") + } + }; + } +} diff --git a/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj b/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj index 4f563a6e5..e749c8d4a 100644 --- a/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj +++ b/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj @@ -1,18 +1,22 @@ + + [Elsa.Alterations]*,[Elsa.Workflows.Runtime]* + + - - - + + + - - Always - - - Always - + + Always + + + Always + diff --git a/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj b/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj new file mode 100644 index 000000000..3cc54f7ff --- /dev/null +++ b/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj @@ -0,0 +1,13 @@ + + + + [Elsa.Common]* + 0 + + + + + + + + diff --git a/test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs b/test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs new file mode 100644 index 000000000..f252f81bb --- /dev/null +++ b/test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs @@ -0,0 +1,107 @@ +using Elsa.Expressions.Helpers; + +namespace Elsa.Common.IntegrationTests.Serialization; + +/// +/// Tests for ObjectFormatter.Format() to ensure proper serialization of different types: +/// - Strings are preserved as-is +/// - Byte arrays are base64-encoded +/// - Arrays and collections serialize to JSON instead of "T[] Array" +/// Related to GitHub issue #7019. +/// +public class ObjectFormatterTests +{ + [Fact(DisplayName = "String is preserved as-is")] + public void StringIsPreservedAsIs() + { + var testString = "Hello World"; + var result = testString.Format(); + + Assert.Equal("Hello World", result); + } + + [Fact(DisplayName = "Byte array is serialized as base64 string")] + public void ByteArrayIsSerializedAsBase64String() + { + var testByteArray = new byte[] { 0x01, 0x02, 0x03, 0x04, 0xFF }; + var result = testByteArray.Format(); + + // Byte arrays are base64-encoded for serialization + Assert.NotNull(result); + var expectedBase64 = Convert.ToBase64String(testByteArray); + Assert.Equal(expectedBase64, result); + } + + [Fact(DisplayName = "Integer array is serialized as JSON array")] + public void IntegerArrayIsSerializedAsJsonArray() + { + var testArray = new[] { 1, 2, 3, 4, 5 }; + var result = testArray.Format(); + + Assert.Equal("[1,2,3,4,5]", result); + } + + [Fact(DisplayName = "String array is serialized as JSON array")] + public void StringArrayIsSerializedAsJsonArray() + { + var testArray = new[] { "Hello", "World" }; + var result = testArray.Format(); + + Assert.Equal("[\"Hello\",\"World\"]", result); + } + + [Fact(DisplayName = "String array with multiple elements is serialized as JSON array")] + public void StringArrayWithMultipleElementsIsSerializedAsJsonArray() + { + var testArray = new[] { "Element 1", "Element 2", "Element 3" }; + var result = testArray.Format(); + + Assert.Equal("[\"Element 1\",\"Element 2\",\"Element 3\"]", result); + } + + [Fact(DisplayName = "Custom class array is serialized as JSON array")] + public void CustomClassArrayIsSerializedAsJsonArray() + { + var testArray = new[] { new TestClass { Name = "Item1" }, new TestClass { Name = "Item2" } }; + var result = testArray.Format(); + + // Should be JSON, not "TestClass[] Array" + Assert.NotNull(result); + Assert.StartsWith("[", result); + Assert.Contains("Item1", result); + Assert.Contains("Item2", result); + } + + [Fact(DisplayName = "List of integers is serialized as JSON array")] + public void ListOfIntegersIsSerializedAsJsonArray() + { + var testList = new List { 1, 2, 3 }; + var result = testList.Format(); + + Assert.Equal("[1,2,3]", result); + } + + [Fact(DisplayName = "List with different values is serialized as JSON array")] + public void ListWithDifferentValuesIsSerializedAsJsonArray() + { + var testList = new List { 10, 20, 30 }; + var result = testList.Format(); + + Assert.Equal("[10,20,30]", result); + } + + [Fact(DisplayName = "Null returns null")] + public void NullReturnsNull() + { + object? testValue = null; + var result = testValue.Format(); + + Assert.Null(result); + } + + private class TestClass + { + public string Name { get; set; } = string.Empty; + } +} + diff --git a/test/integration/Elsa.Common.IntegrationTests/Usings.cs b/test/integration/Elsa.Common.IntegrationTests/Usings.cs new file mode 100644 index 000000000..c802f4480 --- /dev/null +++ b/test/integration/Elsa.Common.IntegrationTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/CompilerTests.cs b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/CompilerTests.cs new file mode 100644 index 000000000..c1dbde0b1 --- /dev/null +++ b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/CompilerTests.cs @@ -0,0 +1,415 @@ +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Extensions; +using Elsa.Http; +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Dsl.ElsaScript.IntegrationTests; + +/// +/// Integration tests for the ElsaScript compiler. +/// +public class CompilerTests +{ + private readonly IElsaScriptCompiler _compiler; + + public CompilerTests(ITestOutputHelper testOutputHelper) + { + var services = new TestApplicationBuilder(testOutputHelper) + .ConfigureElsa(elsa => + { + elsa.UseElsaScript(); + elsa.AddActivitiesFrom(); + }) + .Build(); + + _compiler = services.GetRequiredService(); + } + + [Fact(DisplayName = "Compiler can compile a simple workflow from source")] + public async Task CompileAsync_WithSimpleWorkflowSource_ShouldCreateWorkflowWithCorrectName() + { + // Arrange + var source = @" +use Elsa.Activities.Console; +use expressions js; + +workflow HelloWorld { + WriteLine(""Hello World""); +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert - verify the workflow is correctly compiled + Assert.NotNull(workflow); + Assert.Equal("HelloWorld", workflow.Name); + Assert.NotNull(workflow.Root); + } + + [Fact(DisplayName = "Compiler can compile workflow with variable declarations")] + public async Task CompileAsync_WithVariableDeclarations_ShouldCreateWorkflowWithAllVariables() + { + // Arrange + var source = @" +use expressions js; + +workflow VariableTest { + var message = ""Hello from variable""; + var count = 42; + const pi = 3.14; +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert - verify variables are compiled correctly + Assert.NotNull(workflow); + Assert.Equal("VariableTest", workflow.Name); + Assert.Equal(3, workflow.Variables.Count); + + Assert.Contains(workflow.Variables, v => v.Name == "message"); + Assert.Contains(workflow.Variables, v => v.Name == "count"); + Assert.Contains(workflow.Variables, v => v.Name == "pi"); + } + + [Fact(DisplayName = "Compiler can compile workflow without workflow keyword")] + public async Task CompileAsync_WithoutWorkflowKeyword_ShouldCreateWorkflow() + { + // Arrange + var source = @"WriteLine(""Hello World"");"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.NotNull(workflow.Root); + } + + [Fact(DisplayName = "Compiler can compile complex workflow with variables, listen statements, and expressions")] + public async Task CompileAsync_WithComplexWorkflow_ShouldCreateWorkflowWithCorrectStructure() + { + // Arrange + var source = @" +use expressions js; + +workflow HelloWorldHttpDsl { + var message = ""Hello World from DSL via Expressions!""; + listen HttpEndpoint(""/hello-world-dsl""); + WriteLine(js => `Message: ${message}`); + WriteHttpResponse(message); +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.Equal("HelloWorldHttpDsl", workflow.Name); + + // Verify workflow has the message variable + Assert.Single(workflow.Variables); + var messageVar = workflow.Variables.First(); + Assert.Equal("message", messageVar.Name); + Assert.Equal("Hello World from DSL via Expressions!", messageVar.Value); + + // Verify root is a Sequence with 3 activities (HttpEndpoint, WriteLine, WriteHttpResponse) + var sequence = Assert.IsType(workflow.Root); + Assert.Equal(3, sequence.Activities.Count); + + // Verify HttpEndpoint activity (from listen statement) + var httpEndpoint = sequence.Activities.ElementAt(0); + Assert.Equal("Elsa.HttpEndpoint", httpEndpoint.Type); + + // Verify HttpEndpoint can start workflow (CanStartWorkflow property should be true) + var canStartWorkflowProp = httpEndpoint.GetType().GetProperty("CanStartWorkflow"); + Assert.NotNull(canStartWorkflowProp); + var canStartWorkflow = (bool)canStartWorkflowProp.GetValue(httpEndpoint)!; + Assert.True(canStartWorkflow); + + // Verify WriteLine activity with JavaScript expression + var writeLine = sequence.Activities.ElementAt(1); + Assert.Equal("Elsa.WriteLine", writeLine.Type); + + var textProp = writeLine.GetType().GetProperty("Text"); + Assert.NotNull(textProp); + var textInput = Assert.IsType>(textProp.GetValue(writeLine)); + + // Verify it's a JavaScript expression + var expression = textInput.Expression; + Assert.NotNull(expression); + Assert.Equal("JavaScript", expression.Type); + Assert.Contains("Message:", expression.Value?.ToString()); + Assert.Contains("message", expression.Value?.ToString()); + + // Verify WriteHttpResponse activity with variable reference + var writeHttpResponse = sequence.Activities.ElementAt(2); + Assert.Equal("Elsa.WriteHttpResponse", writeHttpResponse.Type); + + var contentProp = writeHttpResponse.GetType().GetProperty("Content"); + Assert.NotNull(contentProp); + var contentInput = Assert.IsType>(contentProp.GetValue(writeHttpResponse)); + + // Verify it references the message variable + var memoryBlockReference = contentInput.MemoryBlockReference(); + Assert.NotNull(memoryBlockReference); + Assert.Equal(messageVar.Id, memoryBlockReference.Id); + } + + [Fact(DisplayName = "Compiler can compile for loop with 'to' keyword (exclusive)")] + public async Task CompileAsync_WithForLoopExclusive_ShouldCreateForActivity() + { + // Arrange + var source = @" +use expressions js; + +workflow ForLoopTest { + for (var i = 0 to 10 step 1) + { + WriteLine(js => `Step: ${i}`) + } +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.Equal("ForLoopTest", workflow.Name); + + // Verify the For activity is created + var forActivity = Assert.IsType(workflow.Root); + Assert.NotNull(forActivity); + + // Verify Start, End, Step values + Assert.NotNull(forActivity.Start); + Assert.NotNull(forActivity.End); + Assert.NotNull(forActivity.Step); + + // Verify OuterBoundInclusive is false (exclusive 'to') + Assert.NotNull(forActivity.OuterBoundInclusive); + var outerBoundInput = forActivity.OuterBoundInclusive; + var outerBoundExpr = outerBoundInput.Expression; + Assert.NotNull(outerBoundExpr); + Assert.False((bool?)outerBoundExpr.Value); + + // Verify loop variable exists + Assert.Single(workflow.Variables); + var loopVar = workflow.Variables.First(); + Assert.Equal("i", loopVar.Name); + + // Verify body exists + Assert.NotNull(forActivity.Body); + } + + [Fact(DisplayName = "Compiler can compile for loop with 'through' keyword (inclusive)")] + public async Task CompileAsync_WithForLoopInclusive_ShouldCreateForActivity() + { + // Arrange + var source = @" +use expressions js; + +workflow ForLoopInclusiveTest { + for (var i = 0 through 10 step 1) + { + WriteLine(js => `Step: ${i}`) + } +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.Equal("ForLoopInclusiveTest", workflow.Name); + + // Verify the For activity is created + var forActivity = Assert.IsType(workflow.Root); + Assert.NotNull(forActivity); + + // Verify OuterBoundInclusive is true (inclusive 'through') + Assert.NotNull(forActivity.OuterBoundInclusive); + var outerBoundInput = forActivity.OuterBoundInclusive; + var outerBoundExpr = outerBoundInput.Expression; + Assert.NotNull(outerBoundExpr); + Assert.True((bool?)outerBoundExpr.Value); + + // Verify loop variable exists + Assert.Single(workflow.Variables); + var loopVar = workflow.Variables.First(); + Assert.Equal("i", loopVar.Name); + } + + [Fact(DisplayName = "Compiler can compile workflow with metadata")] + public async Task CompileAsync_WithWorkflowMetadata_ShouldCreateWorkflowWithCorrectMetadata() + { + // Arrange + var source = @" +use Elsa.Activities.Console; + +workflow HelloWorldDsl( + DisplayName: ""Hello World DSL"", + Description: ""Demonstrates ElsaScript with metadata"", + DefinitionId: ""hello-world-dsl"", + DefinitionVersionId: ""hello-world-dsl-v1"", + Version: 2, + UsableAsActivity: true +) { + use expressions js; + + WriteLine(""Hello World from Elsa DSL!""); +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + + // Check identity + Assert.Equal("hello-world-dsl", workflow.Identity.DefinitionId); + Assert.Equal(2, workflow.Identity.Version); + Assert.Equal("hello-world-dsl-v1", workflow.Identity.Id); + + // Check metadata + Assert.Equal("Hello World DSL", workflow.WorkflowMetadata.Name); + Assert.Equal("Demonstrates ElsaScript with metadata", workflow.WorkflowMetadata.Description); + + // Check options + Assert.True(workflow.Options.UsableAsActivity); + + // Check root activity + Assert.NotNull(workflow.Root); + } + + [Fact(DisplayName = "Compiler can compile empty flowchart")] + public async Task CompileAsync_WithEmptyFlowchart_ShouldCreateFlowchartActivity() + { + // Arrange + var source = @" +workflow FlowchartTest { + flowchart { + } +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.NotNull(workflow.Root); + + Assert.IsType(workflow.Root); + } + + [Fact(DisplayName = "Compiler can compile flowchart with node and connection")] + public async Task CompileAsync_WithFlowchartNodeAndConnection_ShouldCreateFlowchartWithCorrectStructure() + { + // Arrange + var source = @" +workflow FlowchartTest { + flowchart { + entry Start; + Start: WriteLine(); + End: WriteLine(); + Start -> End; + } +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.NotNull(workflow.Root); + + var flowchart = Assert.IsType(workflow.Root); + + // Check that flowchart has 2 activities + Assert.Equal(2, flowchart.Activities.Count); + + // Check connections + Assert.Single(flowchart.Connections); + + // Check entry point is set + Assert.NotNull(flowchart.Start); + } + + [Fact(DisplayName = "Compiler can compile flowchart with block node")] + public async Task CompileAsync_WithFlowchartBlockNode_ShouldCreateSequenceActivity() + { + // Arrange + var source = @" +workflow FlowchartWithBlock { + flowchart { + entry Start; + + Start: { + WriteLine(""First""); + WriteLine(""Second""); + } + } +}"; + + // Act + var workflow = await _compiler.CompileAsync(source); + + // Assert + Assert.NotNull(workflow); + Assert.NotNull(workflow.Root); + + var flowchart = Assert.IsType(workflow.Root); + + // Should have one activity + Assert.Single(flowchart.Activities); + + // The activity should be a Sequence (from the block) + var sequenceActivity = Assert.IsType(flowchart.Activities.First()); + + // Sequence should have 2 activities + Assert.Equal(2, sequenceActivity.Activities.Count); + } + + [Fact(DisplayName = "Compiler resets default expression language between compilations")] + public async Task CompileAsync_WithSecondWorkflowAfterLiquid_ShouldResetToJavaScript() + { + // Arrange - First workflow sets Liquid as default + var firstSource = @" +use expressions liquid; + +workflow FirstWorkflow { + WriteLine(liquid => ""{{ 'test' }}""); +}"; + + // Second workflow with no 'use expressions' directive + var secondSource = @" +workflow SecondWorkflow { + WriteLine(js => ""test""); +}"; + + // Act - Compile first workflow (sets default to Liquid) + var firstWorkflow = await _compiler.CompileAsync(firstSource); + + // Act - Compile second workflow (should reset to JavaScript) + var secondWorkflow = await _compiler.CompileAsync(secondSource); + + // Assert - Verify first workflow used Liquid + Assert.NotNull(firstWorkflow); + var firstWriteLine = firstWorkflow.Root; + var firstTextProp = firstWriteLine.GetType().GetProperty("Text"); + var firstTextInput = Assert.IsType>(firstTextProp!.GetValue(firstWriteLine)); + Assert.Equal("Liquid", firstTextInput.Expression?.Type); + + // Assert - Verify second workflow uses JavaScript (not leaked Liquid) + Assert.NotNull(secondWorkflow); + var secondWriteLine = secondWorkflow.Root; + var secondTextProp = secondWriteLine.GetType().GetProperty("Text"); + var secondTextInput = Assert.IsType>(secondTextProp!.GetValue(secondWriteLine)); + Assert.Equal("JavaScript", secondTextInput.Expression?.Type); + } +} + diff --git a/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/Elsa.Dsl.ElsaScript.IntegrationTests.csproj b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/Elsa.Dsl.ElsaScript.IntegrationTests.csproj new file mode 100644 index 000000000..9af927fc0 --- /dev/null +++ b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/Elsa.Dsl.ElsaScript.IntegrationTests.csproj @@ -0,0 +1,14 @@ + + + + [Elsa.Dsl.ElsaScript]* + + + + + + + + + + diff --git a/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/ParserTests.cs b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/ParserTests.cs new file mode 100644 index 000000000..4bade764c --- /dev/null +++ b/test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/ParserTests.cs @@ -0,0 +1,430 @@ +using Elsa.Dsl.ElsaScript.Contracts; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Dsl.ElsaScript.IntegrationTests; + +/// +/// Integration tests for the ElsaScript parser. +/// +public class ParserTests +{ + private readonly IServiceProvider _services; + private readonly IElsaScriptParser _parser; + + public ParserTests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper) + .ConfigureElsa(elsa => elsa.UseElsaScript()) + .Build(); + + _parser = _services.GetRequiredService(); + } + + [Fact(DisplayName = "Parser can parse a simple workflow definition")] + public void Parse_WithSimpleWorkflowDefinition_ShouldReturnWorkflowWithCorrectStructure() + { + // Arrange + var source = @" +use Elsa.Activities.Console; +use expressions js; + +workflow HelloWorld { + WriteLine(""Hello World""); + WriteLine(""Great to meet you!""); +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("HelloWorld", workflow.Id); + + // Global use statements + Assert.Equal(2, program.GlobalUseStatements.Count); + + // No workflow-level use statements (all are global) + Assert.Empty(workflow.UseStatements); + + Assert.Equal(2, workflow.Body.Count); + } + + [Fact(DisplayName = "Parser can parse variable declarations")] + public void Parse_WithVariableDeclarations_ShouldReturnWorkflowWithAllVariableNodes() + { + // Arrange + var source = @" +use expressions js; + +workflow VariableTest { + var greeting = ""Hello""; + var count = 42; + const pi = 3.14; +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("VariableTest", workflow.Id); + Assert.Equal(3, workflow.Body.Count); + + var varDecl = Assert.IsType(workflow.Body[0]); + Assert.Equal(Ast.VariableKind.Var, varDecl.Kind); + Assert.Equal("greeting", varDecl.Name); + } + + [Fact(DisplayName = "Parser can parse activity invocations with named arguments")] + public void Parse_WithActivityInvocationWithNamedArguments_ShouldReturnActivityNodeWithCorrectArguments() + { + // Arrange + var source = @" +use expressions js; + +workflow ActivityTest { + WriteLine(Text: ""Hello World""); +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Single(workflow.Body); + + var activity = Assert.IsType(workflow.Body[0]); + Assert.Equal("WriteLine", activity.ActivityName); + Assert.Single(activity.Arguments); + Assert.Equal("Text", activity.Arguments[0].Name); + } + + [Fact(DisplayName = "Parser can parse listen statements")] + public void Parse_WithListenStatement_ShouldReturnWorkflowWithListenNode() + { + // Arrange + var source = @" +use expressions js; + +workflow ListenTest { + listen HttpEndpoint(""/test""); + WriteLine(""Triggered!""); +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal(2, workflow.Body.Count); + + var listen = Assert.IsType(workflow.Body[0]); + Assert.Equal("HttpEndpoint", listen.Activity.ActivityName); + } + + [Fact(DisplayName = "Parser can parse workflow without workflow keyword")] + public void Parse_WithoutWorkflowKeyword_ShouldReturnWorkflowWithCorrectStructure() + { + // Arrange + var source = @"WriteLine(""Hello World""); WriteLine(""Great to meet you!"");"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal(2, workflow.Body.Count); + } + + [Fact(DisplayName = "Parser can parse complex workflow with variables, listen statements, and ElsaScript expressions")] + public void Parse_WithComplexWorkflow_ShouldReturnWorkflowWithCorrectStructure() + { + // Arrange + var source = @" +use expressions js; + +workflow HelloWorldHttpDsl { + var message = ""Hello World from DSL via Expressions!""; + listen HttpEndpoint(""/hello-world-dsl""); + WriteLine(js => `Message: ${message}`); + WriteHttpResponse(message); +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("HelloWorldHttpDsl", workflow.Id); + + // Verify global use statement + Assert.Single(program.GlobalUseStatements); + var useStatement = program.GlobalUseStatements[0]; + Assert.Equal(Ast.UseType.Expressions, useStatement.Type); + Assert.Equal("js", useStatement.Value); + + // No workflow-level use statements + Assert.Empty(workflow.UseStatements); + + // Verify body contains: var declaration, listen statement, WriteLine, WriteHttpResponse + Assert.Equal(4, workflow.Body.Count); + + // Verify variable declaration + var varDecl = Assert.IsType(workflow.Body[0]); + Assert.Equal("message", varDecl.Name); + Assert.Equal(Ast.VariableKind.Var, varDecl.Kind); + + // Verify listen statement + var listenNode = Assert.IsType(workflow.Body[1]); + Assert.Equal("HttpEndpoint", listenNode.Activity.ActivityName); + Assert.Single(listenNode.Activity.Arguments); + + // Verify WriteLine with ElsaScript expression + var writeLineNode = Assert.IsType(workflow.Body[2]); + Assert.Equal("WriteLine", writeLineNode.ActivityName); + Assert.Single(writeLineNode.Arguments); + var writeLineExpr = Assert.IsType(writeLineNode.Arguments[0].Value); + Assert.Equal("js", writeLineExpr.Language); + + // Verify WriteHttpResponse with variable reference + var writeHttpNode = Assert.IsType(workflow.Body[3]); + Assert.Equal("WriteHttpResponse", writeHttpNode.ActivityName); + Assert.Single(writeHttpNode.Arguments); + var writeHttpArg = Assert.IsType(writeHttpNode.Arguments[0].Value); + Assert.Equal("message", writeHttpArg.Name); + } + + [Fact(DisplayName = "Parser can parse for loop with 'to' keyword (exclusive)")] + public void Parse_WithForLoopExclusive_ShouldReturnWorkflowWithForNode() + { + // Arrange + var source = @" +use expressions js; + +workflow ForLoopTest { + for (var i = 0 to 10 step 1) + { + WriteLine(js => `Step: ${i}`) + } +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("ForLoopTest", workflow.Id); + Assert.Single(workflow.Body); + + var forNode = Assert.IsType(workflow.Body[0]); + Assert.True(forNode.DeclaresVariable); // var i + Assert.Equal("i", forNode.VariableName); + Assert.False(forNode.IsInclusive); + + var startLiteral = Assert.IsType(forNode.Start); + // Numbers are parsed as decimals by the parser + Assert.Equal(0m, Convert.ToDecimal(startLiteral.Value!)); + + var endLiteral = Assert.IsType(forNode.End); + Assert.Equal(10m, Convert.ToDecimal(endLiteral.Value!)); + + var stepLiteral = Assert.IsType(forNode.Step); + Assert.Equal(1m, Convert.ToDecimal(stepLiteral.Value!)); + } + + [Fact(DisplayName = "Parser can parse for loop with 'through' keyword (inclusive)")] + public void Parse_WithForLoopInclusive_ShouldReturnWorkflowWithForNode() + { + // Arrange + var source = @" +use expressions js; + +workflow ForLoopInclusiveTest { + for (var i = 0 through 10 step 1) + { + WriteLine(js => `Step: ${i}`) + } +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("ForLoopInclusiveTest", workflow.Id); + Assert.Single(workflow.Body); + + var forNode = Assert.IsType(workflow.Body[0]); + Assert.True(forNode.DeclaresVariable); // var i + Assert.Equal("i", forNode.VariableName); + Assert.True(forNode.IsInclusive); + } + + [Fact(DisplayName = "Parser can parse workflow with metadata")] + public void Parse_WithWorkflowMetadata_ShouldReturnWorkflowWithCorrectMetadata() + { + // Arrange + var source = @" +use Elsa.Activities.Console; + +workflow HelloWorldDsl( + DisplayName: ""Hello World DSL"", + Description: ""Demonstrates ElsaScript with metadata"", + DefinitionId: ""hello-world-dsl"", + DefinitionVersionId: ""hello-world-dsl-v1"", + Version: 1, + UsableAsActivity: true +) { + use expressions js; + + WriteLine(""Hello World from Elsa DSL!""); +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("HelloWorldDsl", workflow.Id); + + // Check metadata + Assert.Equal("Hello World DSL", (string)workflow.Metadata["DisplayName"]); + Assert.Equal("Demonstrates ElsaScript with metadata", (string)workflow.Metadata["Description"]); + Assert.Equal("hello-world-dsl", (string)workflow.Metadata["DefinitionId"]); + Assert.Equal("hello-world-dsl-v1", (string)workflow.Metadata["DefinitionVersionId"]); + Assert.Equal(1L, Convert.ToInt64(workflow.Metadata["Version"])); + Assert.True(Convert.ToBoolean(workflow.Metadata["UsableAsActivity"])); + + // Check use statements (workflow-level only, global handled separately) + Assert.Single(workflow.UseStatements); + Assert.Equal(Ast.UseType.Expressions, workflow.UseStatements[0].Type); + Assert.Equal("js", workflow.UseStatements[0].Value); + + // Check body + Assert.Single(workflow.Body); + } + + [Fact(DisplayName = "Parser can parse empty flowchart")] + public void Parse_WithEmptyFlowchart_ShouldReturnFlowchartNode() + { + // Arrange + var source = @" +workflow FlowchartTest { + flowchart { + } +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + Assert.Equal("FlowchartTest", workflow.Id); + Assert.Single(workflow.Body); + + Assert.IsType(workflow.Body[0]); + } + + [Fact(DisplayName = "Parser can parse flowchart with node and connection")] + public void Parse_WithFlowchartNodeAndConnection_ShouldReturnCorrectStructure() + { + // Arrange + var source = @" +workflow FlowchartTest { + flowchart { + entry Start; + Start: WriteLine(); + End: WriteLine(); + Start -> End; + } +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + Assert.Single(program.Workflows); + + var workflow = program.Workflows[0]; + var flowchart = Assert.IsType(workflow.Body[0]); + + // Check entry point + Assert.Equal("Start", flowchart.EntryPoint); + + // Check nodes + Assert.Equal(2, flowchart.Activities.Count); + + // Check connections + Assert.Single(flowchart.Connections); + Assert.Equal("Start", flowchart.Connections[0].Source); + Assert.Equal("End", flowchart.Connections[0].Target); + } + + [Fact(DisplayName = "Parser can parse flowchart with block node")] + public void Parse_WithFlowchartBlockNode_ShouldReturnBlockStatement() + { + // Arrange + var source = @" +workflow FlowchartWithBlock { + flowchart { + entry Start; + + Start: { + WriteLine(""First""); + WriteLine(""Second""); + } + } +}"; + + // Act + var program = _parser.Parse(source); + + // Assert + Assert.NotNull(program); + var workflow = program.Workflows[0]; + var flowchart = Assert.IsType(workflow.Body[0]); + + Assert.Single(flowchart.Activities); + Assert.Equal("Start", flowchart.Activities[0].Label); + + // The block should be a BlockNode with 2 statements + var blockNode = Assert.IsType(flowchart.Activities[0].Activity); + Assert.Equal(2, blockNode.Statements.Count); + } + +} diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj b/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj index fa80c09c2..b7f1ee64d 100644 --- a/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj +++ b/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net9.0 + [Elsa.Expressions.JavaScript]* diff --git a/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj b/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj index 95419bcf8..a77331da1 100644 --- a/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj +++ b/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net9.0 + [Elsa.Resilience]*,[Elsa.Http]*,[Elsa.Workflows.Core]* diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/BreakTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/BreakTests.cs index 5233b5e1c..c293c2d96 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/BreakTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/BreakTests.cs @@ -1,64 +1,58 @@ using Elsa.Testing.Shared; using Elsa.Workflows.IntegrationTests.Activities.Workflows; -using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; namespace Elsa.Workflows.IntegrationTests.Activities; -public class BreakTests +/// +/// Integration tests for the activity. +/// Tests Break behavior across different looping constructs (ForEach, For, While, Fork). +/// +public class BreakTests(ITestOutputHelper testOutputHelper) { - private readonly IWorkflowRunner _workflowRunner; - private readonly CapturingTextWriter _capturingTextWriter = new(); - private readonly IServiceProvider _services; + private readonly WorkflowTestFixture _fixture = new(testOutputHelper); - public BreakTests(ITestOutputHelper testOutputHelper) + [Fact(DisplayName = "Break exits ForEach loop")] + public async Task Break_ExitsForEachLoop() { - _services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); - _workflowRunner = _services.GetRequiredService(); - } + // Act + await _fixture.RunWorkflowAsync(new BreakForEachWorkflow()); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); - [Fact(DisplayName = "Break exits out of ForEach")] - public async Task Test1() - { - await _services.PopulateRegistriesAsync(); - await _workflowRunner.RunAsync(); - var lines = _capturingTextWriter.Lines.ToList(); + // Assert Assert.Equal(new[] { "Start", "C#", "End" }, lines); } - [Fact(DisplayName = "Break exits out of immediate ForEach only")] - public async Task Test2() + [Fact(DisplayName = "Break exits only immediate loop in nested ForEach")] + public async Task Break_ExitsOnlyImmediateLoopInNestedForEach() { - await _services.PopulateRegistriesAsync(); - await _workflowRunner.RunAsync(); - var lines = _capturingTextWriter.Lines.ToList(); + // Act + await _fixture.RunWorkflowAsync(new NestedForEachWithBreakWorkflow()); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + + // Assert Assert.Equal(new[] { "C#", "Classes", "Rust", "Classes", "Go", "Classes" }, lines); } - [Fact(DisplayName = "Break exits out of For")] - public async Task Test3() + [Fact(DisplayName = "Break exits For loop")] + public async Task Break_ExitsForLoop() { - await _services.PopulateRegistriesAsync(); - await _workflowRunner.RunAsync(); - var lines = _capturingTextWriter.Lines.ToList(); + // Act + await _fixture.RunWorkflowAsync(new BreakForWorkflow()); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + + // Assert Assert.Equal(new[] { "Start", "0", "1", "End" }, lines); } - - [Fact(DisplayName = "Break exits out of While")] - public async Task Test4() + + [Fact(DisplayName = "Break exits While loop")] + public async Task Break_ExitsWhileLoop() { - await _services.PopulateRegistriesAsync(); - await _workflowRunner.RunAsync(); - var lines = _capturingTextWriter.Lines.ToList(); + // Act + await _fixture.RunWorkflowAsync(new BreakWhileWorkflow()); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + + // Assert Assert.Equal(new[] { "Start", "1", "2", "End" }, lines); } - - [Fact(DisplayName = "Break removes the right bookmarks")] - public async Task Test5() - { - await _services.PopulateRegistriesAsync(); - await _workflowRunner.RunAsync(); - var lines = _capturingTextWriter.Lines.ToList(); - Assert.Equal(new[] { "Start", "1", "2", "End" }, lines); - } -} \ No newline at end of file +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakForEachWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakForEachWorkflow.cs index 1a00cc3f3..d5b36e141 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakForEachWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakForEachWorkflow.cs @@ -3,7 +3,7 @@ using Elsa.Workflows.Memory; namespace Elsa.Workflows.IntegrationTests.Activities.Workflows; -class BreakForEachWorkflow : WorkflowBase +public class BreakForEachWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder workflow) { diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakForWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakForWorkflow.cs index 6766297f6..70aa5205b 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakForWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakForWorkflow.cs @@ -3,7 +3,7 @@ using Elsa.Workflows.Memory; namespace Elsa.Workflows.IntegrationTests.Activities.Workflows; -class BreakForWorkflow : WorkflowBase +public class BreakForWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder workflow) { diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakWhileForkWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakWhileForkWorkflow.cs deleted file mode 100644 index 8574327b9..000000000 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Break/Workflows/BreakWhileForkWorkflow.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Elsa.Workflows.Activities; -using Elsa.Workflows.Memory; -using Elsa.Workflows.Runtime.Activities; - -namespace Elsa.Workflows.IntegrationTests.Activities.Workflows; - -public class BreakWhileForkWorkflow : WorkflowBase -{ - protected override void Build(IWorkflowBuilder workflow) - { - var currentValue = new Variable("CurrentValue", 0); - - workflow.Root = new Sequence - { - Variables = { currentValue }, - Activities = - { - While.True(new Fork - { - Branches = - { - new Sequence - { - Activities = - { - new SetVariable - { - Variable = currentValue, - Value = new (context => currentValue.Get(context) + 1) - }, - new If(context => currentValue.Get(context) == 3) - { - Then = new Break() - } - } - }, - new Sequence - { - Activities = - { - new WriteLine("Waiting for event..."), - new Event("Some event") { Id = "SomeEvent" }, - new WriteLine("Resuming"), - } - } - } - }), - } - }; - } -} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/ContainerTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/ContainerTests.cs new file mode 100644 index 000000000..687958c11 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/ContainerTests.cs @@ -0,0 +1,111 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Activities.Container; + +/// +/// Integration tests for the activity. +/// +public class ContainerTests(ITestOutputHelper testOutputHelper) +{ + private readonly WorkflowTestFixture _fixture = new(testOutputHelper); + + [Fact(DisplayName = "Container executes child activities in order")] + public async Task Container_ExecutesChildActivitiesInOrder() + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new SimpleContainerWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(new[] { "Activity 1", "Activity 2", "Activity 3" }, lines); + } + + [Fact(DisplayName = "Container with variables makes them available to child activities")] + public async Task Container_WithVariables_MakesThemAvailableToChildren() + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new ContainerWithVariablesWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Contains("Counter: 0", lines[0]); + Assert.Contains("Counter: 1", lines[1]); + Assert.Contains("Counter: 2", lines[2]); + } + + [Fact(DisplayName = "Container with nested containers scopes variables correctly")] + public async Task Container_WithNestedContainers_ScopesVariablesCorrectly() + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new NestedContainersWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Contains("Outer: 10", lines[0]); + Assert.Contains("Inner: 20", lines[1]); + Assert.Contains("Outer again: 10", lines[2]); + } + + [Fact(DisplayName = "Container with unnamed variables auto-names them")] + public async Task Container_WithUnnamedVariables_AutoNamesThem() + { + // Arrange & Act + var (result, _) = await RunWorkflowAndCaptureOutput(new ContainerWithUnnamedVariablesWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + // The workflow should complete successfully even with unnamed variables + } + + [Fact(DisplayName = "Container with no activities completes successfully")] + public async Task Container_WithNoActivities_CompletesSuccessfully() + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new EmptyContainerWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Empty(lines); + } + + [Fact(DisplayName = "Container with multiple variable types handles them correctly")] + public async Task Container_WithMultipleVariableTypes_HandlesThemCorrectly() + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new ContainerWithMixedVariableTypesWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Contains("Int: 42", lines[0]); + Assert.Contains("String: Hello", lines[1]); + Assert.Contains("Bool: True", lines[2]); + } + + [Theory(DisplayName = "Container handles different numbers of child activities")] + [InlineData(1)] + [InlineData(5)] + [InlineData(10)] + public async Task Container_HandlesDifferentNumbersOfChildActivities(int activityCount) + { + // Arrange & Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new DynamicContainerWorkflow(activityCount)); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(activityCount, lines.Count); + for (var i = 0; i < activityCount; i++) + { + Assert.Equal($"Activity {i + 1}", lines[i]); + } + } + + private async Task<(RunWorkflowResult Result, List Lines)> RunWorkflowAndCaptureOutput(IWorkflow workflow) + { + var result = await _fixture.RunWorkflowAsync(workflow); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + return (result, lines); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/Workflows.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/Workflows.cs new file mode 100644 index 000000000..d9215d94a --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Container/Workflows.cs @@ -0,0 +1,154 @@ +using Elsa.Testing.Shared.Activities; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; + +namespace Elsa.Workflows.IntegrationTests.Activities.Container; + +/// +/// Simple workflow with a container that executes activities sequentially. +/// +public class SimpleContainerWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new TestContainer + { + Activities = + { + new WriteLine("Activity 1"), + new WriteLine("Activity 2"), + new WriteLine("Activity 3") + } + }; + } +} + +/// +/// Workflow demonstrating container with variables. +/// +public class ContainerWithVariablesWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var counter = new Variable("Counter", 0); + + workflow.Root = new TestContainer + { + Variables = { counter }, + Activities = + { + new WriteLine(context => $"Counter: {counter.Get(context)}"), + new SetVariable { Variable = counter, Value = new(1) }, + new WriteLine(context => $"Counter: {counter.Get(context)}"), + new SetVariable { Variable = counter, Value = new(2) }, + new WriteLine(context => $"Counter: {counter.Get(context)}") + } + }; + } +} + +/// +/// Workflow with nested containers to test variable scoping. +/// +public class NestedContainersWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var outerVar = new Variable("OuterVar", 10); + var innerVar = new Variable("InnerVar", 20); + + workflow.Root = new TestContainer + { + Variables = { outerVar }, + Activities = + { + new WriteLine(context => $"Outer: {outerVar.Get(context)}"), + new TestContainer + { + Variables = { innerVar }, + Activities = + { + new WriteLine(context => $"Inner: {innerVar.Get(context)}") + } + }, + new WriteLine(context => $"Outer again: {outerVar.Get(context)}") + } + }; + } +} + +/// +/// Workflow with unnamed variables to test auto-naming. +/// +public class ContainerWithUnnamedVariablesWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new TestContainer + { + Variables = + { + new Variable(), + new Variable(), + new Variable() + }, + Activities = + { + new WriteLine("Unnamed variables workflow") + } + }; + } +} + +/// +/// Workflow with an empty container (no activities). +/// +public class EmptyContainerWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new TestContainer(); + } +} + +/// +/// Workflow with mixed variable types. +/// +public class ContainerWithMixedVariableTypesWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var intVar = new Variable("IntVar", 42); + var stringVar = new Variable("StringVar", "Hello"); + var boolVar = new Variable("BoolVar", true); + + workflow.Root = new TestContainer + { + Variables = { intVar, stringVar, boolVar }, + Activities = + { + new WriteLine(context => $"Int: {intVar.Get(context)}"), + new WriteLine(context => $"String: {stringVar.Get(context)}"), + new WriteLine(context => $"Bool: {boolVar.Get(context)}") + } + }; + } +} + +/// +/// Workflow with a dynamic number of activities. +/// +public class DynamicContainerWorkflow(int activityCount) : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var container = new TestContainer(); + + for (var i = 1; i <= activityCount; i++) + { + container.Activities.Add(new WriteLine($"Activity {i}")); + } + + workflow.Root = container; + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Finish/FinishForkedWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Finish/FinishForkedWorkflow.cs index 4b6b29128..66a30bc2a 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Finish/FinishForkedWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Finish/FinishForkedWorkflow.cs @@ -17,7 +17,7 @@ public class FinishForkedWorkflow : WorkflowBase Activities = { new WriteLine("Branch 1"), - new Event("Event 1") + new Runtime.Activities.Event("Event 1") } }, new Sequence diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ForEach/ForEachWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ForEach/ForEachWorkflow.cs index 7c979ba77..592d6a315 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ForEach/ForEachWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ForEach/ForEachWorkflow.cs @@ -3,15 +3,8 @@ using Elsa.Workflows.Memory; namespace Elsa.Workflows.IntegrationTests.Activities; -class ForEachWorkflow : WorkflowBase +class ForEachWorkflow(ICollection items) : WorkflowBase { - private readonly ICollection _items; - - public ForEachWorkflow(ICollection items) - { - _items = items; - } - protected override void Build(IWorkflowBuilder workflow) { var currentItem = new Variable("CurrentItem", ""); @@ -23,7 +16,7 @@ class ForEachWorkflow : WorkflowBase { new ForEach { - Items = new(_items), + Items = new(items), CurrentValue = new(currentItem), Body = new WriteLine(currentItem) }, diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ForEach/NestedForEachWithBreakWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ForEach/NestedForEachWithBreakWorkflow.cs index 38b718be0..701015fe3 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/ForEach/NestedForEachWithBreakWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/ForEach/NestedForEachWithBreakWorkflow.cs @@ -3,7 +3,7 @@ using Elsa.Workflows.Memory; namespace Elsa.Workflows.IntegrationTests.Activities; -class NestedForEachWithBreakWorkflow : WorkflowBase +public class NestedForEachWithBreakWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder workflow) { diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/ForkTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/ForkTests.cs index ffabf4de1..0dbb49064 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/ForkTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/ForkTests.cs @@ -1,54 +1,64 @@ using Elsa.Extensions; using Elsa.Testing.Shared; +using Elsa.Workflows.IntegrationTests.Activities.Workflows; using Elsa.Workflows.Options; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; namespace Elsa.Workflows.IntegrationTests.Activities; -public class ForkTests +/// +/// Integration tests for the activity. +/// Tests Fork behavior with different join modes and branch configurations. +/// +public class ForkTests(ITestOutputHelper testOutputHelper) { - private readonly IWorkflowRunner _workflowRunner; - private readonly CapturingTextWriter _capturingTextWriter = new(); - private readonly IWorkflowBuilderFactory _workflowBuilderFactory; - private readonly IServiceProvider _services; + private readonly WorkflowTestFixture _fixture = new(testOutputHelper); - public ForkTests(ITestOutputHelper testOutputHelper) + [Fact(DisplayName = "Fork executes all branches with WaitAll")] + public async Task Fork_ExecutesAllBranchesWithWaitAll() { - _services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); - _workflowBuilderFactory = _services.GetRequiredService(); - _workflowRunner = _services.GetRequiredService(); - } + // Act + await _fixture.RunWorkflowAsync(new BasicForkWorkflow()); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); - [Fact(DisplayName = "Each branch executes")] - public async Task Test1() - { - await _services.PopulateRegistriesAsync(); - var workflow = await _workflowBuilderFactory.CreateBuilder().BuildWorkflowAsync(); - await _workflowRunner.RunAsync(workflow); - var lines = _capturingTextWriter.Lines.ToList(); + // Assert Assert.Equal(new[] { "Branch 1", "Branch 2", "Branch 3" }, lines); } - [Fact(DisplayName = "Wait AnyAsync causes workflow to continue")] - public async Task Test2() + [Fact(DisplayName = "Fork with WaitAny continues after first branch completes")] + public async Task Fork_WaitAnyContinuesAfterFirstBranch() { - await _services.PopulateRegistriesAsync(); - var workflow = await _workflowBuilderFactory.CreateBuilder().BuildWorkflowAsync(); + // Arrange & build services + await _fixture.BuildAsync(); + var workflowBuilderFactory = _fixture.Services.GetRequiredService(); + var workflow = await workflowBuilderFactory.CreateBuilder().BuildWorkflowAsync(); - // First run. - var result = await _workflowRunner.RunAsync(workflow); + // Act - First run + var workflowRunner = _fixture.Services.GetRequiredService(); + var result = await workflowRunner.RunAsync(workflow); - // Collect one of the bookmarks to resume the workflow. + // Collect one of the bookmarks to resume the workflow var bookmark = result.WorkflowState.Bookmarks.FirstOrDefault(x => x.ActivityId == "Event2"); Assert.NotNull(bookmark); - // Resume the workflow. + // Resume the workflow var runOptions = new RunWorkflowOptions { BookmarkId = bookmark.Id }; - await _workflowRunner.RunAsync(workflow, result.WorkflowState, runOptions); + await workflowRunner.RunAsync(workflow, result.WorkflowState, runOptions); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); - // Verify output. - var lines = _capturingTextWriter.Lines.ToList(); + // Assert Assert.Equal(new[] { "Start", "Branch 2", "End" }, lines); } + + [Fact(DisplayName = "Fork with no branches completes successfully")] + public async Task Fork_WithNoBranchesCompletesSuccessfully() + { + // Act + await _fixture.RunWorkflowAsync(new EmptyForkWorkflow()); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + + // Assert + Assert.Equal(new[] { "Before fork", "After fork" }, lines); + } } \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/BasicForkWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/Workflows/BasicForkWorkflow.cs similarity index 85% rename from test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/BasicForkWorkflow.cs rename to test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/Workflows/BasicForkWorkflow.cs index 2cbb9763a..789aebae7 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/BasicForkWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/Workflows/BasicForkWorkflow.cs @@ -1,6 +1,6 @@ using Elsa.Workflows.Activities; -namespace Elsa.Workflows.IntegrationTests.Activities; +namespace Elsa.Workflows.IntegrationTests.Activities.Workflows; public class BasicForkWorkflow : WorkflowBase { @@ -16,4 +16,4 @@ public class BasicForkWorkflow : WorkflowBase } }; } -} \ No newline at end of file +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/Workflows/EmptyForkWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/Workflows/EmptyForkWorkflow.cs new file mode 100644 index 000000000..62fb163d1 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/Workflows/EmptyForkWorkflow.cs @@ -0,0 +1,19 @@ +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.IntegrationTests.Activities.Workflows; + +public class EmptyForkWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Before fork"), + new Fork(), + new WriteLine("After fork") + } + }; + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/JoinAnyForkWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/Workflows/JoinAnyForkWorkflow.cs similarity index 85% rename from test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/JoinAnyForkWorkflow.cs rename to test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/Workflows/JoinAnyForkWorkflow.cs index 3005f8fb4..e37e90262 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/JoinAnyForkWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Fork/Workflows/JoinAnyForkWorkflow.cs @@ -1,7 +1,7 @@ using Elsa.Workflows.Activities; using Elsa.Workflows.Runtime.Activities; -namespace Elsa.Workflows.IntegrationTests.Activities; +namespace Elsa.Workflows.IntegrationTests.Activities.Workflows; public class JoinAnyForkWorkflow : WorkflowBase { @@ -20,7 +20,7 @@ public class JoinAnyForkWorkflow : WorkflowBase { Activities = { - new Event("Event 1") + new Runtime.Activities.Event("Event 1") { Id = "Event1" }, @@ -31,7 +31,7 @@ public class JoinAnyForkWorkflow : WorkflowBase { Activities = { - new Event("Event 2") + new Runtime.Activities.Event("Event 2") { Id = "Event2" }, @@ -42,7 +42,7 @@ public class JoinAnyForkWorkflow : WorkflowBase { Activities = { - new Event("Event 3") + new Runtime.Activities.Event("Event 3") { Id = "Event3" }, @@ -56,4 +56,4 @@ public class JoinAnyForkWorkflow : WorkflowBase } }; } -} \ No newline at end of file +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/NestedSequentialWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/NestedSequentialWorkflow.cs deleted file mode 100644 index bee046f52..000000000 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/NestedSequentialWorkflow.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Elsa.Workflows.Activities; - -namespace Elsa.Workflows.IntegrationTests.Activities; - -class NestedSequentialWorkflow : WorkflowBase -{ - protected override void Build(IWorkflowBuilder workflow) - { - workflow.Root = new Sequence - { - Activities = - { - new WriteLine("Start"), - new Sequence - { - Activities = - { - new WriteLine("Line 1"), - new WriteLine("Line 2"), - new WriteLine("Line 3") - } - }, - new WriteLine("End"), - } - }; - } -} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/SequenceTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/SequenceTests.cs index 8412e7342..047601ab0 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/SequenceTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/SequenceTests.cs @@ -1,38 +1,131 @@ using Elsa.Testing.Shared; -using Microsoft.Extensions.DependencyInjection; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; using Xunit.Abstractions; namespace Elsa.Workflows.IntegrationTests.Activities; -public class SequenceTests +/// +/// Integration tests for the activity. +/// +public class SequenceTests(ITestOutputHelper testOutputHelper) { - private readonly IWorkflowRunner _workflowRunner; - private readonly CapturingTextWriter _capturingTextWriter = new(); - private readonly IServiceProvider _services; + private readonly WorkflowTestFixture _fixture = new(testOutputHelper); - public SequenceTests(ITestOutputHelper testOutputHelper) + [Fact(DisplayName = "Sequence executes all activities in order")] + public async Task Sequence_ExecutesAllActivitiesInOrder() { - _services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); - _workflowRunner = _services.GetRequiredService(); + // Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new SimpleSequenceWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(new[] { "Activity 1", "Activity 2", "Activity 3" }, lines); + } + + [Fact(DisplayName = "Sequence with Break stops execution")] + public async Task Sequence_WithBreak_StopsExecution() + { + // Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new SequenceWithBreakWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(new[] { "Before Break" }, lines); + Assert.DoesNotContain("After Break (should not execute)", lines); + } + + [Fact(DisplayName = "Sequence with conditional Break stops when condition is met")] + public async Task Sequence_WithConditionalBreak_StopsWhenConditionMet() + { + // Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new SequenceWithConditionalBreakWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(new[] { "Iteration 1", "Iteration 2" }, lines); + Assert.DoesNotContain("Iteration 3 (should not execute)", lines); + } + + [Fact(DisplayName = "Nested sequences with Break propagates to outer sequence")] + public async Task NestedSequences_WithBreak_PropagatesToOuterSequence() + { + // Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new NestedSequencesWithBreakWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(new[] + { + "Outer: Start", + "Inner: Activity 1" + }, lines); + Assert.DoesNotContain("Inner: Activity 2 (should not execute)", lines); + Assert.DoesNotContain("Outer: After inner sequence", lines); + Assert.DoesNotContain("Outer: End", lines); + } + + [Fact(DisplayName = "Sequence with Complete terminal node stops workflow")] + public async Task Sequence_WithComplete_StopsWorkflow() + { + // Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new SequenceWithCompleteWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(new[] { "Before Complete" }, lines); + Assert.DoesNotContain("After Complete (should not execute)", lines); + } + + [Fact(DisplayName = "Sequence with variables makes them available to activities")] + public async Task Sequence_WithVariables_MakesThemAvailableToActivities() + { + // Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new SequenceWithVariablesWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(2, lines.Count); + Assert.Contains("Counter: 0, Name: Initial", lines[0]); + Assert.Contains("Counter: 10, Name: Updated", lines[1]); + } + + [Fact(DisplayName = "Empty sequence completes successfully")] + public async Task EmptySequence_CompletesSuccessfully() + { + // Act + var (result, lines) = await RunWorkflowAndCaptureOutput(new EmptySequenceWorkflow()); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Empty(lines); + } + + [Theory(DisplayName = "Sequence executes different numbers of activities")] + [InlineData(1)] + [InlineData(5)] + [InlineData(10)] + public async Task Sequence_ExecutesDifferentNumbersOfActivities(int activityCount) + { + // Arrange + var workflow = new DynamicSequenceWorkflow(activityCount); + + // Act + var (result, lines) = await RunWorkflowAndCaptureOutput(workflow); + + // Assert + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(activityCount, lines.Count); + for (var i = 0; i < activityCount; i++) + { + Assert.Equal($"Activity {i + 1}", lines[i]); + } } - [Fact(DisplayName = "Sequence completes after its child activities complete")] - public async Task Test1() + private async Task<(RunWorkflowResult Result, List Lines)> RunWorkflowAndCaptureOutput(IWorkflow workflow) { - await _services.PopulateRegistriesAsync(); - await _workflowRunner.RunAsync(new SequentialWorkflow()); - var lines = _capturingTextWriter.Lines.ToList(); - Assert.Equal(new[] { "Line 1", "Line 2", "Line 3" }, lines); + var result = await _fixture.RunWorkflowAsync(workflow); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + return (result, lines); } - - [Fact(DisplayName = "Sequence completes after its child sequence activity complete")] - public async Task Test2() - { - await _services.PopulateRegistriesAsync(); - await _workflowRunner.RunAsync(new NestedSequentialWorkflow()); - var lines = _capturingTextWriter.Lines.ToList(); - Assert.Equal(new[] { "Start", "Line 1", "Line 2", "Line 3", "End" }, lines); - } - - -} \ No newline at end of file +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/SequentialWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/SequentialWorkflow.cs deleted file mode 100644 index bd53acc59..000000000 --- a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/SequentialWorkflow.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Elsa.Workflows.Activities; - -namespace Elsa.Workflows.IntegrationTests.Activities; - -class SequentialWorkflow : WorkflowBase -{ - protected override void Build(IWorkflowBuilder workflow) - { - workflow.Root = new Sequence - { - Activities = - { - new WriteLine("Line 1"), - new WriteLine("Line 2"), - new WriteLine("Line 3") - } - }; - } -} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/Workflows.cs b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/Workflows.cs new file mode 100644 index 000000000..9cf357add --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Activities/Sequence/Workflows.cs @@ -0,0 +1,171 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; + +namespace Elsa.Workflows.IntegrationTests.Activities; + +/// +/// Workflow with Sequence that executes all activities. +/// +public class SimpleSequenceWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Activity 1"), + new WriteLine("Activity 2"), + new WriteLine("Activity 3") + } + }; + } +} + +/// +/// Workflow with Sequence containing a Break activity. +/// +public class SequenceWithBreakWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Before Break"), + new Break(), + new WriteLine("After Break (should not execute)") + } + }; + } +} + +/// +/// Workflow with Sequence where Break is conditional. +/// +public class SequenceWithConditionalBreakWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var counter = new Variable("Counter", 0); + + workflow.WithVariables(counter); + + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Iteration 1"), + new SetVariable { Variable = counter, Value = new(1) }, + new WriteLine("Iteration 2"), + new SetVariable { Variable = counter, Value = new(2) }, + new If(context => counter.Get(context) >= 2) + { + Then = new Break() + }, + new WriteLine("Iteration 3 (should not execute)") + } + }; + } +} + +/// +/// Workflow with nested Sequences and Break. +/// +public class NestedSequencesWithBreakWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Outer: Start"), + new Sequence + { + Activities = + { + new WriteLine("Inner: Activity 1"), + new Break(), + new WriteLine("Inner: Activity 2 (should not execute)") + } + }, + new WriteLine("Outer: After inner sequence"), + new WriteLine("Outer: End") + } + }; + } +} + +/// +/// Workflow with Sequence containing Complete activity (terminal node). +/// +public class SequenceWithCompleteWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new Sequence + { + Activities = + { + new WriteLine("Before Complete"), + new Complete(), + new WriteLine("After Complete (should not execute)") + } + }; + } +} + +/// +/// Workflow with Sequence and variables. +/// +public class SequenceWithVariablesWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var counter = new Variable("Counter", 0); + var name = new Variable("Name", "Initial"); + + workflow.Root = new Sequence + { + Variables = { counter, name }, + Activities = + { + new WriteLine(context => $"Counter: {counter.Get(context)}, Name: {name.Get(context)}"), + new SetVariable { Variable = counter, Value = new(10) }, + new SetVariable { Variable = name, Value = new("Updated") }, + new WriteLine(context => $"Counter: {counter.Get(context)}, Name: {name.Get(context)}") + } + }; + } +} + +/// +/// Workflow with empty Sequence. +/// +public class EmptySequenceWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + workflow.Root = new Sequence(); + } +} + +/// +/// Workflow with dynamic number of activities in sequence. +/// +public class DynamicSequenceWorkflow(int activityCount) : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var sequence = new Sequence(); + + for (var i = 1; i <= activityCount; i++) + { + sequence.Activities.Add(new WriteLine($"Activity {i}")); + } + + workflow.Root = sequence; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj index 0e68aac94..43f34a790 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj +++ b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj @@ -2,6 +2,8 @@ {ca65b7d5-57e8-4932-b8b8-732d040c9b3a} + [Elsa.Expressions.JavaScript]*,[Elsa.Http]*,[Elsa.Scheduling]*,[Elsa.Workflows.Runtime.Distributed]*,[Elsa.Workflows.Runtime]* + 5 @@ -9,7 +11,6 @@ - @@ -90,4 +91,5 @@ Always + diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs index 413e2fbca..f2a981b4f 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/EvaluationTestHelpers.cs @@ -1,6 +1,4 @@ using Elsa.Testing.Shared; -using Elsa.Workflows; -using Elsa.Workflows.Activities; namespace Elsa.Workflows.IntegrationTests.Evaluation; diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs index f44678614..42f0803f3 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Evaluation/InputEvaluationErrorTests.cs @@ -4,8 +4,6 @@ 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; diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/BlockingAndBreaking/Workflows/BreakWhileFromForkWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/BlockingAndBreaking/Workflows/BreakWhileFromForkWorkflow.cs index 80c77f707..8fb0457b1 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/BlockingAndBreaking/Workflows/BreakWhileFromForkWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/BlockingAndBreaking/Workflows/BreakWhileFromForkWorkflow.cs @@ -36,7 +36,7 @@ public class BreakWhileFromForkWorkflow : WorkflowBase new Event("Branch 1") { Id = "Branch 1" }, new WriteLine("Branch 1 - Resumed"), - // This should break the while loop, not matter how high up in the tree it is. + // This should break the while loop, no matter how high up in the tree it is. new Break() } }, diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs index 7a0fe6d1c..dd25a4f73 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs @@ -188,7 +188,7 @@ public class FlowchartNextActivityTests var result = await _workflowRunner.RunAsync(workflow); var lines = _capturingTextWriter.Lines.ToList(); Assert.Equal(WorkflowSubStatus.Faulted, result.WorkflowState.SubStatus); - Assert.Equal(1, result.WorkflowState.Incidents.Count()); + Assert.Single(result.WorkflowState.Incidents); Assert.Equal("Invalid backward connection: Every path from the source ('WriteLineE') must go through the target ('WriteLineC') when tracing back to the start.", result.WorkflowState.Incidents.First().Message); Assert.Equal(new[] { diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Incidents/IncidentStrategyTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Incidents/IncidentStrategyTests.cs index 9f82e7d03..ad8880b2c 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Incidents/IncidentStrategyTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Incidents/IncidentStrategyTests.cs @@ -30,6 +30,6 @@ public class IncidentStrategyTests var lines = _capturingTextWriter.Lines.ToList(); Assert.Equal(expectedOutput, lines); Assert.Equal(expectedSubStatus, workflowState.SubStatus); - Assert.Equal(1, workflowState.Incidents.Count); + Assert.Single(workflowState.Incidents); } } \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs index f3d678175..34ad36849 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/ForkDecisionJoinTests.cs @@ -13,25 +13,31 @@ public class ForkDecisionJoinTests _services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); } - [Fact(DisplayName = "The implicit join configured with None merge mode should execute.")] - public async Task ImplicitJoinNoneShouldExecute() + [Fact(DisplayName = "The implicit join configured with Stream merge mode should execute.")] + public async Task ImplicitJoinStreamShouldExecute() { await RunAndAssert("fork-decision-join-none.json", ["A", "C"]); } - - [Fact(DisplayName = "The implicit join configured with Converge merge mode should not execute.")] - public async Task ImplicitJoinConvergeShouldNotExecute() + + [Fact(DisplayName = "The implicit join configured with Merge mode should not execute (waits for activated branches only).")] + public async Task ImplicitJoinMergeShouldNotExecute() { await RunAndAssert("fork-decision-join-converge.json", ["A"]); } - + + [Fact(DisplayName = "The implicit join configured with Converge mode should block (strictest - requires ALL inbound connections).")] + public async Task ImplicitJoinConvergeShouldBlock() + { + await RunAndAssert("fork-decision-join-converge-strict.json", ["A"]); + } + [Fact(DisplayName = "The explicit join configured with WaitAll join mode should block.")] public async Task ExplicitJoinWaitAllShouldBlock() { await RunAndAssert("fork-decision-join-waitall.json", ["A", "C", "B", "D"]); } - [Fact(DisplayName = "An implicit join from the True and False branches should execute because the join mode is None and by default, all active branches are joined.")] + [Fact(DisplayName = "An implicit join from the True and False branches should execute because the join mode is Stream and by default, all active branches are joined.")] public async Task ImplicitJoinFromBranchesShouldExecute() { // Populate registries. @@ -42,7 +48,7 @@ public class ForkDecisionJoinTests // Execute. var workflowState = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId); - + // Assert. Assert.Equal(WorkflowStatus.Finished, workflowState.Status); } diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs index b1d4ebdb7..36a60a563 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/JoinRunsOnceTests.cs @@ -1,7 +1,6 @@ using Elsa.Common.Models; using Elsa.Testing.Shared; using Elsa.Workflows.Runtime; -using Elsa.Workflows.Runtime.Filters; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs index 872283064..26f6fbeb7 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/BraidedWorkflow.cs @@ -1,6 +1,5 @@ using Elsa.Workflows.Activities; using Elsa.Workflows.Activities.Flowchart.Activities; -using Elsa.Workflows.Activities.Flowchart.Models; namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows; diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge-strict.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge-strict.json new file mode 100644 index 000000000..40ca9c9f2 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge-strict.json @@ -0,0 +1,184 @@ +{ + "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json", + "id": "strict-converge-test", + "definitionId": "strict-converge-001", + "name": "Fork-Decision-Converge-Strict", + "description": "Tests the strictest Converge mode that requires ALL inbound connections to execute", + "createdAt": "2025-10-20T00:00:00.000000+00:00", + "version": 1, + "toolVersion": "3.6.0.0", + "variables": [], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isSystem": false, + "isLatest": true, + "isPublished": true, + "options": { + "autoUpdateConsumingWorkflows": false + }, + "root": { + "id": "flowchart-root", + "nodeId": "Workflow1:flowchart-root", + "name": "Flowchart1", + "type": "Elsa.Flowchart", + "version": 1, + "customProperties": { + "notFoundConnections": [], + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {}, + "activities": [ + { + "condition": { + "typeName": "Boolean", + "expression": { + "type": "JavaScript", + "value": "return false;" + } + }, + "id": "decision-1", + "nodeId": "Workflow1:flowchart-root:decision-1", + "name": "FlowDecision1", + "type": "Elsa.FlowDecision", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + }, + { + "id": "start-1", + "nodeId": "Workflow1:flowchart-root:start-1", + "name": "Start1", + "type": "Elsa.Start", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "A" + } + }, + "id": "writeline-a", + "nodeId": "Workflow1:flowchart-root:writeline-a", + "name": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "B" + } + }, + "id": "writeline-b", + "nodeId": "Workflow1:flowchart-root:writeline-b", + "name": "WriteLine2", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + }, + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "C" + } + }, + "id": "writeline-c", + "nodeId": "Workflow1:flowchart-root:writeline-c", + "name": "WriteLine3", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false, + "mergeMode": "Converge" + }, + "metadata": {} + } + ], + "variables": [], + "connections": [ + { + "source": { + "activity": "writeline-a", + "port": "Done" + }, + "target": { + "activity": "writeline-c", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "writeline-b", + "port": "Done" + }, + "target": { + "activity": "writeline-c", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "start-1", + "port": "Done" + }, + "target": { + "activity": "writeline-a", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "start-1", + "port": "Done" + }, + "target": { + "activity": "decision-1", + "port": "In" + }, + "vertices": [] + }, + { + "source": { + "activity": "decision-1", + "port": "True" + }, + "target": { + "activity": "writeline-b", + "port": "In" + }, + "vertices": [] + } + ] + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge.json index 75dc547a4..8bc40e337 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge.json +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-converge.json @@ -174,7 +174,7 @@ "customProperties": { "canStartWorkflow": false, "runAsynchronously": false, - "mergeMode": "Converge" + "mergeMode": "Merge" }, "metadata": { "designer": { diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-none.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-none.json index 9aee4d16d..ddb3db562 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-none.json +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JoinBehaviors/Workflows/fork-decision-join-none.json @@ -174,7 +174,7 @@ "customProperties": { "canStartWorkflow": false, "runAsynchronously": false, - "mergeMode": "None" + "mergeMode": "Stream" }, "metadata": { "designer": { diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Serialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Serialization/Tests.cs index 786c7a8ba..9c44a3efe 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Serialization/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Serialization/Tests.cs @@ -24,8 +24,8 @@ public class Tests public async Task Test1() { await _services.PopulateRegistriesAsync(); - var workflowDefinition = _publisher.New(); - var root = _serializer.Deserialize(workflowDefinition.StringData); + var workflowDefinition = await _publisher.NewAsync(); + var root = _serializer.Deserialize(workflowDefinition.StringData!); Assert.NotNull(root); } } \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Tests.cs new file mode 100644 index 000000000..0720b0cbd --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Tests.cs @@ -0,0 +1,28 @@ +using Elsa.Testing.Shared; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.SetGetVariablesArray; + +public class Tests +{ + private readonly IWorkflowRunner _workflowRunner; + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _services; + + public Tests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); + _services.GetRequiredService(); + _workflowRunner = _services.GetRequiredService(); + } + + [Fact(DisplayName = "Workflow can set variable")] + public async Task Test1() + { + await _services.PopulateRegistriesAsync(); + await _workflowRunner.RunAsync(); + var lines = _capturingTextWriter.Lines.ToList(); + Assert.Equal(new[] { "Line 1", "Line 2" }, lines); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Workflows.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Workflows.cs new file mode 100644 index 000000000..2caa4d735 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Workflows.cs @@ -0,0 +1,33 @@ +using Elsa.Extensions; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.SetGetVariablesArray; + +class SetGetVariableArrayWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + var variable1 = new Variable("Variable1", []); + var currentValueVariable = new Variable("CurrentValue", null!); + + workflow.Root = new Sequence + { + Variables = + { + variable1 + }, + + Activities = + { + new SetVariable(variable1, ["Line 1", "Line 2"]), + new ForEach(new Input>(variable1)) + { + CurrentValue = new Output(currentValueVariable), + Body = new WriteLine(currentValueVariable) + } + } + }; + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTestWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTestWorkflow.cs new file mode 100644 index 000000000..1bda08cf9 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTestWorkflow.cs @@ -0,0 +1,209 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management.Activities.SetOutput; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.SmokeTests; + +/// +/// Comprehensive smoke test workflow that exercises basic control flow and data manipulation activities. +/// Tests: Start, Sequence, Break, Complete, If, Switch, While, For, ForEach, +/// WriteLine, SetName, SetVariable<T>, SetVariable (untyped), SetOutput +/// +public class ActivitiesSmokeTestWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder workflow) + { + // Variables for testing + var counter = new Variable("Counter", 0); + var name = new Variable("Name", "Initial"); + var result = new Variable("Result", ""); + var items = new Variable>("Items", ["A", "B", "C"]); + var currentItem = new Variable("CurrentItem", ""); + var loopCounter = new Variable("LoopCounter", 0); + var untypedVar = new Variable("UntypedVar", null); + var switchValue = new Variable("SwitchValue", 2); + + workflow.WithVariables(counter, name, result, items, currentItem, loopCounter, untypedVar, switchValue); + + workflow.Root = new Sequence + { + Activities = + { + // Test Start activity + new Start(), + + // Test SetName + new SetName + { + Value = new("SmokeTestWorkflow") + }, + + // Test WriteLine + new WriteLine(context => "=== Smoke Test Started ==="), + + // Test SetVariable + new SetVariable + { + Variable = name, + Value = new("Updated Name") + }, + new WriteLine(context => $"Name: {name.Get(context)}"), + + // Test SetVariable (untyped) + new SetVariable + { + Variable = untypedVar, + Value = new("Untyped value") + }, + new WriteLine(context => $"Untyped: {untypedVar.Get(context)}"), + + // Test If activity (condition true) + new If(() => true) + { + Then = new Sequence + { + Activities = + { + new WriteLine("If branch: True path executed"), + new SetVariable { Variable = counter, Value = new(10) } + } + }, + Else = new WriteLine("If branch: False path (should not execute)") + }, + + // Test Switch activity + new Switch + { + Cases = + { + new( + "Case1", + context => ValueTask.FromResult(switchValue.Get(context) == 1), + new WriteLine("Switch: Case 1 (should not execute)") + ), + new( + "Case2", + context => ValueTask.FromResult(switchValue.Get(context) == 2), + new Sequence + { + Activities = + { + new WriteLine("Switch: Case 2 executed"), + new SetVariable { Variable = result, Value = new("Switch-2") } + } + } + ), + new( + "Case3", + context => ValueTask.FromResult(switchValue.Get(context) == 3), + new WriteLine("Switch: Case 3 (should not execute)") + ) + }, + Default = new WriteLine("Switch: Default (should not execute)") + }, + + // Test For loop with Break + new Sequence + { + Activities = + { + new WriteLine("For loop: Starting"), + new For + { + Start = new(0), + End = new(100), + Step = new(1), + Body = new Sequence + { + Activities = + { + new SetVariable { Variable = loopCounter, Value = new(context => loopCounter.Get(context) + 1) }, + // Break after 3 iterations + new If(context => loopCounter.Get(context) >= 3) + { + Then = new Break() + } + } + } + }, + new WriteLine(context => $"For loop: Completed with {loopCounter.Get(context)} iterations") + } + }, + + // Test While loop with Break + new Sequence + { + Activities = + { + new SetVariable { Variable = loopCounter, Value = new(0) }, + new WriteLine("While loop: Starting"), + new While(() => true) + { + Body = new Sequence + { + Activities = + { + new SetVariable { Variable = loopCounter, Value = new(context => loopCounter.Get(context) + 1) }, + new WriteLine(context => $"While loop: Iteration {loopCounter.Get(context)}"), + // Break after 3 iterations + new If(context => loopCounter.Get(context) >= 3) + { + Then = new Break() + } + } + } + }, + new WriteLine(context => $"While loop: Completed with {loopCounter.Get(context)} iterations") + } + }, + + // Test ForEach with Break + new Sequence + { + Activities = + { + new SetVariable { Variable = loopCounter, Value = new(0) }, + new WriteLine("ForEach loop: Starting"), + new ForEach + { + Items = new(items), + CurrentValue = new(currentItem), + Body = new Sequence + { + Activities = + { + new SetVariable { Variable = loopCounter, Value = new(context => loopCounter.Get(context) + 1) }, + new WriteLine(context => $"ForEach: Item '{currentItem.Get(context)}'"), + // Break after processing 2 items (A, B) + new If(context => loopCounter.Get(context) >= 2) + { + Then = new Break() + } + } + } + }, + new WriteLine(context => $"ForEach loop: Completed with {loopCounter.Get(context)} items processed") + } + }, + + // Test SetOutput + new SetOutput + { + OutputName = new("FinalResult"), + OutputValue = new(context => $"Counter={counter.Get(context)}, Name={name.Get(context)}, Result={result.Get(context)}") + }, + + // Test Finish and End activities + new Finish(), + new End(), + + // Test Complete activity (ends workflow immediately) + new Complete(), + + // This should not execute due to Complete + new WriteLine("After Complete (should not execute)") + } + }; + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTests.cs new file mode 100644 index 000000000..16d27936a --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTests.cs @@ -0,0 +1,109 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Models; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.SmokeTests; + +/// +/// Smoke tests that verify basic functionality of core workflow activities. +/// +public class ActivitiesSmokeTests(ITestOutputHelper testOutputHelper) +{ + private readonly WorkflowTestFixture _fixture = new(testOutputHelper); + + [Fact(DisplayName = "Smoke test executes all core activities successfully")] + public async Task SmokeTest_ExecutesAllActivities_Successfully() + { + // Act + var (result, _) = await RunWorkflowAndCaptureOutput(); + + // Assert - Workflow completed successfully + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); + + // Verify outputs were set correctly + var outputs = result.WorkflowState.Output; + Assert.NotNull(outputs); + Assert.True(outputs.TryGetValue("FinalResult", out var finalResultObj)); + + var finalResult = finalResultObj?.ToString(); + Assert.NotNull(finalResult); + Assert.Contains("Counter=10", finalResult); + Assert.Contains("Name=Updated Name", finalResult); + Assert.Contains("Result=Switch-2", finalResult); + } + + [Fact(DisplayName = "Smoke test verifies all activities were executed")] + public async Task SmokeTest_VerifiesActivityExecution() + { + // Act + var (_, lines) = await RunWorkflowAndCaptureOutput(); + + // Assert - Verify key activities executed by checking WriteLine outputs + Assert.Contains(lines, line => line.Contains("=== Smoke Test Started ===")); + Assert.Contains(lines, line => line.Contains("Name: Updated Name")); + Assert.Contains(lines, line => line.Contains("Untyped: Untyped value")); + Assert.Contains(lines, line => line.Contains("If branch: True path executed")); + Assert.Contains(lines, line => line.Contains("Switch: Case 2 executed")); + Assert.Contains(lines, line => line.Contains("For loop: Completed with 3 iterations")); + Assert.Contains(lines, line => line.Contains("While loop: Completed with 3 iterations")); + Assert.Contains(lines, line => line.Contains("ForEach loop: Completed with 2 items processed")); + + // Verify activities after Complete did NOT execute + Assert.DoesNotContain(lines, line => line.Contains("After Complete (should not execute)")); + } + + [Fact(DisplayName = "Break activity works correctly in different loop contexts")] + public async Task SmokeTest_BreakActivity_WorksInDifferentLoops() + { + // Act + var (_, lines) = await RunWorkflowAndCaptureOutput(); + + // Assert - Verify Break worked in For loop (stopped at 3 iterations, not 100) + Assert.Contains(lines, line => line.Contains("For loop: Completed with 3 iterations")); + + // Verify Break worked in While loop (stopped at 3 iterations, didn't run infinitely) + Assert.Contains(lines, line => line.Contains("While loop: Iteration 1")); + Assert.Contains(lines, line => line.Contains("While loop: Iteration 2")); + Assert.Contains(lines, line => line.Contains("While loop: Iteration 3")); + Assert.Contains(lines, line => line.Contains("While loop: Completed with 3 iterations")); + + // Verify Break worked in ForEach (stopped after 2 items: A, B, not C) + Assert.Contains(lines, line => line.Contains("ForEach: Item 'A'")); + Assert.Contains(lines, line => line.Contains("ForEach: Item 'B'")); + Assert.DoesNotContain(lines, line => line.Contains("ForEach: Item 'C'")); + Assert.Contains(lines, line => line.Contains("ForEach loop: Completed with 2 items processed")); + } + + [Fact(DisplayName = "Switch activity executes correct case")] + public async Task SmokeTest_SwitchActivity_ExecutesCorrectCase() + { + // Act + var (_, lines) = await RunWorkflowAndCaptureOutput(); + + // Assert - Only Case 2 should execute + Assert.DoesNotContain(lines, line => line.Contains("Switch: Case 1 (should not execute)")); + Assert.Contains(lines, line => line.Contains("Switch: Case 2 executed")); + Assert.DoesNotContain(lines, line => line.Contains("Switch: Case 3 (should not execute)")); + Assert.DoesNotContain(lines, line => line.Contains("Switch: Default (should not execute)")); + } + + [Fact(DisplayName = "If activity executes correct branch")] + public async Task SmokeTest_IfActivity_ExecutesCorrectBranch() + { + // Act + var (_, lines) = await RunWorkflowAndCaptureOutput(); + + // Assert - Only Then branch should execute + Assert.Contains(lines, line => line.Contains("If branch: True path executed")); + Assert.DoesNotContain(lines, line => line.Contains("If branch: False path (should not execute)")); + } + + private async Task<(RunWorkflowResult Result, List Lines)> RunWorkflowAndCaptureOutput() + { + var workflow = new ActivitiesSmokeTestWorkflow(); + var result = await _fixture.RunWorkflowAsync(workflow); + var lines = _fixture.CapturingTextWriter.Lines.ToList(); + return (result, lines); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/FailingMaterializer.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/FailingMaterializer.cs new file mode 100644 index 000000000..b977e6b4e --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/FailingMaterializer.cs @@ -0,0 +1,18 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.Fakes; + +/// +/// A custom materializer that throws an exception when materialization is attempted. +/// This simulates the "Provider not found" scenario. +/// +public class FailingMaterializer : IWorkflowMaterializer +{ + public const string MaterializerName = "FailingMaterializer"; + public string Name => MaterializerName; + + public ValueTask MaterializeAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) => + ValueTask.FromException(new("Provider not found")); +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/WorkingMaterializer.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/WorkingMaterializer.cs new file mode 100644 index 000000000..7761c2c0e --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/WorkingMaterializer.cs @@ -0,0 +1,22 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.Fakes; + +/// +/// A custom materializer that successfully materializes workflows from a registry. +/// +public class WorkingMaterializer(Dictionary workflowRegistry) : IWorkflowMaterializer +{ + public const string MaterializerName = "WorkingMaterializer"; + public string Name => MaterializerName; + + public ValueTask MaterializeAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) + { + if (workflowRegistry.TryGetValue(definition.Id, out var workflow)) + return ValueTask.FromResult(workflow); + + throw new InvalidOperationException($"Workflow {definition.Id} not found in registry"); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerDeletionTestScenario.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerDeletionTestScenario.cs new file mode 100644 index 000000000..0dea2fc55 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerDeletionTestScenario.cs @@ -0,0 +1,13 @@ +namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.TestData; + +/// +/// Represents a test scenario for trigger deletion with expected outcomes. +/// +public record TriggerDeletionTestScenario +{ + public required string DisplayName { get; init; } + public required WorkflowConfig[] Workflows { get; init; } + public required string[] ExpectedRemainingTriggerIds { get; init; } + + public override string ToString() => DisplayName; +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerTestDataBuilder.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerTestDataBuilder.cs new file mode 100644 index 000000000..594ffb333 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerTestDataBuilder.cs @@ -0,0 +1,57 @@ +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Runtime.Entities; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.TestData; + +/// +/// Fluent builder for constructing test data with workflows and triggers. +/// +public class TriggerTestDataBuilder +{ + private readonly List<(string workflowId, string definitionId, string materializerName)> _workflows = []; + + public TriggerTestDataBuilder AddWorkflow(string workflowId, string definitionId, string materializerName) + { + _workflows.Add((workflowId, definitionId, materializerName)); + return this; + } + + public (WorkflowDefinition[] Workflows, StoredTrigger[] Triggers) Build() + { + var workflows = _workflows + .Select(config => CreateWorkflowDefinition(config.workflowId, config.definitionId, config.materializerName)) + .ToArray(); + + var triggers = _workflows + .Select((config, index) => CreateTrigger($"trigger{index + 1}", config.definitionId, config.workflowId, $"act{index + 1}")) + .ToArray(); + + return (workflows, triggers); + } + + private static WorkflowDefinition CreateWorkflowDefinition(string id, string definitionId, string materializerName) + { + return new() + { + Id = id, + DefinitionId = definitionId, + Version = 1, + IsPublished = true, + IsLatest = true, + MaterializerName = materializerName, + StringData = "{\"root\":{\"type\":\"Elsa.Sequence\",\"id\":\"seq1\",\"version\":1,\"activities\":[]}}" + }; + } + + private static StoredTrigger CreateTrigger(string id, string definitionId, string versionId, string activityId) + { + return new() + { + Id = id, + WorkflowDefinitionId = definitionId, + WorkflowDefinitionVersionId = versionId, + Name = "TestTrigger", + ActivityId = activityId + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/WorkflowConfig.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/WorkflowConfig.cs new file mode 100644 index 000000000..e43148fbe --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/WorkflowConfig.cs @@ -0,0 +1,6 @@ +namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.TestData; + +/// +/// Configuration for a workflow in a test scenario. +/// +public record WorkflowConfig(string Id, string DefinitionId, string MaterializerName, bool ShouldSucceed); \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs new file mode 100644 index 000000000..5bd8b05ef --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs @@ -0,0 +1,140 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.Fakes; +using Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.TestData; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Entities; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing; + +public class Tests +{ + private readonly IServiceProvider _services; + private readonly ITriggerIndexer _triggerIndexer; + private readonly ITriggerStore _triggerStore; + private readonly IWorkflowDefinitionStore _workflowDefinitionStore; + private readonly Dictionary _workflowRegistry = new(); + + public Tests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper).ConfigureServices(services => + { + services.AddSingleton(); + services.AddSingleton(_ => new WorkingMaterializer(_workflowRegistry)); + }) + .Build(); + + _triggerIndexer = _services.GetRequiredService(); + _triggerStore = _services.GetRequiredService(); + _workflowDefinitionStore = _services.GetRequiredService(); + } + + [Theory(DisplayName = "DeleteTriggersAsync handles workflow materialization failures correctly")] + [MemberData(nameof(GetTriggerDeletionTestData))] + public async Task DeleteTriggersAsync_HandlesWorkflowFailures(TriggerDeletionTestScenario scenario) + { + // Arrange + var builder = new TriggerTestDataBuilder(); + + foreach (var workflow in scenario.Workflows) + { + builder.AddWorkflow(workflow.Id, workflow.DefinitionId, workflow.MaterializerName); + + // Register working workflows in the registry + if (workflow.ShouldSucceed) + RegisterWorkflowInRegistry(workflow.Id, workflow.DefinitionId); + } + + var testData = builder.Build(); + await SetupTestScenarioAsync(testData); + + // Act + await DeleteTriggersAsync(); + + // Assert + await AssertRemainingTriggers(scenario.ExpectedRemainingTriggerIds); + } + + public static IEnumerable GetTriggerDeletionTestData() + { + // Scenario 1: All workflows fail to load - all triggers should remain + yield return + [ + new TriggerDeletionTestScenario + { + DisplayName = "All workflows fail to load", + Workflows = + [ + new("workflow1", "def1", "Json", ShouldSucceed: false), + new("workflow2", "def2", FailingMaterializer.MaterializerName, ShouldSucceed: false), + new("workflow3", "def3", "Json", ShouldSucceed: false) + ], + ExpectedRemainingTriggerIds = ["trigger1", "trigger2", "trigger3"] + } + ]; + + // Scenario 2: One workflow fails, others succeed - only the failing workflow's trigger remains + yield return + [ + new TriggerDeletionTestScenario + { + DisplayName = "One workflow fails, others succeed", + Workflows = + [ + new("workflow1", "def1", WorkingMaterializer.MaterializerName, ShouldSucceed: true), + new("workflow2", "def2", FailingMaterializer.MaterializerName, ShouldSucceed: false), + new("workflow3", "def3", WorkingMaterializer.MaterializerName, ShouldSucceed: true) + ], + ExpectedRemainingTriggerIds = ["trigger2"] + } + ]; + } + + private async Task AssertRemainingTriggers(string[] expectedTriggerIds) + { + var remainingTriggerIds = await GetRemainingTriggerIdsAsync(); + Assert.Equal(expectedTriggerIds.OrderBy(id => id), remainingTriggerIds); + } + + private async Task SaveWorkflowsAndTriggersAsync(WorkflowDefinition[] workflows, StoredTrigger[] triggers) + { + foreach (var workflow in workflows) + await _workflowDefinitionStore.SaveAsync(workflow); + + foreach (var trigger in triggers) + await _triggerStore.SaveAsync(trigger); + + var allTriggers = await _triggerStore.FindManyAsync(new()); + Assert.Equal(triggers.Length, allTriggers.Count()); + } + + + private void RegisterWorkflowInRegistry(string workflowId, string definitionId) + { + _workflowRegistry[workflowId] = new() + { + Identity = new(definitionId, 1, workflowId) + }; + } + + private async Task SetupTestScenarioAsync((WorkflowDefinition[] Workflows, StoredTrigger[] Triggers) testData) + { + await _services.PopulateRegistriesAsync(); + await SaveWorkflowsAndTriggersAsync(testData.Workflows, testData.Triggers); + } + + private async Task DeleteTriggersAsync() + { + await _triggerIndexer.DeleteTriggersAsync(new()); + } + + private async Task GetRemainingTriggerIdsAsync() + { + var remainingTriggers = await _triggerStore.FindManyAsync(new()); + return remainingTriggers.Select(t => t.Id).OrderBy(id => id).ToArray(); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs index 1dc95db61..bfa3909ed 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs @@ -1,7 +1,5 @@ using Elsa.Workflows.Activities; -using Elsa.Workflows.Models; using Elsa.Workflows.Runtime; -using Elsa.Workflows.Runtime.Activities; namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionStorePopulation; @@ -14,8 +12,7 @@ public class InMemoryWorkflowsProvider(Workflow workflow) : IWorkflowsProvider var materializedWorkflow = new MaterializedWorkflow( Workflow: workflow, ProviderName: "InMemory", - MaterializerName: "InMemory", - MaterializerContext: null + MaterializerName: "InMemory" ); return new([materializedWorkflow]); diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs index e3f2c9716..ae7af0a84 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs @@ -79,8 +79,8 @@ public class Tests new(start, writeLine), new(writeLine, end), }, + RunAsynchronously = false }; - container.RunAsynchronously = false; // Act @@ -93,12 +93,11 @@ public class Tests } [Fact] - public async void SerializeSequenceContainerTest() + public async Task SerializeSequenceContainerTest() { await _services.PopulateRegistriesAsync(); // Arrange - var container = new Sequence { Id = "sequence", @@ -127,9 +126,9 @@ public class Tests { "int", 10 }, { "bool", false }, { "string", "str"}, - } + }, + RunAsynchronously = false }; - container.RunAsynchronously = false; // Act @@ -142,12 +141,11 @@ public class Tests } [Fact] - public async void SerializeParallelContainerTest() + public async Task SerializeParallelContainerTest() { await _services.PopulateRegistriesAsync(); // Arrange - var container = new Workflows.Activities.Parallel { Id = "parallel", @@ -177,9 +175,9 @@ public class Tests { "int", 10 }, { "bool", false }, { "string", "str"}, - } + }, + RunAsynchronously = false }; - container.RunAsynchronously = false; // Act @@ -199,10 +197,16 @@ public class Tests // Assert.Equivalent has trouble with the Behavior.Owner reference - since these aren't serialzied anyway, ignore them deserializedContainer.Behaviors.Clear(); container.Behaviors.Clear(); - foreach (Activity activity in deserializedContainer.Activities) + foreach (var activity1 in deserializedContainer.Activities) + { + var activity = (Activity)activity1; activity.Behaviors.Clear(); - foreach (Activity activity in container.Activities) + } + + foreach (var activity in container.Activities.Cast()) + { activity.Behaviors.Clear(); + } // strict:false here allows "actual" to have extra public members that aren't part of "expected", and collection // comparison allows "actual" to have more data in it than is present in "expected". diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs index f4a853f28..e8e6343a9 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs @@ -158,7 +158,7 @@ public class SerializationTests(ITestOutputHelper testOutputHelper) "StatusCode", "Created" }, { - "Content", isArray ? (type == typeof(JArray) ? JArray.Parse(jsonContent) : JsonArray.Parse(jsonContent)) : (type == typeof(JObject) ? JObject.Parse(jsonContent) : JsonObject.Parse(jsonContent)) + "Content", (isArray ? type == typeof(JArray) ? JArray.Parse(jsonContent) : JsonNode.Parse(jsonContent) : type == typeof(JObject) ? JObject.Parse(jsonContent) : JsonNode.Parse(jsonContent))! } }; return dict; diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/UIHintSerializiation/ActivityUIHintDescriberTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/UIHintSerializiation/ActivityUIHintDescriberTests.cs index 284d665c8..efcdc8e39 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/UIHintSerializiation/ActivityUIHintDescriberTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/UIHintSerializiation/ActivityUIHintDescriberTests.cs @@ -45,11 +45,11 @@ public class Tests var description = await activityDescriber.DescribeActivityAsync(typeof(TestActivity)); var inputDescription = description.Inputs.First(); - Assert.True(inputDescription.UISpecifications.ContainsKey(InputUIHints.DropDown)); + Assert.True(inputDescription.UISpecifications!.ContainsKey(InputUIHints.DropDown)); Assert.True(inputDescription.UISpecifications[InputUIHints.DropDown] is DropDownProps); var dropDownProperties = (DropDownProps) inputDescription.UISpecifications[InputUIHints.DropDown]; - Assert.Collection(dropDownProperties.SelectList.Items, + Assert.Collection(dropDownProperties.SelectList!.Items, item => { Assert.Equal("OptionsAreNice", item.Text); Assert.Equal("OptionsAreNice", item.Value); }, item => { Assert.Equal("ToHave", item.Text); Assert.Equal("ToHave", item.Value); }, item => { Assert.Equal("IfYouCanChooseThem", item.Text); Assert.Equal("IfYouCanChooseThem", item.Value); }); diff --git a/test/performance/Directory.Build.props b/test/performance/Directory.Build.props index 674d5ca9b..9565fdad1 100644 --- a/test/performance/Directory.Build.props +++ b/test/performance/Directory.Build.props @@ -1,7 +1,37 @@ - - - - + + + + + enable + enable + false + Exe + false + + false + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/performance/Elsa.Workflows.PerformanceTests/ConsoleActivitiesBenchmark.cs b/test/performance/Elsa.Workflows.PerformanceTests/ConsoleActivitiesBenchmark.cs index 9f214cf1b..f8d6041fa 100644 --- a/test/performance/Elsa.Workflows.PerformanceTests/ConsoleActivitiesBenchmark.cs +++ b/test/performance/Elsa.Workflows.PerformanceTests/ConsoleActivitiesBenchmark.cs @@ -8,9 +8,9 @@ namespace Elsa.Workflows.PerformanceTests; [Config(typeof(Config))] public class ConsoleActivitiesBenchmark { - private WriteLine _writeLineWorkflow; - private IWorkflowRunner _workflowRunner; - private ServiceProvider _serviceProvider; + private WriteLine _writeLineWorkflow = null!; + private IWorkflowRunner _workflowRunner = null!; + private ServiceProvider _serviceProvider = null!; [GlobalSetup] public void GlobalSetup() @@ -21,7 +21,7 @@ public class ConsoleActivitiesBenchmark _serviceProvider = services.BuildServiceProvider(); _workflowRunner = _serviceProvider.GetRequiredService(); - _writeLineWorkflow = new WriteLine("Hello, World!"); + _writeLineWorkflow = new("Hello, World!"); } [Benchmark] diff --git a/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj b/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj index 244833891..9f0c772af 100644 --- a/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj +++ b/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj @@ -1,15 +1,5 @@ - - - net9.0 - enable - enable - - false - Exe - false - - + diff --git a/test/unit/Elsa.Activities.UnitTests/Branching/FlowDecisionTests.cs b/test/unit/Elsa.Activities.UnitTests/Branching/FlowDecisionTests.cs new file mode 100644 index 000000000..6c8ad5c30 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Branching/FlowDecisionTests.cs @@ -0,0 +1,74 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Activities; + +namespace Elsa.Activities.UnitTests.Branching; + +public class FlowDecisionTests +{ + [Theory(DisplayName = "FlowDecision produces correct outcome and completes successfully")] + [InlineData(true, "True")] + [InlineData(false, "False")] + public async Task Should_Produce_Correct_Outcome_And_Complete(bool condition, string expectedOutcome) + { + // Arrange + var flowDecision = new FlowDecision(ctx => condition); + + // Act + var context = await ExecuteAsync(flowDecision); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + Assert.True(context.HasOutcome(expectedOutcome)); + } + + [Fact(DisplayName = "FlowDecision defaults to False when no condition is set")] + public async Task Should_Default_To_False_When_No_Condition_Is_Set() + { + // Arrange + var flowDecision = new FlowDecision(); + + // Act + var context = await ExecuteAsync(flowDecision); + + // Assert + Assert.True(context.HasOutcome("False")); + } + + [Fact(DisplayName = "FlowDecision evaluates condition exactly once")] + public async Task Should_Evaluate_Condition_Exactly_Once() + { + // Arrange + var count = 0; + var flowDecision = new FlowDecision(ctx => { count++; return true; }); + + // Act + await ExecuteAsync(flowDecision); + + // Assert + Assert.Equal(1, count); + } + + [Fact(DisplayName = "FlowDecision uses latest captured state when evaluating condition")] + public async Task Should_Use_Latest_Captured_State_When_Evaluating_Condition() + { + // Arrange + var flag = false; + // ReSharper disable once AccessToModifiedClosure + var flowDecision = new FlowDecision(ctx => flag); + + // Mutate after construction, before execution + flag = true; + + // Act + var context = await ExecuteAsync(flowDecision); + + // Assert + Assert.True(context.HasOutcome("True")); + } + + private static Task ExecuteAsync(IActivity activity) + { + return new ActivityTestFixture(activity).ExecuteAsync(); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Composition/CompleteTests.cs b/test/unit/Elsa.Activities.UnitTests/Composition/CompleteTests.cs new file mode 100644 index 000000000..843e7fa65 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Composition/CompleteTests.cs @@ -0,0 +1,34 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; + +namespace Elsa.Activities.UnitTests.Composition; + +/// +/// Unit tests for the activity. +/// +public class CompleteTests +{ + [Fact(DisplayName = "Complete implements ITerminalNode interface")] + public void Complete_ImplementsITerminalNode() + { + // Arrange + var completeActivity = new Complete(); + + // Assert + Assert.IsAssignableFrom(completeActivity); + } + + [Fact(DisplayName = "Complete completes execution")] + public async Task Complete_CompletesExecution() + { + // Arrange + var completeActivity = new Complete(); + var fixture = new ActivityTestFixture(completeActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Composition/ContainerTests.cs b/test/unit/Elsa.Activities.UnitTests/Composition/ContainerTests.cs new file mode 100644 index 000000000..f0a145e10 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Composition/ContainerTests.cs @@ -0,0 +1,217 @@ +using Elsa.Testing.Shared; +using Elsa.Testing.Shared.Activities; +using Elsa.Workflows; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Composition; + +/// +/// Unit tests for the activity focusing on ExecuteAsync behavior. +/// +public class ContainerTests +{ + [Theory(DisplayName = "Container schedules child activities")] + [InlineData(1)] + [InlineData(3)] + [InlineData(5)] + public async Task Container_SchedulesChildActivities(int activityCount) + { + // Arrange + var activities = CreateActivities(activityCount); + var container = CreateContainerWithActivities(activities); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert - First activity should be scheduled (sequential scheduling) + Assert.True(context.HasScheduledActivity(activities[0])); + } + + [Fact(DisplayName = "Container with no activities completes without scheduling")] + public async Task Container_WithNoActivities_CompletesWithoutScheduling() + { + // Arrange + var container = new TestContainer(); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert + var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList(); + Assert.Empty(scheduledActivities); + } + + [Fact(DisplayName = "Container declares variables in memory")] + public async Task Container_DeclaresVariablesInMemory() + { + // Arrange + var variable1 = new Variable("Counter", 10); + var variable2 = new Variable("Name", "Test"); + var container = CreateContainerWithVariables(variable1, variable2); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert + var memory = context.ExpressionExecutionContext.Memory; + Assert.True(memory.HasBlock(variable1.Id)); + Assert.True(memory.HasBlock(variable2.Id)); + } + + [Fact(DisplayName = "Container auto-names unnamed variables")] + public async Task Container_AutoNamesUnnamedVariables() + { + // Arrange + var var1 = new Variable(); + var var2 = new Variable(); + var var3 = new Variable(); + var container = CreateContainerWithVariables(var1, var2, var3); + + // Act + await ExecuteContainerAsync(container); + + // Assert + Assert.Equal("Variable1", var1.Name); + Assert.Equal("Variable2", var2.Name); + Assert.Equal("Variable3", var3.Name); + } + + [Fact(DisplayName = "Container preserves named variables")] + public async Task Container_PreservesNamedVariables() + { + // Arrange + var namedVar = new Variable("MyVariable", "value"); + var unnamedVar = new Variable(); + var container = CreateContainerWithVariables(namedVar, unnamedVar); + + // Act + await ExecuteContainerAsync(container); + + // Assert + Assert.Equal("MyVariable", namedVar.Name); + Assert.Equal("Variable1", unnamedVar.Name); + } + + [Theory(DisplayName = "Container handles multiple unnamed variables correctly")] + [InlineData(1)] + [InlineData(5)] + [InlineData(10)] + public async Task Container_HandlesMultipleUnnamedVariables(int variableCount) + { + // Arrange + var variables = CreateUnnamedVariables(variableCount); + var container = CreateContainerWithVariables(variables.Cast().ToArray()); + + // Act + await ExecuteContainerAsync(container); + + // Assert + for (var i = 0; i < variableCount; i++) + { + Assert.Equal($"Variable{i + 1}", variables[i].Name); + } + } + + [Fact(DisplayName = "Container with mixed named and unnamed variables")] + public async Task Container_WithMixedNamedAndUnnamedVariables() + { + // Arrange + var namedVar1 = new Variable("FirstVar", 1); + var unnamedVar1 = new Variable(); + var namedVar2 = new Variable("SecondVar", "test"); + var unnamedVar2 = new Variable(); + var container = CreateContainerWithVariables(namedVar1, unnamedVar1, namedVar2, unnamedVar2); + + // Act + await ExecuteContainerAsync(container); + + // Assert + Assert.Equal("FirstVar", namedVar1.Name); + Assert.Equal("Variable1", unnamedVar1.Name); + Assert.Equal("SecondVar", namedVar2.Name); + Assert.Equal("Variable2", unnamedVar2.Name); + } + + [Fact(DisplayName = "Container schedules mixed activity types")] + public async Task Container_SchedulesMixedActivityTypes() + { + // 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 container = CreateContainerWithActivities(writeLine, setVariable, mockActivity); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert + Assert.True(context.HasScheduledActivity(writeLine)); + } + + [Fact(DisplayName = "Container with single activity schedules it")] + public async Task Container_WithSingleActivity_SchedulesIt() + { + // Arrange + var activity = new WriteLine("Single activity"); + var container = CreateContainerWithActivities(activity); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert + Assert.True(context.HasScheduledActivity(activity)); + } + + [Fact(DisplayName = "Container with variables and activities works correctly")] + public async Task Container_WithVariablesAndActivities_WorksCorrectly() + { + // Arrange + var variable = new Variable("Counter", 0); + var activity = new WriteLine("Activity"); + var container = new TestContainer(); + container.Variables.Add(variable); + container.Activities.Add(activity); + + // Act + var context = await ExecuteContainerAsync(container); + + // Assert + Assert.True(context.ExpressionExecutionContext.Memory.HasBlock(variable.Id)); + Assert.True(context.HasScheduledActivity(activity)); + } + + private static Task ExecuteContainerAsync(TestContainer container) => + new ActivityTestFixture(container).ExecuteAsync(); + + private static IActivity[] CreateActivities(int count) => + Enumerable.Range(1, count) + .Select(i => new WriteLine($"Activity {i}")) + .Cast() + .ToArray(); + + private static List> CreateUnnamedVariables(int count) => + Enumerable.Range(0, count) + .Select(_ => new Variable()) + .ToList(); + + private static TestContainer CreateContainerWithActivities(params IActivity[] activities) + { + var container = new TestContainer(); + foreach (var activity in activities) + { + container.Activities.Add(activity); + } + return container; + } + + private static TestContainer CreateContainerWithVariables(params Variable[] variables) + { + var container = new TestContainer(); + foreach (var variable in variables) + { + container.Variables.Add(variable); + } + return container; + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs b/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs index 1dd0a993c..c95c226a2 100644 --- a/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs @@ -50,7 +50,7 @@ public class ExecuteWorkflowTests opts.CorrelationId == DefaultCorrelationId && opts.Input != null && opts.Input.ContainsKey("Key1") && (string)opts.Input["Key1"] == "Value1" && opts.Input.ContainsKey("ParentInstanceId") && (string)opts.Input["ParentInstanceId"] == parentInstanceId && - opts.Properties.ContainsKey("ParentInstanceId") && (string)opts.Properties["ParentInstanceId"] == parentInstanceId && + opts.Properties!.ContainsKey("ParentInstanceId") && (string)opts.Properties["ParentInstanceId"] == parentInstanceId && (waitForCompletion ? opts.Properties.ContainsKey("WaitForCompletion") && (bool)opts.Properties["WaitForCompletion"] : !opts.Properties.ContainsKey("WaitForCompletion")) ), Arg.Any() @@ -226,7 +226,7 @@ public class ExecuteWorkflowTests { Status = status, SubStatus = subStatus, - Output = output + Output = output ?? new Dictionary() }; var workflowResult = new RunWorkflowResult(null!, workflowState, workflow, null, Journal.Empty); diff --git a/test/unit/Elsa.Activities.UnitTests/Console/ReadLineTests.cs b/test/unit/Elsa.Activities.UnitTests/Console/ReadLineTests.cs new file mode 100644 index 000000000..d89e925ae --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Console/ReadLineTests.cs @@ -0,0 +1,104 @@ +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Console; + +/// +/// Unit tests for the activity. +/// +public class ReadLineTests +{ + private readonly TextReader _mockTextReader; + private readonly IStandardInStreamProvider _mockProvider; + + public ReadLineTests() + { + _mockTextReader = Substitute.For(); + _mockProvider = Substitute.For(); + _mockProvider.GetTextReader().Returns(_mockTextReader); + } + + [Theory(DisplayName = "ReadLine reads text from input and completes successfully")] + [InlineData("Hello, World!")] + [InlineData("Test input")] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public async Task Should_Read_Text_From_Input_And_Complete(string inputText) + { + // Arrange + var readLine = new ReadLine(); + + // Act + var context = await ExecuteAsync(readLine, inputText); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + var result = (string)context.GetActivityOutput(() => readLine.Result)!; + Assert.Equal(inputText, result); + } + + [Fact(DisplayName = "ReadLine uses provided stream provider")] + public async Task Should_Use_Provided_Stream_Provider() + { + // Act + await ExecuteAsync(new ReadLine()); + + // Assert + _mockProvider.Received(1).GetTextReader(); + _mockTextReader.Received(1).ReadLine(); + } + + [Fact(DisplayName = "ReadLine handles null return value from stream")] + public async Task Should_Handle_Null_Return_Value() + { + // Arrange + var readLine = new ReadLine(); + + // Act + var context = await ExecuteAsync(readLine, null); + + // Assert - The null-forgiving operator in ReadLine implementation means it expects non-null, + // but null can occur at end of stream. Verify the behavior stores null. + var result = context.GetActivityOutput(() => readLine.Result); + Assert.Null(result); + } + + [Fact(DisplayName = "ReadLine uses default provider when none is registered")] + public async Task Should_Use_Default_Provider_When_None_Registered() + { + // Arrange + var readLine = new ReadLine(); + const string expectedInput = "Default provider test"; + var originalConsoleIn = System.Console.In; + + try + { + // Redirect Console.In to our StringReader + System.Console.SetIn(new StringReader(expectedInput)); + + // Act + var context = await new ActivityTestFixture(readLine).ExecuteAsync(); + + // Assert + var result = (string)context.GetActivityOutput(() => readLine.Result)!; + Assert.Equal(expectedInput, result); + } + finally + { + // Restore original Console.In + System.Console.SetIn(originalConsoleIn); + } + } + + private Task ExecuteAsync(ReadLine readLine, string? inputText = "Test") + { + _mockTextReader.ReadLine().Returns(inputText); + return new ActivityTestFixture(readLine) + .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 39ed0c815..9359c36ac 100644 --- a/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj +++ b/test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj @@ -1,5 +1,10 @@  + + [Elsa.Scheduling]*,[Elsa.Workflows.Core]*,[Elsa.Workflows.Management]*,[Elsa.Http]* + 6 + + diff --git a/test/unit/Elsa.Activities.UnitTests/Flow/EndTests.cs b/test/unit/Elsa.Activities.UnitTests/Flow/EndTests.cs new file mode 100644 index 000000000..bd2837b00 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Flow/EndTests.cs @@ -0,0 +1,35 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.UnitTests.Flow; + +/// +/// Unit tests for the activity. +/// +public class EndTests +{ + [Fact(DisplayName = "End implements ITerminalNode interface")] + public void End_ImplementsITerminalNode() + { + // Arrange + var endActivity = new End(); + + // Assert + Assert.IsAssignableFrom(endActivity); + } + + [Fact(DisplayName = "End completes execution")] + public async Task End_CompletesExecution() + { + // Arrange + var endActivity = new End(); + var fixture = new ActivityTestFixture(endActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Flow/ForkTests.cs b/test/unit/Elsa.Activities.UnitTests/Flow/ForkTests.cs index 21778bf96..1b9c53d77 100644 --- a/test/unit/Elsa.Activities.UnitTests/Flow/ForkTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Flow/ForkTests.cs @@ -1,20 +1,18 @@ using Elsa.Testing.Shared; using Elsa.Workflows; -using Elsa.Workflows.Signals; namespace Elsa.Activities.UnitTests.Flow; /// -/// Unit tests for the Fork activity covering various join modes, branching scenarios, and signal handling. +/// Unit tests for the activity. /// public class ForkTests { - [Fact] - public async Task SchedulesAllBranches() + [Fact(DisplayName = "Fork schedules all branches")] + public async Task Fork_SchedulesAllBranches() { // Arrange var branches = CreateBranches(3); - var fork = new Fork { JoinMode = ForkJoinMode.WaitAll, @@ -31,36 +29,30 @@ public class ForkTests } } - [Fact] - public async Task ExecutesWithNoBranches() + [Fact(DisplayName = "Fork with no branches completes immediately")] + public async Task Fork_WithNoBranchesCompletesImmediately() { // Arrange - var fork = new Fork - { - JoinMode = ForkJoinMode.WaitAll - }; + var fork = new Fork { JoinMode = ForkJoinMode.WaitAll }; // Act var context = await ExecuteForkAsync(fork); // Assert - Assert.NotNull(context); + Assert.Equal(ActivityStatus.Completed, context.Status); } - [Theory] + [Theory(DisplayName = "Fork respects join mode")] [InlineData(ForkJoinMode.WaitAll)] [InlineData(ForkJoinMode.WaitAny)] - public async Task ExecutesWithSpecifiedJoinMode(ForkJoinMode joinMode) + public async Task Fork_RespectsJoinMode(ForkJoinMode joinMode) { // Arrange var branch = new WriteLine("Test Branch"); var fork = new Fork { JoinMode = joinMode, - Branches = - { - branch - } + Branches = { branch } }; // Act @@ -71,305 +63,11 @@ public class ForkTests Assert.True(context.HasScheduledActivity(branch)); } - [Theory] - [InlineData(ForkJoinMode.WaitAll, 1, 1)] // WaitAll: complete 1 of 2, should track 1 - [InlineData(ForkJoinMode.WaitAll, 2, 2)] // WaitAll: complete 2 of 2, should track 2 - [InlineData(ForkJoinMode.WaitAny, 1, 1)] // WaitAny: complete 1 of 2, should track 1 - public async Task CompletionTrackingWithJoinModes(ForkJoinMode joinMode, int branchesToComplete, int expectedCompleted) - { - // Arrange - var branches = CreateBranches(2); - var fork = new Fork - { - JoinMode = joinMode, - Branches = branches.Cast().ToList() - }; - var context = await ExecuteForkAsync(fork); + private static Task ExecuteForkAsync(Fork fork) => + new ActivityTestFixture(fork).ExecuteAsync(); - // Act - HashSet completedSet = null!; - for (var i = 0; i < branchesToComplete; i++) - { - completedSet = await CompleteBranchAsync(fork, context, branches[i]); - } - - // Assert - Assert.NotNull(completedSet); - Assert.Equal(expectedCompleted, completedSet.Count); - for (var i = 0; i < branchesToComplete; i++) - { - Assert.Contains(branches[i].Id, completedSet); - } - - for (var i = branchesToComplete; i < branches.Length; i++) - { - Assert.DoesNotContain(branches[i].Id, completedSet); - } - } - - [Fact] - public async Task HandlesBreakSignal() - { - // Arrange - var fork = new Fork - { - Branches = - { - new WriteLine("Branch") - } - }; - var context = await ExecuteForkAsync(fork); - - // Act - var breakSignal = new BreakSignal(); - var signalContext = new SignalContext(context, context, CancellationToken.None); - await InvokePrivateMethodAsync(fork, "OnBreakSignalReceived", breakSignal, signalContext); - - // Assert - Assert.True(context.GetIsBreaking()); - } - - [Fact] - public async Task CompletesImmediatelyWhenBreaking() - { - // Arrange - var branch = new WriteLine("Branch"); - var fork = new Fork - { - Branches = - { - branch - } - }; - var context = await ExecuteForkAsync(fork); - context.SetIsBreaking(); - - // Act - await CompleteBranchAsync(fork, context, branch); - - // Assert - Assert.True(context.GetIsBreaking()); - } - - [Fact] - public async Task SingleBranchCompletesCorrectly() - { - // Arrange - var branch = new WriteLine("Single Branch"); - var fork = new Fork - { - JoinMode = ForkJoinMode.WaitAll, - Branches = - { - branch - } - }; - var context = await ExecuteForkAsync(fork); - - // Act - var completedSet = await CompleteBranchAsync(fork, context, branch); - - // Assert - Assert.NotNull(completedSet); - Assert.Single(completedSet); - Assert.Contains(branch.Id, completedSet); - } - - [Theory] - [InlineData(ForkJoinMode.WaitAll, 3, 3)] // WaitAll requires all 3 branches to complete - [InlineData(ForkJoinMode.WaitAny, 3, 1)] // WaitAny only needs 1 branch to complete - public async Task HandlesMultipleBranchesWithJoinModes(ForkJoinMode joinMode, int totalBranches, int branchesToComplete) - { - // Arrange - var branches = CreateBranches(totalBranches); - var fork = new Fork - { - JoinMode = joinMode, - Branches = branches.Cast().ToList() - }; - var context = await ExecuteForkAsync(fork); - - // Act - HashSet completedSet = null!; - for (var i = 0; i < branchesToComplete; i++) - { - completedSet = await CompleteBranchAsync(fork, context, branches[i]); - } - - // Assert - Assert.NotNull(completedSet); - Assert.Equal(branchesToComplete, completedSet.Count); - for (var i = 0; i < branchesToComplete; i++) - { - Assert.Contains(branches[i].Id, completedSet); - } - } - - [Fact] - public async Task TracksAllActivityCompletions() // Including non-branch activities - { - // Arrange - var branch = new WriteLine("Valid Branch") - { - Id = "branch-activity" - }; - var nonBranch = new WriteLine("Non-Branch Activity") - { - Id = "non-branch-activity" - }; - var fork = new Fork - { - JoinMode = ForkJoinMode.WaitAll, - Branches = - { - branch - } - }; - var context = await ExecuteForkAsync(fork); - - // Act - complete a non-branch activity - var completedSet = await CompleteBranchAsync(fork, context, nonBranch); - - // Assert - Fork tracks ALL completions, even non-branch activities - Assert.NotNull(completedSet); - Assert.Contains(nonBranch.Id, completedSet); - Assert.Single(completedSet); - } - - [Fact] - public async Task JoinLogicOnlyConsidersBranchActivities() - { - // Arrange - var branch = new WriteLine("Valid Branch") - { - Id = "branch-activity" - }; - var nonBranch = new WriteLine("Non-Branch Activity") - { - Id = "non-branch-activity" - }; - var fork = new Fork - { - JoinMode = ForkJoinMode.WaitAll, - Branches = - { - branch - } - }; - var context = await ExecuteForkAsync(fork); - - // Act - complete non-branch activity first, then branch activity - await CompleteBranchAsync(fork, context, nonBranch); - var completedSet = await CompleteBranchAsync(fork, context, branch); - - // Assert - both activities should be tracked - Assert.NotNull(completedSet); - Assert.Contains(branch.Id, completedSet); - Assert.Contains(nonBranch.Id, completedSet); - Assert.Equal(2, completedSet.Count); - } - - [Fact] - public async Task MaintainsSeparateCompletionTracking() - { - // Arrange - var branch1 = new WriteLine("Branch 1") - { - Id = "branch1-unique" - }; - var branch2 = new WriteLine("Branch 2") - { - Id = "branch2-unique" - }; - var fork1 = new Fork - { - Branches = - { - branch1 - } - }; - var fork2 = new Fork - { - Branches = - { - branch2 - } - }; - var context1 = await ExecuteForkAsync(fork1); - var context2 = await ExecuteForkAsync(fork2); - - // Act - var completed1 = await CompleteBranchAsync(fork1, context1, branch1); - var completed2 = await CompleteBranchAsync(fork2, context2, branch2); - - // Assert - each fork should track its own completions - Assert.NotNull(completed1); - Assert.NotNull(completed2); - Assert.Contains(branch1.Id, completed1); - Assert.DoesNotContain(branch2.Id, completed1); - Assert.Contains(branch2.Id, completed2); - Assert.DoesNotContain(branch1.Id, completed2); - } - - private static async Task ExecuteForkAsync(Fork fork) - { - var fixture = new ActivityTestFixture(fork); - return await fixture.ExecuteAsync(); - } - - private static WriteLine[] CreateBranches(int count, string namePrefix = "Branch") - { - return Enumerable - .Range(1, count) - .Select(i => new WriteLine($"{namePrefix} {i}") - { - Id = $"{namePrefix.ToLower()}-{i}" - }) + private static WriteLine[] CreateBranches(int count) => + Enumerable.Range(1, count) + .Select(i => new WriteLine($"Branch {i}") { Id = $"branch-{i}" }) .ToArray(); - } - - private static async Task CreateChildContextAsync(IActivity childActivity) - { - var childFixture = new ActivityTestFixture(childActivity); - var childContext = await childFixture.ExecuteAsync(); - return childContext; - } - - private static async Task> CompleteBranchAsync(Fork fork, ActivityExecutionContext parentContext, IActivity branch) - { - var childContext = await CreateChildContextAsync(branch); - var completedContext = new ActivityCompletedContext(parentContext, childContext); - await InvokeCompleteChildAsync(fork, completedContext); - return parentContext.GetProperty>("Completed") ?? new HashSet(); - } - - private static async Task InvokePrivateMethodAsync(object instance, string methodName, params object[] parameters) - { - var method = instance.GetType().GetMethod(methodName, - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?? throw new InvalidOperationException($"{methodName} method not found on {instance.GetType().Name} class. This may indicate a breaking change in the implementation."); - - try - { - var result = method.Invoke(instance, parameters); - switch (result) - { - case ValueTask valueTask: - await valueTask; - break; - case Task task: - await task; - break; - } - } - catch (System.Reflection.TargetInvocationException ex) when (ex.InnerException != null) - { - throw ex.InnerException; - } - } - - private static Task InvokeCompleteChildAsync(Fork fork, ActivityCompletedContext completedContext) - { - return InvokePrivateMethodAsync(fork, "CompleteChildAsync", completedContext); - } -} \ No newline at end of file +} diff --git a/test/unit/Elsa.Activities.UnitTests/Flow/SequenceTests.cs b/test/unit/Elsa.Activities.UnitTests/Flow/SequenceTests.cs new file mode 100644 index 000000000..3748473c6 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Flow/SequenceTests.cs @@ -0,0 +1,151 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Flow; + +/// +/// Unit tests for the activity focusing on sequential scheduling behavior. +/// +public class SequenceTests +{ + [Theory(DisplayName = "Sequence schedules first child activity")] + [InlineData(1)] + [InlineData(3)] + [InlineData(5)] + public async Task Sequence_SchedulesFirstChildActivity(int activityCount) + { + // Arrange + var activities = CreateActivities(activityCount); + var sequence = CreateSequenceWithActivities(activities); + + // Act + var context = await ExecuteSequenceAsync(sequence); + + // Assert - Only first activity should be scheduled (sequential scheduling) + Assert.True(context.HasScheduledActivity(activities[0])); + } + + [Fact(DisplayName = "Sequence with no activities completes without scheduling")] + public async Task Sequence_WithNoActivities_CompletesWithoutScheduling() + { + // Arrange + var sequence = new Sequence(); + + // Act + var context = await ExecuteSequenceAsync(sequence); + + // Assert + var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList(); + Assert.Empty(scheduledActivities); + } + + [Fact(DisplayName = "Sequence with single activity schedules it")] + public async Task Sequence_WithSingleActivity_SchedulesIt() + { + // Arrange + var activity = new WriteLine("Single activity"); + var sequence = CreateSequenceWithActivities(activity); + + // Act + var context = await ExecuteSequenceAsync(sequence); + + // Assert + Assert.True(context.HasScheduledActivity(activity)); + } + + [Fact(DisplayName = "Sequence tracks current index property")] + public async Task Sequence_TracksCurrentIndexProperty() + { + // Arrange + var activities = CreateActivities(3); + var sequence = CreateSequenceWithActivities(activities); + + // Act + var context = await ExecuteSequenceAsync(sequence); + + // Assert - CurrentIndex should be incremented after scheduling first activity + var currentIndex = context.GetProperty("CurrentIndex"); + Assert.Equal(1, currentIndex); + } + + [Theory(DisplayName = "Sequence schedules activities with different counts")] + [InlineData(2)] + [InlineData(5)] + [InlineData(10)] + public async Task Sequence_SchedulesActivitiesWithDifferentCounts(int activityCount) + { + // Arrange + var activities = CreateActivities(activityCount); + var sequence = CreateSequenceWithActivities(activities); + + // Act + var context = await ExecuteSequenceAsync(sequence); + + // Assert - Only first activity should be scheduled initially + Assert.True(context.HasScheduledActivity(activities[0])); + if (activityCount > 1) + { + Assert.False(context.HasScheduledActivity(activities[1])); + } + } + + [Fact(DisplayName = "Sequence schedules mixed activity types")] + public async Task Sequence_SchedulesMixedActivityTypes() + { + // 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 sequence = CreateSequenceWithActivities(writeLine, setVariable, mockActivity); + + // Act + var context = await ExecuteSequenceAsync(sequence); + + // Assert - First activity should be scheduled + Assert.True(context.HasScheduledActivity(writeLine)); + Assert.False(context.HasScheduledActivity(setVariable)); + Assert.False(context.HasScheduledActivity(mockActivity)); + } + + [Fact(DisplayName = "Sequence with variables declares them in memory")] + public async Task Sequence_WithVariables_DeclaresThemInMemory() + { + // Arrange + var variable1 = new Variable("Counter", 10); + var variable2 = new Variable("Name", "Test"); + var sequence = new Sequence + { + Variables = { variable1, variable2 }, + Activities = { new WriteLine("Activity") } + }; + + // Act + var context = await ExecuteSequenceAsync(sequence); + + // Assert + var memory = context.ExpressionExecutionContext.Memory; + Assert.True(memory.HasBlock(variable1.Id)); + Assert.True(memory.HasBlock(variable2.Id)); + } + + private static Task ExecuteSequenceAsync(Sequence sequence) => + new ActivityTestFixture(sequence).ExecuteAsync(); + + private static IActivity[] CreateActivities(int count) => + Enumerable.Range(1, count) + .Select(i => new WriteLine($"Activity {i}")) + .Cast() + .ToArray(); + + private static Sequence CreateSequenceWithActivities(params IActivity[] activities) + { + var sequence = new Sequence(); + foreach (var activity in activities) + { + sequence.Activities.Add(activity); + } + return sequence; + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Flow/StartTests.cs b/test/unit/Elsa.Activities.UnitTests/Flow/StartTests.cs new file mode 100644 index 000000000..82243b233 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Flow/StartTests.cs @@ -0,0 +1,35 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.UnitTests.Flow; + +/// +/// Unit tests for the activity. +/// +public class StartTests +{ + [Fact(DisplayName = "Start implements IStartNode interface")] + public void Start_ImplementsIStartNode() + { + // Arrange + var startActivity = new Start(); + + // Assert + Assert.IsAssignableFrom(startActivity); + } + + [Fact(DisplayName = "Start completes execution")] + public async Task Start_CompletesExecution() + { + // Arrange + var startActivity = new Start(); + var fixture = new ActivityTestFixture(startActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs new file mode 100644 index 000000000..301c5e1ee --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs @@ -0,0 +1,332 @@ +using System.Net; +using Elsa.Activities.UnitTests.Http.Helpers; +using Elsa.Extensions; +using Elsa.Http; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using static Elsa.Activities.UnitTests.Http.Helpers.SendHttpRequestTestHelpers; + +namespace Elsa.Activities.UnitTests.Http; + +public class DownloadHttpFileTests +{ + [Theory] + [InlineData("GET", "https://example.com/file.pdf")] + [InlineData("POST", "https://api.example.com/download")] + [InlineData("PUT", "https://api.example.com/update-file")] + public async Task ExecuteAsync_SendsRequest_WithCorrectMethodAndUrl(string method, string url) + { + // Arrange + var expectedUrl = new Uri(url); + var expectedMethod = new HttpMethod(method); + var requestCapture = new RequestCapture(); + var responseHandler = CreateFileResponseHandler(requestCapture: requestCapture); + var activity = CreateActivity(expectedUrl, method); + + // Act + await ExecuteAsync(activity, responseHandler); + + // Assert + Assert.NotNull(requestCapture.CapturedRequest); + Assert.Equal(expectedMethod, requestCapture.CapturedRequest.Method); + Assert.Equal(expectedUrl, requestCapture.CapturedRequest.RequestUri); + } + + [Theory] + [InlineData("Bearer token123")] + [InlineData("Basic YWRtaW46cGFzcw==")] + public async Task ExecuteAsync_AddsAuthorizationHeader_WhenProvided(string authorizationHeader) + { + // Arrange + var requestCapture = new RequestCapture(); + var responseHandler = CreateFileResponseHandler(requestCapture: requestCapture); + var activity = CreateActivity(new("https://example.com/file.pdf"), authorization: authorizationHeader); + + // Act + await ExecuteAsync(activity, responseHandler); + + // Assert + Assert.NotNull(requestCapture.CapturedRequest); + Assert.NotNull(requestCapture.CapturedRequest.Headers.Authorization); + Assert.Equal(authorizationHeader, requestCapture.CapturedRequest.Headers.Authorization.ToString()); + } + + [Theory] + [InlineData(200)] + [InlineData(201)] + [InlineData(404)] + public async Task ExecuteAsync_SetsStatusCodeOutput(int statusCode) + { + // Arrange + var responseHandler = CreateFileResponseHandler(statusCode: (HttpStatusCode)statusCode); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var actualStatusCode = context.GetActivityOutput(() => activity.StatusCode); + Assert.Equal(statusCode, actualStatusCode); + } + + [Fact] + public async Task ExecuteAsync_SetsFileOutput_WithCorrectFilename() + { + // Arrange + const string expectedFilename = "document.pdf"; + var responseHandler = CreateFileResponseHandler(filename: expectedFilename); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result) as HttpFile; + Assert.NotNull(file); + Assert.Equal(expectedFilename, file.Filename); + } + + [Fact] + public async Task ExecuteAsync_SetsFileOutput_WithCorrectContentType() + { + // Arrange + const string expectedContentType = "application/pdf"; + var responseHandler = CreateFileResponseHandler(contentType: expectedContentType); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result) as HttpFile; + Assert.NotNull(file); + Assert.Equal(expectedContentType, file.ContentType); + } + + [Fact] + public async Task ExecuteAsync_SetsResponseContentStream_FromFile() + { + // Arrange + var fileContent = "Test file content"u8.ToArray(); + var responseHandler = CreateFileResponseHandler(content: fileContent); + var activity = CreateActivity(new("https://example.com/file.txt")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var stream = context.GetActivityOutput(() => activity.ResponseContentStream) as Stream; + Assert.NotNull(stream); + + using var reader = new StreamReader(stream); + var actualContent = await reader.ReadToEndAsync(); + Assert.Equal("Test file content", actualContent); + } + + [Fact] + public async Task ExecuteAsync_SetsResponseHeaders() + { + // Arrange + var expectedHeaders = new Dictionary + { + { "X-Custom-Header", "CustomValue" }, + { "X-Request-Id", "12345" } + }; + var responseHandler = CreateFileResponseHandler(additionalHeaders: expectedHeaders); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var responseHeaders = context.GetActivityOutput(() => activity.ResponseHeaders); + Assert.NotNull(responseHeaders); + var httpHeaders = Assert.IsType(responseHeaders); + Assert.True(httpHeaders.Count > 0); + Assert.Contains(httpHeaders.Keys, k => k.Equals("X-Custom-Header", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(httpHeaders.Keys, k => k.Equals("X-Request-Id", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ExecuteAsync_CompletesSuccessfully() + { + // Arrange + var responseHandler = CreateFileResponseHandler(); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + } + + [Fact] + public async Task ExecuteAsync_HandlesHttpRequestException() + { + // Arrange + var responseHandler = CreateExceptionHandler("Connection failed"); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + Assert.True(context.JournalData.ContainsKey("Error")); + } + + [Fact] + public async Task ExecuteAsync_HandlesTaskCanceledException() + { + // Arrange + var responseHandler = CreateExceptionHandler("Request timed out"); + var activity = CreateActivity(new("https://example.com/file.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + Assert.True(context.JournalData.ContainsKey("Cancelled")); + } + + [Fact] + public async Task ExecuteAsync_ReturnsNull_WhenResponseHasNoContent() + { + // Arrange + var responseHandler = CreateEmptyResponseHandler(); + var activity = CreateActivity(new("https://example.com/empty")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result); + Assert.Null(file); + + var stream = context.GetActivityOutput(() => activity.ResponseContentStream); + Assert.Null(stream); + } + + [Fact] + public async Task ExecuteAsync_ExtractsFilename_FromContentDisposition() + { + // Arrange + const string expectedFilename = "report.xlsx"; + var responseHandler = CreateFileResponseHandler(filename: expectedFilename); + var activity = CreateActivity(new("https://example.com/download")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result) as HttpFile; + Assert.NotNull(file); + Assert.Equal(expectedFilename, file.Filename); + } + + [Fact] + public async Task ExecuteAsync_ExtractsFilename_FromUrlSegment_WhenNoContentDisposition() + { + // Arrange + var responseHandler = CreateFileResponseHandler(filename: null); // No Content-Disposition + var activity = CreateActivity(new("https://example.com/files/document.pdf")); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result) as HttpFile; + Assert.NotNull(file); + Assert.Equal("document.pdf", file.Filename); + } + + [Theory] + [InlineData("https://example.com", "/")] + [InlineData("https://example.com/", "/")] + [InlineData("https://example.com/download", "download")] + public async Task ExecuteAsync_ExtractsFilename_FromUrlWhenNoContentDisposition(string url, string expectedFilename) + { + // Arrange + var responseHandler = CreateFileResponseHandler(filename: null); + var activity = CreateActivity(new Uri(url)); + + // Act + var context = await ExecuteAsync(activity, responseHandler); + + // Assert + var file = context.GetActivityOutput(() => activity.Result) as HttpFile; + Assert.NotNull(file); + Assert.Equal(expectedFilename, file.Filename); + } + + private static DownloadHttpFile CreateActivity( + Uri url, + string method = "GET", + string? authorization = null) => + new() + { + Url = new(url), + Method = new(method), + Authorization = authorization != null ? new(authorization) : null!, + ExpectedStatusCodes = new([200]) + }; + + private static Task ExecuteAsync( + DownloadHttpFile activity, + Func> responseHandler) => + new ActivityTestFixture(activity).WithHttpServices(responseHandler).ExecuteAsync(); + + private static Func> CreateFileResponseHandler( + HttpStatusCode statusCode = HttpStatusCode.OK, + byte[]? content = null, + string? filename = null, + string? contentType = null, + Dictionary? additionalHeaders = null, + RequestCapture? requestCapture = null) + { + return (request, _) => + { + requestCapture?.CapturedRequest = request; + + var response = new HttpResponseMessage(statusCode); + + // Always set Content (even if empty) because DownloadHttpFile accesses Content.Headers + var actualContent = content ?? (statusCode == HttpStatusCode.OK ? "Default file content"u8.ToArray() : []); + var actualContentType = contentType ?? "application/octet-stream"; + response.Content = new ByteArrayContent(actualContent); + response.Content.Headers.ContentType = new(actualContentType); + + if (filename != null) + { + response.Content.Headers.ContentDisposition = new("attachment") + { + FileName = filename + }; + } + + if (additionalHeaders != null) + { + foreach (var header in additionalHeaders) + { + response.Headers.Add(header.Key, header.Value); + } + } + + return Task.FromResult(response); + }; + } + + private static Func> CreateEmptyResponseHandler() + { + return (_, _) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK); + // Set empty content (ContentLength = 0) to test HasContent returning false + response.Content = new ByteArrayContent(Array.Empty()); + response.Content.Headers.ContentLength = 0; + return Task.FromResult(response); + }; + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs index 7379ea8d5..12b15ca1d 100644 --- a/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text; using System.IO.Compression; using Elsa.Common; @@ -29,9 +30,9 @@ public class WriteFileHttpResponseTests var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); var testContent = "Hello World"u8.ToArray(); - activity.Content = new Input(testContent); - activity.ContentType = new Input(contentType); - activity.Filename = new Input(filename); + activity.Content = new(testContent); + activity.ContentType = new(contentType); + activity.Filename = new(filename); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -49,8 +50,8 @@ public class WriteFileHttpResponseTests { // Arrange var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); - activity.Content = new Input(content); - activity.Filename = new Input("test.txt"); + activity.Content = new(content); + activity.Filename = new("test.txt"); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -68,8 +69,8 @@ public class WriteFileHttpResponseTests var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); var testBytes = "Hello World"u8.ToArray(); - activity.Content = new Input(testBytes); - activity.Filename = new Input("test.bin"); + activity.Content = new(testBytes); + activity.Filename = new("test.bin"); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -88,8 +89,8 @@ public class WriteFileHttpResponseTests var testContent = "Stream content"; var stream = new MemoryStream(Encoding.UTF8.GetBytes(testContent)); - activity.Content = new Input(stream); - activity.Filename = new Input("stream.txt"); + activity.Content = new(stream); + activity.Filename = new("stream.txt"); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -107,8 +108,8 @@ public class WriteFileHttpResponseTests var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); var testUri = new Uri("https://example.com/file.txt"); - activity.Content = new Input(testUri); - activity.Filename = new Input("downloaded.txt"); + activity.Content = new(testUri); + activity.Filename = new("downloaded.txt"); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -129,8 +130,8 @@ public class WriteFileHttpResponseTests "File 2 content"u8.ToArray() }; - activity.Content = new Input(files); - activity.Filename = new Input("archive.zip"); + activity.Content = new(files); + activity.Filename = new("archive.zip"); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -146,7 +147,7 @@ public class WriteFileHttpResponseTests { // Arrange var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); - activity.Content = new Input((object?)null!, string.Empty); + activity.Content = new((object?)null!, string.Empty); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -161,7 +162,7 @@ public class WriteFileHttpResponseTests { // Arrange var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); - activity.Content = new Input(Array.Empty()); + activity.Content = new(Array.Empty()); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -181,9 +182,9 @@ public class WriteFileHttpResponseTests var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); var testContent = "Hello World"u8.ToArray(); - activity.Content = new Input(testContent); - activity.EntityTag = new Input(entityTag); - activity.EnableResumableDownloads = new Input(true); + activity.Content = new(testContent); + activity.EntityTag = new(entityTag); + activity.EnableResumableDownloads = new(true); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -202,9 +203,9 @@ public class WriteFileHttpResponseTests var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); var testContent = "Hello World"u8.ToArray(); - activity.Content = new Input(testContent); - activity.EnableResumableDownloads = new Input(true); - activity.EntityTag = new Input("\"test-etag\""); + activity.Content = new(testContent); + activity.EnableResumableDownloads = new(true); + activity.EntityTag = new("\"test-etag\""); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -223,9 +224,9 @@ public class WriteFileHttpResponseTests var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); var testContent = "Hello World"u8.ToArray(); - activity.Content = new Input(testContent); - activity.EnableResumableDownloads = new Input(false); - activity.EntityTag = new Input("\"test-etag\""); + activity.Content = new(testContent); + activity.EnableResumableDownloads = new(false); + activity.EntityTag = new("\"test-etag\""); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -247,8 +248,8 @@ public class WriteFileHttpResponseTests var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); var testContent = "Hello World"u8.ToArray(); - activity.Content = new Input(testContent); - activity.Filename = new Input(filename); + activity.Content = new(testContent); + activity.Filename = new(filename); // ContentType not set, should be determined from filename // Act @@ -266,7 +267,7 @@ public class WriteFileHttpResponseTests var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); var testContent = "Hello World"u8.ToArray(); - activity.Content = new Input(testContent); + activity.Content = new(testContent); // Filename not set, should default to "file.bin" // Act @@ -306,7 +307,7 @@ public class WriteFileHttpResponseTests // Arrange var activity = new WriteFileHttpResponse(); var testContent = "Hello World"u8.ToArray(); - activity.Content = new Input(testContent); + activity.Content = new(testContent); var fixture = new ActivityTestFixture(activity); var httpContext = CreateMockHttpContext(); @@ -361,7 +362,7 @@ public class WriteFileHttpResponseTests ETag = "\"metadata-etag\"" }; - activity.Content = new Input(downloadable); + activity.Content = new(downloadable); // Act var context = await ExecuteActivityAsync(activity, httpContext); @@ -379,9 +380,9 @@ public class WriteFileHttpResponseTests var (activity, httpContext) = CreateWriteFileHttpResponseActivity(); var testContent = "Hello World"u8.ToArray(); - activity.Content = new Input(testContent); - activity.EntityTag = new Input("W/\"weak-etag\""); // Malformed weak ETag format - activity.EnableResumableDownloads = new Input(true); + activity.Content = new(testContent); + activity.EntityTag = new("W/\"weak-etag\""); // Malformed weak ETag format + activity.EnableResumableDownloads = new(true); // Act & Assert var exception = await Assert.ThrowsAsync(async () => @@ -548,14 +549,14 @@ public class WriteFileHttpResponseTests } else { - services.AddSingleton(_ => new object()); + services.AddSingleton(_ => new()); } } private static object CreateZipManagerInstance(IServiceProvider serviceProvider) { var zipManagerType = typeof(WriteFileHttpResponse).Assembly.GetType("Elsa.Http.Services.ZipManager"); - if (zipManagerType == null) return new object(); + if (zipManagerType == null) return new(); try { @@ -591,13 +592,14 @@ public class WriteFileHttpResponseTests }; } + [UnconditionalSuppressMessage("Trimming", "IL2060:Call to MakeGenericMethod can not be statically analyzed", Justification = "Test helper method that creates mocks dynamically for any logger type.")] private static object CreateLoggerMock(Type loggerType) { try { var mockLoggerMethod = typeof(Substitute).GetMethods() .FirstOrDefault(m => m is { Name: "For", IsGenericMethodDefinition: true } && m.GetParameters().Length == 0); - + if (mockLoggerMethod != null) { var genericMethod = mockLoggerMethod.MakeGenericMethod(loggerType); @@ -634,7 +636,7 @@ public class WriteFileHttpResponseTests { // Simplified mock creation for different content types case byte[] byteArray: - yield return new Downloadable + yield return new() { Stream = new MemoryStream(byteArray), ContentType = null, @@ -642,7 +644,7 @@ public class WriteFileHttpResponseTests }; break; case string text: - yield return new Downloadable + yield return new() { Stream = new MemoryStream(Encoding.UTF8.GetBytes(text)), ContentType = null, @@ -651,7 +653,7 @@ public class WriteFileHttpResponseTests break; case Stream stream: // For Stream objects, return them as-is - yield return new Downloadable + yield return new() { Stream = stream, ContentType = null, @@ -662,7 +664,7 @@ public class WriteFileHttpResponseTests { // For Uri objects, simulate downloaded content var mockContent = $"Downloaded content from {uri}"; - yield return new Downloadable + yield return new() { Stream = new MemoryStream(Encoding.UTF8.GetBytes(mockContent)), ContentType = null, @@ -710,7 +712,7 @@ public class WriteFileHttpResponseTests } memoryStream.Position = 0; - return new Downloadable + return new() { Stream = memoryStream, ContentType = "application/zip", diff --git a/test/unit/Elsa.Activities.UnitTests/Http/WriteHttpResponseTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/WriteHttpResponseTests.cs index 912094126..533862c4e 100644 --- a/test/unit/Elsa.Activities.UnitTests/Http/WriteHttpResponseTests.cs +++ b/test/unit/Elsa.Activities.UnitTests/Http/WriteHttpResponseTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text; using Elsa.Http; using Elsa.Http.ContentWriters; using Elsa.Http.Options; diff --git a/test/unit/Elsa.Activities.UnitTests/Looping/BreakTests.cs b/test/unit/Elsa.Activities.UnitTests/Looping/BreakTests.cs new file mode 100644 index 000000000..5730e1b3b --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Looping/BreakTests.cs @@ -0,0 +1,35 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Activities; + +namespace Elsa.Activities.UnitTests.Looping; + +/// +/// Unit tests for the activity. +/// +public class BreakTests +{ + [Fact(DisplayName = "Break implements ITerminalNode interface")] + public void Break_ImplementsITerminalNode() + { + // Arrange + var breakActivity = new Break(); + + // Assert + Assert.IsAssignableFrom(breakActivity); + } + + [Fact(DisplayName = "Break completes execution")] + public async Task Break_CompletesExecution() + { + // Arrange + var breakActivity = new Break(); + var fixture = new ActivityTestFixture(breakActivity); + + // Act + var context = await fixture.ExecuteAsync(); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Looping/ParallelForEachTests.cs b/test/unit/Elsa.Activities.UnitTests/Looping/ParallelForEachTests.cs new file mode 100644 index 000000000..05a94ed1e --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Looping/ParallelForEachTests.cs @@ -0,0 +1,94 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; + +namespace Elsa.Activities.UnitTests.Looping; + +public class ParallelForEachTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public async Task Should_Schedule_Body_For_Each_Item(int itemCount) + { + var items = Enumerable.Range(0, itemCount).Select(i => $"item{i}").ToArray(); + var body = new MockBodyActivity(); + var parallelForEach = new ParallelForEach(items) { Body = body }; + + var context = await ExecuteAsync(parallelForEach); + + AssertScheduledCount(context, itemCount); + Assert.True(context.HasScheduledActivity(body)); + } + + [Fact] + public async Task Should_Complete_When_Items_Empty() + { + var body = new MockBodyActivity(); + var parallelForEach = new ParallelForEach(Array.Empty()) { Body = body }; + + var context = await ExecuteAsync(parallelForEach); + + Assert.Equal(ActivityStatus.Completed, context.Status); + Assert.False(context.HasScheduledActivity(body)); + } + + [Fact] + public async Task Should_Not_Schedule_When_Body_Null() + { + var items = new[] { "a", "b" }; + var parallelForEach = new ParallelForEach(items) { Body = null! }; + + var context = await ExecuteAsync(parallelForEach); + + AssertScheduledCount(context, 0); + } + + [Fact] + public async Task Should_Handle_Integer_Items() + { + var items = new[] { 1, 2, 3 }; + var body = new MockBodyActivity(); + var parallelForEach = new ParallelForEach(items) { Body = body }; + + var context = await ExecuteAsync(parallelForEach); + + AssertScheduledCount(context, items.Length); + } + + [Fact] + public void Verify_Activity_Attributes() + { + var parallelForEach = new ParallelForEach(); + var fixture = new ActivityTestFixture(parallelForEach); + + fixture.AssertActivityAttributes( + expectedNamespace: "Elsa", + expectedKind: ActivityKind.Action, + expectedCategory: "Looping", + expectedDescription: "Schedule an activity for each item in parallel." + ); + } + + [Fact] + public void Default_Property_Values() + { + var parallelForEach = new ParallelForEach(); + + Assert.NotNull(parallelForEach.Items); + } + + private static Task ExecuteAsync(IActivity activity) => + new ActivityTestFixture(activity).ExecuteAsync(); + + private static void AssertScheduledCount(ActivityExecutionContext context, int expectedCount) + { + var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList(); + Assert.Equal(expectedCount, scheduledActivities.Count); + } + + private class MockBodyActivity : Activity + { + protected override ValueTask ExecuteAsync(ActivityExecutionContext context) => ValueTask.CompletedTask; + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/CorrelateTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/CorrelateTests.cs new file mode 100644 index 000000000..fc7586cdf --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/CorrelateTests.cs @@ -0,0 +1,54 @@ +using Elsa.Testing.Shared; + +namespace Elsa.Activities.UnitTests.Primitives; + +public class CorrelateTests +{ + [Fact] + public async Task Should_Set_CorrelationId_From_String_Literal() + { + const string expected = "test-correlation-id"; + var correlate = new Correlate(expected, null, null); + + await AssertCorrelationIdAsync(correlate, expected); + } + + [Fact] + public async Task Should_Set_CorrelationId_From_Input() + { + const string expected = "dynamic-correlation-id"; + var correlate = new Correlate { CorrelationId = new(expected) }; + + await AssertCorrelationIdAsync(correlate, expected); + } + + [Fact] + public async Task Should_Set_CorrelationId_From_Func() + { + const string expected = "func-correlation-id"; + var correlate = new Correlate(_ => expected); + + await AssertCorrelationIdAsync(correlate, expected); + } + + [Fact] + public async Task Should_Overwrite_Existing_CorrelationId() + { + const string initial = "initial-correlation-id"; + const string expected = "updated-correlation-id"; + var correlate = new Correlate(expected, null, null); + var fixture = new ActivityTestFixture(correlate); + + var context = await fixture.BuildAsync(); + context.WorkflowExecutionContext.CorrelationId = initial; + await fixture.ExecuteAsync(context); + + Assert.Equal(expected, context.WorkflowExecutionContext.CorrelationId); + } + + private static async Task AssertCorrelationIdAsync(Correlate correlate, string expected) + { + var context = await new ActivityTestFixture(correlate).ExecuteAsync(); + Assert.Equal(expected, context.WorkflowExecutionContext.CorrelationId); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/EventBaseTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/EventBaseTests.cs new file mode 100644 index 000000000..c952718b9 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/EventBaseTests.cs @@ -0,0 +1,157 @@ +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Activities; +using Elsa.Workflows.Runtime.Stimuli; + +namespace Elsa.Activities.UnitTests.Primitives; + +public class EventBaseTests +{ + [Fact] + public async Task ExecuteAsync_CreatesBookmark_WithCorrectEventName() + { + // Arrange + const string eventName = "MyTestEvent"; + var activity = new TestEvent(eventName); + + // Act + var context = await ExecuteAsync(activity); + + // Assert + Assert.Equal(ActivityStatus.Running, context.Status); + var stimulus = GetEventStimulusFromContext(context); + Assert.Equal(eventName, stimulus.EventName); + } + + [Fact] + public async Task ExecuteAsync_CreatesBookmark_WithoutActivityInstanceId() + { + // Arrange + var activity = new TestEvent("TestEvent"); + + // Act + var context = await ExecuteAsync(activity); + + // Assert + var bookmark = Assert.Single(context.WorkflowExecutionContext.Bookmarks); + Assert.True(string.IsNullOrEmpty(bookmark.ActivityInstanceId), + "ActivityInstanceId should be null or empty because IncludeActivityInstanceId is set to false"); + } + + [Theory] + [InlineData("Event.Order.Created")] + [InlineData("Event.User.Registered")] + [InlineData("CustomEvent")] + public async Task ExecuteAsync_CreatesBookmark_WithCorrectEventStimulus(string eventName) + { + // Arrange & Act + var context = await ExecuteAsync(new TestEvent(eventName)); + + // Assert + var stimulus = GetEventStimulusFromContext(context); + Assert.Equal(eventName, stimulus.EventName); + } + + [Fact] + public async Task ExecuteAsync_SetsResultOutput_WhenCallbackIsInvoked() + { + // Arrange + const string expectedInput = "test payload"; + var activity = new TestEvent("TestEvent"); + + // Act + var context = await ExecuteAsync(activity); + context.WorkflowExecutionContext.Input[Event.EventInputWorkflowInputKey] = expectedInput; + await activity.InvokeCallbackAsync(context); + + // Assert + var result = context.GetActivityOutput(() => activity.Result); + Assert.Equal(expectedInput, result); + } + + [Fact] + public async Task GetTriggerPayload_ReturnsEventStimulus_WithCorrectEventName() + { + // Arrange + const string eventName = "TriggerEvent"; + var activity = new TestEvent(eventName); + var triggerIndexingContext = await CreateTriggerIndexingContextAsync(activity); + + // Act + var payload = activity.GetTriggerPayloadPublic(triggerIndexingContext); + + // Assert + var stimulus = Assert.IsType(payload); + Assert.Equal(eventName, stimulus.EventName); + } + + [Fact] + public async Task OnEventReceivedAsync_IsCalledDuringCallback() + { + // Arrange + const string testPayload = "callback-invoked"; + var activity = new TestEvent("TestEvent"); + + // Act + var context = await ExecuteAsync(activity); + context.WorkflowExecutionContext.Input[Elsa.Workflows.Runtime.Activities.Event.EventInputWorkflowInputKey] = testPayload; + await activity.InvokeCallbackAsync(context); + + // Assert + Assert.True(activity.OnEventReceivedAsyncWasCalled); + Assert.Equal(testPayload, activity.ReceivedInput); + } + + private static Task ExecuteAsync(IActivity activity) => + new ActivityTestFixture(activity).ExecuteAsync(); + + private static EventStimulus GetEventStimulusFromContext(ActivityExecutionContext context) + { + var bookmark = Assert.Single(context.WorkflowExecutionContext.Bookmarks); + Assert.Equal(RuntimeStimulusNames.Event, bookmark.Name); + return Assert.IsType(bookmark.Payload); + } + + private static async Task CreateTriggerIndexingContextAsync(IActivity activity) + { + var fixture = new ActivityTestFixture(activity); + var activityContext = await fixture.BuildAsync(); + var workflowExecutionContext = activityContext.WorkflowExecutionContext; + var expressionExecutionContext = new ExpressionExecutionContext(workflowExecutionContext.ServiceProvider, workflowExecutionContext.MemoryRegister); + var workflowIndexingContext = new WorkflowIndexingContext(workflowExecutionContext.Workflow, CancellationToken.None); + return new( + workflowIndexingContext, + expressionExecutionContext, + (ITrigger)activity, + CancellationToken.None); + } + + // Test implementation of EventBase for unit testing + private class TestEvent(string eventName) : EventBase + { + protected override string GetEventName(ExpressionExecutionContext context) => eventName; + + public object GetTriggerPayloadPublic(TriggerIndexingContext context) => GetTriggerPayload(context); + } + + // Test implementation with generic type and callback tracking + private class TestEvent(string eventName) : EventBase + { + public bool OnEventReceivedAsyncWasCalled { get; private set; } + public TResult? ReceivedInput { get; private set; } + + protected override string GetEventName(ExpressionExecutionContext context) => eventName; + + protected override ValueTask OnEventReceivedAsync(ActivityExecutionContext context, TResult? input) + { + OnEventReceivedAsyncWasCalled = true; + ReceivedInput = input; + return base.OnEventReceivedAsync(context, input); + } + + public ValueTask InvokeCallbackAsync(ActivityExecutionContext context) => EventReceivedAsync(context); + } +} \ No newline at end of file diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/FaultTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/FaultTests.cs new file mode 100644 index 000000000..179b8923b --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/FaultTests.cs @@ -0,0 +1,128 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Exceptions; + +namespace Elsa.Activities.UnitTests.Primitives; + +/// +/// Unit tests for the activity. +/// +public class FaultTests +{ + [Fact(DisplayName = "Fault throws FaultException with all properties set")] + public async Task Should_Throw_FaultException_With_All_Properties() + { + // Arrange + var fault = new Fault + { + Code = new("ERR_001"), + Category = new("HTTP"), + FaultType = new("Business"), + Message = new("Invalid request") + }; + + // Act & Assert + var exception = await ExecuteAndAssertFaultAsync(fault); + Assert.Equal("ERR_001", exception.Code); + Assert.Equal("HTTP", exception.Category); + Assert.Equal("Business", exception.Type); + Assert.Equal("Invalid request", exception.Message); + } + + [Fact(DisplayName = "Fault uses default values when inputs are null")] + public async Task Should_Use_Default_Values_When_Inputs_Are_Null() + { + // Arrange + var fault = new Fault + { + Code = new((string)null!), + Category = new((string)null!), + FaultType = new((string)null!), + Message = new((string?)null) + }; + + // Act & Assert + var exception = await ExecuteAndAssertFaultAsync(fault); + Assert.Equal("0", exception.Code); + Assert.Equal("General", exception.Category); + Assert.Equal("System", exception.Type); + Assert.NotNull(exception.Message); + } + + [Fact(DisplayName = "Fault throws FaultException with null message")] + public async Task Should_Throw_FaultException_With_Null_Message() + { + // Arrange + var fault = new Fault + { + Code = new("ERR_002"), + Category = new("Database"), + FaultType = new("Integration"), + Message = new((string?)null) + }; + + // Act & Assert + var exception = await ExecuteAndAssertFaultAsync(fault); + Assert.Equal("ERR_002", exception.Code); + Assert.Equal("Database", exception.Category); + Assert.Equal("Integration", exception.Type); + Assert.NotNull(exception.Message); + } + + [Theory(DisplayName = "Fault throws FaultException with various code values")] + [InlineData("404", "404")] + [InlineData("VALIDATION_ERROR", "VALIDATION_ERROR")] + [InlineData("", "")] + public async Task Should_Throw_FaultException_With_Various_Codes(string code, string expectedCode) + { + // Arrange + var fault = new Fault + { + Code = new(code), + Category = new("Test"), + FaultType = new("Test") + }; + + // Act & Assert + var exception = await ExecuteAndAssertFaultAsync(fault); + Assert.Equal(expectedCode, exception.Code); + } + + [Fact(DisplayName = "Fault.Create factory method creates correctly configured instance")] + public async Task Create_Factory_Method_Creates_Correctly_Configured_Instance() + { + // Arrange + var fault = Fault.Create("CREATE_001", "Factory", "Test", "Created via factory"); + + // Act & Assert + var exception = await ExecuteAndAssertFaultAsync(fault); + Assert.Equal("CREATE_001", exception.Code); + Assert.Equal("Factory", exception.Category); + Assert.Equal("Test", exception.Type); + Assert.Equal("Created via factory", exception.Message); + } + + [Fact(DisplayName = "Fault.Create factory method with null message")] + public async Task Create_Factory_Method_With_Null_Message() + { + // Arrange + var fault = Fault.Create("CODE", "Category", "Type"); + + // Act & Assert + var exception = await ExecuteAndAssertFaultAsync(fault); + Assert.Equal("CODE", exception.Code); + Assert.Equal("Category", exception.Category); + Assert.Equal("Type", exception.Type); + Assert.NotNull(exception.Message); + } + + private static async Task ExecuteAndAssertFaultAsync(IActivity activity) + { + return await Assert.ThrowsAsync(() => ExecuteAsync(activity)); + } + + private static async Task ExecuteAsync(IActivity activity) + { + return await new ActivityTestFixture(activity).ExecuteAsync(); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/FinishTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/FinishTests.cs new file mode 100644 index 000000000..8e9fe5da5 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/FinishTests.cs @@ -0,0 +1,51 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; + +namespace Elsa.Activities.UnitTests.Primitives; + +/// +/// Unit tests for the activity. +/// +public class FinishTests +{ + [Fact(DisplayName = "Finish implements ITerminalNode interface")] + public void Finish_ImplementsITerminalNode() + { + // Arrange + var finish = new Finish(); + + // Assert + Assert.IsAssignableFrom(finish); + } + + [Fact(DisplayName = "Finish completes successfully")] + public async Task Should_Complete_Successfully() + { + // Arrange + var finish = new Finish(); + + // Act + var context = await ExecuteAsync(finish); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + } + + [Fact(DisplayName = "Finish transitions workflow to Finished substatus")] + public async Task Should_Transition_Workflow_To_Finished() + { + // Arrange + var finish = new Finish(); + + // Act + var context = await ExecuteAsync(finish); + + // Assert + Assert.Equal(WorkflowSubStatus.Finished, context.WorkflowExecutionContext.SubStatus); + } + + private static async Task ExecuteAsync(IActivity activity) + { + return await new ActivityTestFixture(activity).ExecuteAsync(); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/NotFoundActivityTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/NotFoundActivityTests.cs new file mode 100644 index 000000000..812a99bf6 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/NotFoundActivityTests.cs @@ -0,0 +1,69 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Exceptions; + +namespace Elsa.Activities.UnitTests.Primitives; + +public class NotFoundActivityTests +{ + [Fact(DisplayName = "NotFoundActivity throws ActivityNotFoundException")] + public async Task Should_Throw_ActivityNotFoundException() + { + // Arrange + const string typeName = "MyMissingActivity"; + + // Act & Assert + var exception = await ExecuteAndAssertExceptionAsync(typeName); + Assert.Equal(typeName, exception.MissingTypeName); + } + + [Fact(DisplayName = "NotFoundActivity includes type name in exception")] + public async Task Should_Include_TypeName_In_Exception() + { + // Arrange + const string typeName = "Custom.Namespace.MyActivity"; + + // Act & Assert + var exception = await ExecuteAndAssertExceptionAsync(typeName); + Assert.Contains(typeName, exception.Message); + } + + [Fact(DisplayName = "NotFoundActivity includes version in exception")] + public async Task Should_Include_Version_In_Exception() + { + // Arrange + const string typeName = "MyMissingActivity"; + const int version = 2; + + // Act & Assert + var exception = await ExecuteAndAssertExceptionAsync(typeName, version); + Assert.Equal(version, exception.MissingTypeVersion); + Assert.Contains(version.ToString(), exception.Message); + } + + [Theory(DisplayName = "NotFoundActivity preserves various type names")] + [InlineData("SimpleActivity")] + [InlineData("Namespace.Activity")] + [InlineData("My.Custom.Namespace.ComplexActivity")] + [InlineData("Activity123")] + public async Task Should_Preserve_Various_TypeNames(string typeName) + { + // Act & Assert + var exception = await ExecuteAndAssertExceptionAsync(typeName); + Assert.Equal(typeName, exception.MissingTypeName); + } + + private static async Task ExecuteAndAssertExceptionAsync(string typeName, int version = 0) + { + var notFoundActivity = new NotFoundActivity(typeName, null, null) + { + MissingTypeVersion = version + }; + return await Assert.ThrowsAsync(() => ExecuteAsync(notFoundActivity)); + } + + private static async Task ExecuteAsync(IActivity activity) + { + return await new ActivityTestFixture(activity).ExecuteAsync(); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/PublishEventTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/PublishEventTests.cs new file mode 100644 index 000000000..255fd34a6 --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/PublishEventTests.cs @@ -0,0 +1,148 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Activities; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace Elsa.Activities.UnitTests.Primitives; + +public class PublishEventTests +{ + [Theory] + [InlineData("OrderCreated", null, null, false)] + [InlineData("OrderCreated", "correlation-123", "correlation-123", false)] + [InlineData("OrderEvent", "", null, true)] + [InlineData("OrderEvent", " ", null, true)] + public async Task ExecuteAsync_PublishesEvent_WithParameters(string eventName, string? correlationId, string? expectedCorrelationId, bool expectNullCorrelation) + { + // Arrange + var publisher = Substitute.For(); + + // Act + await ExecuteAsync(CreateActivity(eventName, correlationId), publisher); + + // Assert + await AssertPublishedAsync( + publisher, + eventName, + correlationId: expectedCorrelationId, + expectNullCorrelationId: expectNullCorrelation); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task ExecuteAsync_LocalEvent_PassesCorrectWorkflowInstanceId(bool isLocalEvent, bool expectNull) + { + // Arrange + const string eventName = "TestEvent"; + var publisher = Substitute.For(); + + // Act + var context = await ExecuteAsync(CreateActivity(eventName, isLocalEvent: isLocalEvent), publisher); + + // Assert + await AssertPublishedAsync( + publisher, + eventName, + workflowInstanceId: expectNull ? null : context.WorkflowExecutionContext.Id, + expectNullWorkflowInstanceId: expectNull); + } + + [Fact] + public async Task ExecuteAsync_PublishesEvent_WithPayload() + { + // Arrange + const string eventName = "OrderCreated"; + var payload = new { OrderId = 123, Amount = 99.99m }; + var publisher = Substitute.For(); + + // Act + await ExecuteAsync(CreateActivity(eventName, payload: payload), publisher); + + // Assert + await AssertPublishedAsync(publisher, eventName, payload: payload); + } + + [Fact] + public async Task ExecuteAsync_CompletesActivity() + { + // Arrange + var publisher = Substitute.For(); + + // Act + var context = await ExecuteAsync(CreateActivity("TestEvent"), publisher); + + // Assert + Assert.Equal(ActivityStatus.Completed, context.Status); + } + + [Fact] + public async Task ExecuteAsync_WithAllParameters_PassesAllValuesToPublisher() + { + // Arrange + const string eventName = "CompleteOrderEvent"; + const string correlationId = "correlation-456"; + var payload = new { Status = "Shipped" }; + var publisher = Substitute.For(); + + // Act + var context = await ExecuteAsync(CreateActivity(eventName, correlationId, isLocalEvent: true, payload: payload), publisher); + + // Assert + await publisher.Received(1).PublishAsync( + eventName, + correlationId, + context.WorkflowExecutionContext.Id, + null, + payload, + true, + Arg.Any()); + } + + private static PublishEvent CreateActivity( + string eventName, + string? correlationId = null, + bool? isLocalEvent = null, + object? payload = null) => + new() + { + EventName = new(eventName), + CorrelationId = correlationId != null ? new(correlationId) : null!, + IsLocalEvent = isLocalEvent.HasValue ? new(isLocalEvent.Value) : null!, + Payload = payload != null ? new(payload) : null! + }; + + private static async Task ExecuteAsync(PublishEvent activity, IEventPublisher publisher) => + await new ActivityTestFixture(activity) + .ConfigureServices(services => services.AddSingleton(publisher)) + .ExecuteAsync(); + + private static async Task AssertPublishedAsync( + IEventPublisher publisher, + string eventName, + string? correlationId = null, + string? workflowInstanceId = null, + object? payload = null, + bool expectNullCorrelationId = false, + bool expectNullWorkflowInstanceId = false) + { + var correlationIdArg = expectNullCorrelationId + ? Arg.Is(x => x == null) + : correlationId ?? Arg.Any(); + + var workflowInstanceIdArg = expectNullWorkflowInstanceId + ? Arg.Is(x => x == null) + : workflowInstanceId ?? Arg.Any(); + + await publisher.Received(1).PublishAsync( + eventName, + correlationIdArg, + workflowInstanceIdArg, + Arg.Any(), + payload ?? Arg.Any(), + Arg.Any(), + Arg.Any()); + } +} diff --git a/test/unit/Elsa.Activities.UnitTests/Primitives/SetNameTests.cs b/test/unit/Elsa.Activities.UnitTests/Primitives/SetNameTests.cs new file mode 100644 index 000000000..b6247700c --- /dev/null +++ b/test/unit/Elsa.Activities.UnitTests/Primitives/SetNameTests.cs @@ -0,0 +1,56 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows; + +namespace Elsa.Activities.UnitTests.Primitives; + +public class SetNameTests +{ + [Fact(DisplayName = "SetName sets workflow name and completes successfully")] + public async Task Should_Set_Workflow_Name_And_Complete() + { + // Arrange + const string name = "My Workflow"; + + // Act + var context = await ExecuteAsync(name); + + // Assert + Assert.Equal(name, context.WorkflowExecutionContext.Name); + Assert.Equal(ActivityStatus.Completed, context.Status); + } + + [Theory(DisplayName = "SetName handles various name values")] + [InlineData("Simple Name")] + [InlineData("Name-With-Dashes")] + [InlineData("Name_With_Underscores")] + [InlineData("Name.With.Dots")] + [InlineData("123 Numeric Name")] + [InlineData("")] + public async Task Should_Handle_Various_Name_Values(string name) + { + // Act + var context = await ExecuteAsync(name); + + // Assert + Assert.Equal(name, context.WorkflowExecutionContext.Name); + } + + [Fact(DisplayName = "SetName handles null value")] + public async Task Should_Handle_Null_Value() + { + // Act + var context = await ExecuteAsync(null!); + + // Assert + Assert.Null(context.WorkflowExecutionContext.Name); + } + + private static async Task ExecuteAsync(string name) + { + var setName = new SetName + { + Value = new(name) + }; + return await new ActivityTestFixture(setName).ExecuteAsync(); + } +} diff --git a/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj b/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj index 1d098435a..adc6fdc79 100644 --- a/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj +++ b/test/unit/Elsa.Expressions.UnitTests/Elsa.Expressions.UnitTests.csproj @@ -1,5 +1,10 @@  + + [Elsa.Expressions]* + 5 + + diff --git a/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs b/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs index e8d923bf1..f14b02175 100644 --- a/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs +++ b/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs @@ -1,3 +1,4 @@ +using System.Text; using Elsa.Http.ContentWriters; using Xunit; @@ -64,4 +65,51 @@ public class ContentFactoryTests Assert.Equal(contentType, httpContent.Headers.ContentType?.MediaType); Assert.Null(httpContent.Headers.ContentType?.CharSet); } + + /// + /// Tests that produces correct content length without BOM. + /// + [Fact] + public async Task JsonContentFactory_ShouldProduceCorrectContentLength() + { + // Arrange + const string contentType = "application/json"; + const string content = "{\"test\": \"value\"}"; + var factory = new JsonContentFactory(); + + // Act + var httpContent = factory.CreateHttpContent(content, contentType); + var bytes = await httpContent.ReadAsByteArrayAsync(); + + // Assert + // Content length should match the actual bytes (no BOM) + Assert.Equal(Encoding.UTF8.GetByteCount(content), bytes.Length); + Assert.Equal(httpContent.Headers.ContentLength, bytes.Length); + + // Verify no BOM is present (BOM would be EF-BB-BF at start) + Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF); + } + + /// + /// Tests that handles multi-byte UTF-8 characters correctly. + /// + [Fact] + public async Task JsonContentFactory_ShouldHandleMultiByteCharacters() + { + // Arrange + const string contentType = "application/json"; + const string content = "{\"emoji\": \"😀\", \"chinese\": \"你好\"}"; // Contains multi-byte UTF-8 characters + var factory = new JsonContentFactory(); + + // Act + var httpContent = factory.CreateHttpContent(content, contentType); + var bytes = await httpContent.ReadAsByteArrayAsync(); + + // Assert + // Byte count should be greater than character count due to multi-byte characters + Assert.True(bytes.Length > content.Length); + // Content length should match the actual UTF-8 byte count + Assert.Equal(Encoding.UTF8.GetByteCount(content), bytes.Length); + Assert.Equal(httpContent.Headers.ContentLength, bytes.Length); + } } \ No newline at end of file diff --git a/test/unit/Elsa.Http.UnitTests/Elsa.Http.UnitTests.csproj b/test/unit/Elsa.Http.UnitTests/Elsa.Http.UnitTests.csproj index 1c1478c59..243f0b5da 100644 --- a/test/unit/Elsa.Http.UnitTests/Elsa.Http.UnitTests.csproj +++ b/test/unit/Elsa.Http.UnitTests/Elsa.Http.UnitTests.csproj @@ -1,4 +1,9 @@  + + + [Elsa.Http]* + + diff --git a/test/unit/Elsa.Scheduling.UnitTests/Elsa.Scheduling.UnitTests.csproj b/test/unit/Elsa.Scheduling.UnitTests/Elsa.Scheduling.UnitTests.csproj index 2d658b835..873dc28ec 100644 --- a/test/unit/Elsa.Scheduling.UnitTests/Elsa.Scheduling.UnitTests.csproj +++ b/test/unit/Elsa.Scheduling.UnitTests/Elsa.Scheduling.UnitTests.csproj @@ -1,4 +1,10 @@  + + + [Elsa.Scheduling]* + 1 + + diff --git a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs index 9bf59db61..925f84d49 100644 --- a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs +++ b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs @@ -1,8 +1,6 @@ using Elsa.Common; using Elsa.Mediator.Contracts; -using Elsa.Scheduling; using Elsa.Scheduling.ScheduledTasks; -using Elsa.Scheduling.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; @@ -16,8 +14,7 @@ public class ScheduledCronTaskTests : IDisposable { private const string DefaultCronExpression = "0 */5 * * * *"; private static readonly DateTimeOffset DefaultNow = new(2025, 11, 06, 22, 50, 00, 0, TimeSpan.Zero); - - private readonly ServiceCollection _services; + private readonly ServiceProvider _serviceProvider; private readonly ISystemClock _systemClock; private readonly ICronParser _cronParser; @@ -26,13 +23,13 @@ public class ScheduledCronTaskTests : IDisposable public ScheduledCronTaskTests() { - _services = new ServiceCollection(); + var services = new ServiceCollection(); _systemClock = Substitute.For(); _cronParser = Substitute.For(); _logger = Substitute.For>(); - _services.AddSingleton(Substitute.For()); - _serviceProvider = _services.BuildServiceProvider(); + services.AddSingleton(Substitute.For()); + _serviceProvider = services.BuildServiceProvider(); } private ScheduledCronTask CreateScheduledTask( @@ -106,10 +103,16 @@ public class ScheduledCronTaskTests : IDisposable SetupCronParser(DefaultNow, DefaultNow.AddMinutes(5)); // First: delay=0, Second: proper future time // Act - CreateScheduledTask(); + var task = CreateScheduledTask(); - // Assert - Should call GetNextOccurrence twice (once for initial delay=0, once for retry) - _cronParser.Received(2).GetNextOccurrence(DefaultCronExpression); + // Dispose immediately to prevent timer from firing + ((IDisposable)task).Dispose(); + + // Assert - Should call GetNextOccurrence at least twice (initial + retry) + // May be called more if timer fires before disposal in rare race conditions + _cronParser.Received().GetNextOccurrence(DefaultCronExpression); + var calls = _cronParser.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(_cronParser.GetNextOccurrence)); + Assert.True(calls >= 2, $"Expected at least 2 calls to GetNextOccurrence, but got {calls}"); } [Fact] @@ -120,10 +123,16 @@ public class ScheduledCronTaskTests : IDisposable SetupCronParser(DefaultNow.AddMinutes(-1), DefaultNow.AddMinutes(5)); // First: past, Second: future // Act - CreateScheduledTask(); + var task = CreateScheduledTask(); - // Assert - Should call GetNextOccurrence twice - _cronParser.Received(2).GetNextOccurrence(DefaultCronExpression); + // Dispose immediately to prevent timer from firing + ((IDisposable)task).Dispose(); + Thread.Sleep(5); // Brief wait to ensure disposal completes + + // Assert - Should call GetNextOccurrence at least twice + _cronParser.Received().GetNextOccurrence(DefaultCronExpression); + var calls = _cronParser.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(_cronParser.GetNextOccurrence)); + Assert.True(calls >= 2, $"Expected at least 2 calls to GetNextOccurrence, but got {calls}"); } [Fact] @@ -131,14 +140,22 @@ public class ScheduledCronTaskTests : IDisposable { // Arrange - simulate the bug scenario: both attempts return zero/negative delay // This can happen if the system clock doesn't advance or if there's clock drift + // After the initial attempts, return a proper future time to prevent timer from firing during test SetupSystemClock(DefaultNow); - SetupCronParser(DefaultNow); // Both calls return exactly now (delay = 0) + SetupCronParser(DefaultNow, DefaultNow, DefaultNow.AddMinutes(5)); // First two return now, third returns future // Act - This should not crash and should set up a timer with minimum delay - CreateScheduledTask(); + var task = CreateScheduledTask(); - // Assert - Should call GetNextOccurrence twice (initial + retry) and log warning - _cronParser.Received(2).GetNextOccurrence(DefaultCronExpression); + // Dispose immediately to prevent timer from firing and recursing + _tasksToDispose.Remove(task); + ((IDisposable)task).Dispose(); + + // Assert - Should call GetNextOccurrence at least twice (initial + retry) + // May be called more if timer fires before disposal and triggers Schedule() again + _cronParser.Received().GetNextOccurrence(DefaultCronExpression); + var calls = _cronParser.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(_cronParser.GetNextOccurrence)); + Assert.True(calls >= 2, $"Expected at least 2 calls to GetNextOccurrence, but got {calls}"); AssertWarningLogged(); } @@ -147,11 +164,16 @@ public class ScheduledCronTaskTests : IDisposable { // Arrange - simulate a case where even after retry, delay is negative // This could happen due to system clock adjustments + // After the initial attempts, return a proper future time to prevent timer from firing during test SetupSystemClock(DefaultNow, DefaultNow); - SetupCronParser(DefaultNow.AddMilliseconds(-100), DefaultNow.AddMilliseconds(-50)); + SetupCronParser(DefaultNow.AddMilliseconds(-100), DefaultNow.AddMilliseconds(-50), DefaultNow.AddMinutes(5)); // Act - Should handle negative delay gracefully - CreateScheduledTask(); + var task = CreateScheduledTask(); + + // Dispose immediately to prevent timer from firing + _tasksToDispose.Remove(task); + ((IDisposable)task).Dispose(); // Assert - Should log a warning and still set up timer AssertWarningLogged(); @@ -180,6 +202,7 @@ public class ScheduledCronTaskTests : IDisposable public void Dispose() { + // Dispose tasks first to stop timers before disposing ServiceProvider foreach (var task in _tasksToDispose) { ((IDisposable)task).Dispose(); diff --git a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs new file mode 100644 index 000000000..0d702c406 --- /dev/null +++ b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs @@ -0,0 +1,197 @@ +using Elsa.Common; +using Elsa.Mediator.Contracts; +using Elsa.Scheduling.ScheduledTasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Elsa.Scheduling.UnitTests.ScheduledTasks; + +/// +/// Tests for ScheduledRecurringTask to ensure recurring tasks handle edge cases correctly. +/// +public class ScheduledRecurringTaskTests : IDisposable +{ + private static readonly DateTimeOffset DefaultNow = new(2025, 11, 06, 22, 50, 00, 0, TimeSpan.Zero); + private static readonly TimeSpan DefaultInterval = TimeSpan.FromMinutes(5); + + private readonly ServiceProvider _serviceProvider; + private readonly ISystemClock _systemClock; + private readonly ILogger _logger; + private readonly List _tasksToDispose = new(); + + public ScheduledRecurringTaskTests() + { + var services = new ServiceCollection(); + _systemClock = Substitute.For(); + _logger = Substitute.For>(); + + services.AddSingleton(Substitute.For()); + _serviceProvider = services.BuildServiceProvider(); + } + + private ScheduledRecurringTask CreateScheduledTask( + DateTimeOffset? startAt = null, + TimeSpan? interval = null, + ISystemClock? systemClock = null) + { + var task = Substitute.For(); + var scheduledTask = new ScheduledRecurringTask( + task, + startAt ?? DefaultNow.AddMinutes(5), + interval ?? DefaultInterval, + systemClock ?? _systemClock, + _serviceProvider.CreateScope().ServiceProvider.GetRequiredService(), + _logger + ); + _tasksToDispose.Add(scheduledTask); + return scheduledTask; + } + + private void SetupSystemClock(params DateTimeOffset[] times) + { + _systemClock.UtcNow.Returns(times[0], times.Skip(1).ToArray()); + } + + private void AssertNoErrorLogged() + { + _logger.DidNotReceive().Log( + LogLevel.Error, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + private void AssertWarningLogged(int expectedCount = 1) + { + _logger.Received(expectedCount).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public void Schedule_WithVerySmallDelay_ShouldStillSetupTimer() + { + // Arrange - simulate a case where the delay is very small (1 tick = 100ns) + SetupSystemClock(DefaultNow); + var startAt = DefaultNow.AddTicks(1); // Only 1 tick in the future (100 nanoseconds) + + // Act + CreateScheduledTask(startAt: startAt); + + // Assert - Verify that no error was logged (timer should be set up successfully) + AssertNoErrorLogged(); + } + + [Fact] + public void Schedule_WithZeroDelay_ShouldRetryAndSetupTimer() + { + // Arrange - simulate a case where the first call returns exactly now + // but the second call returns a proper future time + SetupSystemClock(DefaultNow, DefaultNow); + var startAt = DefaultNow; // First: delay=0 + + // Act + var task = CreateScheduledTask(startAt: startAt); + + // Dispose immediately to prevent timer from firing + ((IDisposable)task).Dispose(); + + // Assert - System clock should be called at least twice (initial + retry) + // May be called more if timer fires before disposal in rare race conditions + _ = _systemClock.Received().UtcNow; + var calls = _systemClock.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "get_UtcNow"); + Assert.True(calls >= 2, $"Expected at least 2 calls to UtcNow, but got {calls}"); + } + + [Fact] + public void Schedule_WithNegativeDelay_ShouldRetryAndSetupTimer() + { + // Arrange - simulate a case where the first call returns a time in the past + SetupSystemClock(DefaultNow, DefaultNow); + var startAt = DefaultNow.AddMinutes(-1); // Past time + + // Act + var task = CreateScheduledTask(startAt: startAt); + + // Dispose immediately to prevent timer from firing + ((IDisposable)task).Dispose(); + + // Assert - System clock should be called at least twice (initial + retry) + // May be called more if timer fires before disposal in rare race conditions + _ = _systemClock.Received().UtcNow; + var calls = _systemClock.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "get_UtcNow"); + Assert.True(calls >= 2, $"Expected at least 2 calls to UtcNow, but got {calls}"); + } + + [Fact] + public void Schedule_WithPersistentZeroDelay_ShouldLogWarningAndUseMinimumDelay() + { + // Arrange - simulate the bug scenario: both attempts return zero/negative delay + // This can happen if the system clock doesn't advance or if there's clock drift + SetupSystemClock(DefaultNow); + var startAt = DefaultNow; // Both calls return exactly now (delay = 0) + + // Act - This should not crash and should set up a timer with minimum delay + var task = CreateScheduledTask(startAt: startAt); + + // Dispose immediately to prevent timer from firing and recursing + ((IDisposable)task).Dispose(); + Thread.Sleep(5); // Brief wait to ensure disposal completes + + // Assert - Should call UtcNow at least twice (initial + retry) + // May be called more if timer fires before disposal + _ = _systemClock.Received().UtcNow; + var calls = _systemClock.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "get_UtcNow"); + Assert.True(calls >= 2, $"Expected at least 2 calls to UtcNow, but got {calls}"); + AssertWarningLogged(); + } + + [Fact] + public void Schedule_WithNegativeDelayAfterRetry_ShouldLogWarningAndUseMinimumDelay() + { + // Arrange - simulate a case where even after retry, delay is negative + // This could happen due to system clock adjustments + SetupSystemClock(DefaultNow, DefaultNow); + var startAt = DefaultNow.AddMilliseconds(-100); // Negative delay + + // Act - Should handle negative delay gracefully + var task = CreateScheduledTask(startAt: startAt); + + // Dispose immediately to prevent timer from firing + ((IDisposable)task).Dispose(); + + // Assert - Should log a warning and still set up timer + AssertWarningLogged(); + } + + [Fact] + public void DisposeDuringTimerCallback_ShouldNotCrash() + { + // Arrange - set up a very short delay so timer fires quickly + SetupSystemClock(DefaultNow); + var startAt = DefaultNow; // Will use 1ms minimum delay + + // Act - Create task and immediately dispose it (simulating race condition) + var task = CreateScheduledTask(startAt: startAt); + Thread.Sleep(5); // Give timer a chance to start firing + ((IDisposable)task).Dispose(); + Thread.Sleep(10); // Give any in-flight callbacks time to complete + + // Assert - Should not crash (implicit - test passes if no exception thrown) + } + + public void Dispose() + { + // Dispose tasks first to stop timers before disposing ServiceProvider + foreach (var task in _tasksToDispose) + { + ((IDisposable)task).Dispose(); + } + _serviceProvider.Dispose(); + } +} diff --git a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledSpecificInstantTaskTests.cs b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledSpecificInstantTaskTests.cs new file mode 100644 index 000000000..8321d796b --- /dev/null +++ b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledSpecificInstantTaskTests.cs @@ -0,0 +1,142 @@ +using Elsa.Common; +using Elsa.Mediator.Contracts; +using Elsa.Scheduling.ScheduledTasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Elsa.Scheduling.UnitTests.ScheduledTasks; + +/// +/// Tests for ScheduledSpecificInstantTask to ensure specific instant tasks handle edge cases correctly. +/// +public class ScheduledSpecificInstantTaskTests : IDisposable +{ + private static readonly DateTimeOffset DefaultNow = new(2025, 11, 06, 22, 50, 00, 0, TimeSpan.Zero); + + private readonly ServiceProvider _serviceProvider; + private readonly ISystemClock _systemClock; + private readonly ILogger _logger; + private readonly List _tasksToDispose = new(); + + public ScheduledSpecificInstantTaskTests() + { + var services = new ServiceCollection(); + _systemClock = Substitute.For(); + _logger = Substitute.For>(); + + services.AddSingleton(Substitute.For()); + _serviceProvider = services.BuildServiceProvider(); + } + + private ScheduledSpecificInstantTask CreateScheduledTask( + DateTimeOffset? startAt = null, + ISystemClock? systemClock = null) + { + var task = Substitute.For(); + var scheduledTask = new ScheduledSpecificInstantTask( + task, + startAt ?? DefaultNow.AddMinutes(5), + systemClock ?? _systemClock, + _serviceProvider.CreateScope().ServiceProvider.GetRequiredService(), + _logger + ); + _tasksToDispose.Add(scheduledTask); + return scheduledTask; + } + + private void SetupSystemClock(params DateTimeOffset[] times) + { + _systemClock.UtcNow.Returns(times[0], times.Skip(1).ToArray()); + } + + private void AssertNoErrorLogged() + { + _logger.DidNotReceive().Log( + LogLevel.Error, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + private void AssertWarningLogged(int expectedCount = 1) + { + _logger.Received(expectedCount).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public void Schedule_WithVerySmallDelay_ShouldStillSetupTimer() + { + // Arrange - simulate a case where the delay is very small (1 tick = 100ns) + SetupSystemClock(DefaultNow); + var startAt = DefaultNow.AddTicks(1); // Only 1 tick in the future (100 nanoseconds) + + // Act + CreateScheduledTask(startAt: startAt); + + // Assert - Verify that no error was logged (timer should be set up successfully) + AssertNoErrorLogged(); + } + + [Fact] + public void Schedule_WithZeroDelay_ShouldUseMinimumDelay() + { + // Arrange - simulate a case where startAt is exactly now + SetupSystemClock(DefaultNow); + var startAt = DefaultNow; // delay = 0 + + // Act - Should adjust to 1ms minimum delay + CreateScheduledTask(startAt: startAt); + + // Assert - Should not crash and should log warning + AssertWarningLogged(); + } + + [Fact] + public void Schedule_WithNegativeDelay_ShouldUseMinimumDelay() + { + // Arrange - simulate a case where startAt is in the past + SetupSystemClock(DefaultNow); + var startAt = DefaultNow.AddMinutes(-1); // Past time + + // Act - Should adjust to 1ms minimum delay + CreateScheduledTask(startAt: startAt); + + // Assert - Should log a warning + AssertWarningLogged(); + } + + [Fact] + public void DisposeDuringTimerCallback_ShouldNotCrash() + { + // Arrange - set up a very short delay so timer fires quickly + SetupSystemClock(DefaultNow); + var startAt = DefaultNow; // Will use 1ms minimum delay + + // Act - Create task and immediately dispose it (simulating race condition) + var task = CreateScheduledTask(startAt: startAt); + Thread.Sleep(5); // Give timer a chance to start firing + ((IDisposable)task).Dispose(); + Thread.Sleep(10); // Give any in-flight callbacks time to complete + + // Assert - Should not crash (implicit - test passes if no exception thrown) + } + + public void Dispose() + { + // Dispose tasks first to stop timers before disposing ServiceProvider + foreach (var task in _tasksToDispose) + { + ((IDisposable)task).Dispose(); + } + // Small delay to ensure any timer callbacks have completed + Thread.Sleep(10); + _serviceProvider.Dispose(); + } +} 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 af5003d06..f66aa4ad8 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,5 +1,10 @@ - + + + [Elsa.Workflows.Core]* + 7 + + diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Usings.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Usings.cs deleted file mode 100644 index 8c927eb74..000000000 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file 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 index ac0c7cfde..8a285db5e 100644 --- a/test/unit/Elsa.Workflows.Management.UnitTests/Elsa.Workflows.Management.UnitTests.csproj +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Elsa.Workflows.Management.UnitTests.csproj @@ -1,5 +1,9 @@  + + [Elsa.Workflows.Management]* + + diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Elsa.Workflows.Runtime.UnitTests.csproj b/test/unit/Elsa.Workflows.Runtime.UnitTests/Elsa.Workflows.Runtime.UnitTests.csproj index 65c29eeae..fded55412 100644 --- a/test/unit/Elsa.Workflows.Runtime.UnitTests/Elsa.Workflows.Runtime.UnitTests.csproj +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/Elsa.Workflows.Runtime.UnitTests.csproj @@ -1,5 +1,9 @@ + + [Elsa.Workflows.Runtime]* + + diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Usings.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Usings.cs deleted file mode 100644 index 8c927eb74..000000000 --- a/test/unit/Elsa.Workflows.Runtime.UnitTests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file