diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 000000000..cb262164f
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,230 @@
+# Copilot Coding Agent Instructions for Elsa Workflows
+
+## Repository Overview
+
+**Elsa Workflows** is a powerful .NET workflow library that enables workflow execution within any .NET application. This is version 3.0, supporting .NET 9.0 and providing both a visual designer and programmatic workflow definition capabilities.
+
+### Key Statistics
+- **Language**: C# (.NET 9.0)
+- **Architecture**: Modular library with 104+ projects
+- **Code Size**: ~3,500 C# files across modules
+- **License**: MIT
+- **Build System**: NUKE build automation
+- **Target Frameworks**: .NET 9.0 (primary)
+
+## High-Level Architecture
+
+### Directory Structure
+```
+src/
+├── apps/ # Reference applications (5 projects)
+│ ├── Elsa.Server.Web # Workflow server only
+│ ├── Elsa.ServerAndStudio.Web # Combined server + studio
+│ ├── Elsa.Studio.Web # Studio web interface
+│ ├── ElsaStudioWebAssembly # Studio WebAssembly app
+│ └── Elsa.Server.LoadBalancer # Load balancer
+├── common/ # Shared libraries (8 projects)
+├── modules/ # Core functionality modules (70+ projects)
+│ ├── Elsa.Workflows.Core # Core workflow engine
+│ ├── Elsa.Workflows.Runtime # Runtime execution
+│ ├── Elsa.Workflows.Api # REST API
+│ ├── Elsa.Http # HTTP activities
+│ ├── Elsa.Email # Email activities
+│ └── [many others] # Database, messaging, etc.
+└── clients/ # API clients
+test/
+├── unit/ # Unit tests
+├── integration/ # Integration tests
+├── component/ # Component tests
+└── performance/ # Performance tests
+build/ # NUKE build configuration
+docker/ # Docker configurations
+```
+
+### Core Components
+- **Elsa.Workflows.Core**: Main workflow engine and activities
+- **Elsa.Workflows.Runtime**: Workflow execution runtime
+- **Elsa.Workflows.Api**: RESTful API for workflow management
+- **Elsa.Workflows.Management**: Workflow definition management
+- **Elsa modules**: Specialized functionality (HTTP, email, scheduling, etc.)
+
+## Build Instructions
+
+### Prerequisites
+- **.NET 9.0 SDK** (verified working version: 9.0.305)
+- **Build time**: Initial restore ~1-2 minutes, full compile ~5-10 minutes
+
+### Critical Build Information
+
+⚠️ **IMPORTANT**: The repository has external dependencies that may cause build failures:
+
+1. **External NuGet Feeds**: Some projects depend on packages from:
+ - `https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json` (Elsa Studio packages)
+ - `https://f.feedz.io/sfmskywalker/webhooks-core/nuget/index.json` (Webhooks packages)
+
+2. **Build Failure Workarounds**:
+ - Studio apps (`Elsa.Studio.Web`, `ElsaStudioWebAssembly`, `Elsa.ServerAndStudio.Web`) depend on prebuilt studio packages that may not be accessible
+ - Server app (`Elsa.Server.Web`) depends on WebhooksCore package that may not be accessible
+ - Core workflow functionality can be built independently
+ - Some test projects may fail due to missing external packages
+
+### Build Commands
+
+**Primary build script**: `./build.sh` (Linux/macOS) or `.\build.cmd` (Windows)
+
+```bash
+# View available targets
+./build.sh --help
+
+# Clean build artifacts
+./build.sh Clean
+
+# Restore packages (may show warnings for inaccessible feeds)
+./build.sh Restore --ignore-failed-sources
+
+# Compile core components (excludes studio apps)
+./build.sh Compile
+
+# Run tests (limited due to external dependencies)
+./build.sh Test
+
+# Create NuGet packages
+./build.sh Pack
+
+# Full CI pipeline (compile, test, pack)
+./build.sh Compile Test Pack
+```
+
+**Direct dotnet commands** for core components:
+```bash
+# Build specific core projects that don't require external packages
+dotnet restore src/modules/Elsa.Workflows.Core/ --ignore-failed-sources
+dotnet build src/modules/Elsa.Workflows.Core/ --no-restore
+dotnet restore src/modules/Elsa.Workflows.Runtime/ --ignore-failed-sources
+dotnet build src/modules/Elsa.Workflows.Runtime/ --no-restore
+
+# Note: Server apps may fail due to WebhooksCore dependency
+# Build and test individual core modules
+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
+- Most modules show "succeeded with X warning(s)" (warnings are acceptable)
+
+## Testing
+
+### Test Structure
+- **Unit tests**: `test/unit/` - Fast, isolated tests
+- **Integration tests**: `test/integration/` - End-to-end scenarios
+- **Component tests**: `test/component/` - Feature testing
+- **Performance tests**: `test/performance/` - Benchmarks
+
+### Running Tests
+```bash
+# Via NUKE build system
+./build.sh Test
+
+# Direct dotnet test (for accessible projects)
+dotnet test test/unit/[specific-project]/
+dotnet test --no-build --no-restore [project-path]
+```
+
+**Note**: Many tests may fail to run due to external package dependencies. Focus on core workflow engine tests that don't require studio packages.
+
+## Development Guidelines
+
+### Code Standards
+- **Language version**: C# latest
+- **Target framework**: .NET 9.0
+- **Nullable reference types**: Enabled
+- **Implicit usings**: Enabled
+- **EditorConfig**: Configured (4-space indentation, CRLF line endings)
+
+### Architecture Patterns
+- **Modular design**: Each feature area is a separate project/module
+- **Dependency injection**: Heavy use of Microsoft.Extensions.DependencyInjection
+- **Activity-based**: Workflows are built from composable activities
+- **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
+
+## Continuous Integration
+
+### GitHub Actions Workflow
+- **Trigger**: Pull requests to `main` branch
+- **Runner**: ubuntu-latest
+- **.NET Version**: 9.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
+3. Execute: Compile → Test → Pack
+4. Expected warnings for external feed access
+5. Studio apps may be excluded from CI builds
+
+## Key Configuration Files
+
+- **Build**: `build/Build.cs` (NUKE build configuration)
+- **Dependencies**: `Directory.Packages.props` (central package management)
+- **Global settings**: `Directory.Build.props`
+- **NuGet**: `NuGet.Config` (package sources and mapping)
+- **Solution**: `Elsa.sln` (119 projects)
+- **Docker**: `docker/` directory with multiple Dockerfiles
+- **GitHub Actions**: `.github/workflows/` (auto-generated)
+
+## Docker Support
+
+Multiple Docker configurations available:
+- `ElsaServer.Dockerfile` - Server only
+- `ElsaServerAndStudio.Dockerfile` - Combined server + studio
+- `ElsaStudio.Dockerfile` - Studio only
+- Docker Compose configurations for development
+
+## Quick Start for Development
+
+1. **Clone and build core components**:
+ ```bash
+ git clone [repo-url]
+ cd elsa-core
+ ./build.sh Clean Restore --ignore-failed-sources
+ ```
+
+2. **Work with core modules** (avoid studio dependencies):
+ ```bash
+ cd src/modules/Elsa.Workflows.Core
+ dotnet build
+ dotnet test ../../test/unit/[related-tests]/
+ ```
+
+3. **Work with core modules that don't require external dependencies**:
+ ```bash
+ cd src/modules/Elsa.Workflows.Core
+ dotnet restore --ignore-failed-sources
+ dotnet build --no-restore
+ ```
+
+## 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
+
+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/docker-ca.yml b/.github/workflows/docker-ca.yml
new file mode 100644
index 000000000..e8a737d48
--- /dev/null
+++ b/.github/workflows/docker-ca.yml
@@ -0,0 +1,72 @@
+name: Docker certificate smoke tests
+
+on:
+ pull_request:
+ branches:
+ - main
+ paths:
+ - 'docker/**'
+ - 'scripts/test-ca-trust.sh'
+ - 'test/TlsSmoke/**'
+ - '.github/workflows/docker-ca.yml'
+ push:
+ branches:
+ - main
+ paths:
+ - 'docker/**'
+ - 'scripts/test-ca-trust.sh'
+ - 'test/TlsSmoke/**'
+ - '.github/workflows/docker-ca.yml'
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: elsa-server
+ dockerfile: docker/ElsaServer.Dockerfile
+ image: elsa-server-smoke
+ skip_otel: '0'
+ - name: elsa-server-and-studio
+ dockerfile: docker/ElsaServerAndStudio.Dockerfile
+ image: elsa-server-and-studio-smoke
+ skip_otel: '0'
+ - name: elsa-studio
+ dockerfile: docker/ElsaStudio.Dockerfile
+ image: elsa-studio-smoke
+ skip_otel: '0'
+ - name: elsa-server-datadog
+ dockerfile: docker/ElsaServer-Datadog.Dockerfile
+ image: elsa-server-datadog-smoke
+ skip_otel: '1'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Publish TLS smoke test app
+ run: dotnet publish test/TlsSmoke/TlsSmoke.csproj -c Release -o artifacts/tls-smoke
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build ${{ matrix.name }} image
+ run: docker build -f ${{ matrix.dockerfile }} -t ${{ matrix.image }} .
+
+ - name: Run TLS smoke tests
+ env:
+ SKIP_OTEL: ${{ matrix.skip_otel }}
+ run: |
+ set -euo pipefail
+ TLS_APP_DIR="${{ github.workspace }}/artifacts/tls-smoke"
+ if [ "$SKIP_OTEL" = "1" ]; then
+ scripts/test-ca-trust.sh "${{ matrix.image }}" "$TLS_APP_DIR" -e ELSA_SKIP_OTEL_AUTO=1
+ else
+ scripts/test-ca-trust.sh "${{ matrix.image }}" "$TLS_APP_DIR"
+ fi
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 37e306f02..903d05d30 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,7 +5,7 @@
3.6.0-preview.1175
- 9.0.8
+ 9.0.9
9.7.0
diff --git a/Elsa.sln b/Elsa.sln
index f1c26d414..3b7647720 100644
--- a/Elsa.sln
+++ b/Elsa.sln
@@ -59,14 +59,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{986E54
docker\ElsaStudio.Dockerfile = docker\ElsaStudio.Dockerfile
docker\init-db-postgres.sh = docker\init-db-postgres.sh
docker\otel-collector-config.yaml = docker\otel-collector-config.yaml
+ docker\docker-compose-datadog+otel-collector.yml = docker\docker-compose-datadog+otel-collector.yml
+ docker\entrypoint.sh = docker\entrypoint.sh
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unit", "unit", "{18453B51-25EB-4317-A4B3-B10518252E92}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration", "{1B8D5897-902E-4632-8698-E89CAF3DDF54}"
- ProjectSection(SolutionItems) = preProject
- test\integration\Elsa.Logging.Core.LoggerSinkTests.cs = test\integration\Elsa.Logging.Core.LoggerSinkTests.cs
- EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "component", "component", "{08B41FFA-CEE3-46A7-B5C0-3EB65D37A16C}"
ProjectSection(SolutionItems) = preProject
diff --git a/README.md b/README.md
index 09b323c49..575564ab5 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,32 @@ By default, you can access http://localhost:13000 and log in with:
Password: password
```
+### TLS and custom certificate authorities
+
+All Elsa Docker images now ship with the operating system's certificate authority bundle baked in at build time. This means you can call public HTTPS endpoints such as `https://example.com` without any additional configuration.
+
+If you need to trust a private or corporate CA, mount the certificate bundle into the container and reference it via `EXTRA_CA_CERT`:
+
+```bash
+docker run \
+ -v /path/to/company-ca.crt:/certs/company-ca.crt:ro \
+ -e EXTRA_CA_CERT=/certs/company-ca.crt \
+ elsaworkflows/elsa-server-and-studio-v3:latest
+```
+
+On startup, the container copies the certificate into `/usr/local/share/ca-certificates` and runs `update-ca-certificates`, making the trust available to .NET, OpenSSL, curl, and other system components. Multiple certificates can be provided by pointing `EXTRA_CA_CERT` at a directory containing `.crt` or `.pem` files.
+
+In highly restricted environments where you cannot modify the system trust store, you can instead rely on the standard `SSL_CERT_FILE` or `SSL_CERT_DIR` environment variables:
+
+```bash
+docker run \
+ -v /path/to/company-ca-bundle.pem:/certs/custom.pem:ro \
+ -e SSL_CERT_FILE=/certs/custom.pem \
+ elsaworkflows/elsa-server-and-studio-v3:latest
+```
+
+> ℹ️ Installing the CA bundle adds roughly 300KB to the Debian-based images. No package managers run at container startup; all trust updates happen immutably at build time or via the mounted certificates shown above.
+
## Table of Contents
- [Documentation](#documentation)
diff --git a/docker/ElsaServer-Datadog.Dockerfile b/docker/ElsaServer-Datadog.Dockerfile
index 7d5022c63..b7f1a3f4b 100644
--- a/docker/ElsaServer-Datadog.Dockerfile
+++ b/docker/ElsaServer-Datadog.Dockerfile
@@ -1,5 +1,5 @@
# Version: 1
-# Description: Dockerfile for building and running Elsa Server
+# Description: Dockerfile for building and running Elsa Server with Datadog and OpenTelemetry auto-instrumentation
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build
WORKDIR /source
@@ -22,36 +22,40 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base
WORKDIR /app
COPY --from=build /app/publish ./
-# Install Python 3.11
-RUN apt-get update && apt-get install -y --no-install-recommends \
- python3.11 \
- python3.11-dev \
- libpython3.11 \
- python3-pip && \
- rm -rf /var/lib/apt/lists/*
+# Install runtime dependencies, including CA certificates.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ curl \
+ libpython3.11 \
+ python3.11 \
+ python3.11-dev \
+ python3-pip \
+ unzip \
+ wget \
+ && update-ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY docker/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
# Set PYTHONNET_PYDLL environment variable
ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so
-# Install dependencies
-RUN apt-get update && apt-get install -y wget unzip curl
-
# Set environment variables for OpenTelemetry Auto-Instrumentation
-ENV OTEL_DOTNET_AUTO_HOME=/otel
-ENV OTEL_LOG_LEVEL="debug"
+ENV OTEL_DOTNET_AUTO_HOME=/otel \
+ OTEL_LOG_LEVEL="debug"
# Download and extract OpenTelemetry Auto-Instrumentation
ARG OTEL_VERSION=1.7.0
-RUN mkdir /otel
-RUN curl -L -o /otel/otel-dotnet-install.sh https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/v${OTEL_VERSION}/otel-dotnet-auto-install.sh
-RUN chmod +x /otel/otel-dotnet-install.sh
-RUN /bin/bash /otel/otel-dotnet-install.sh
-
-# Provide necessary permissions for the script to execute
-RUN chmod +x /otel/instrument.sh
+RUN mkdir -p /otel \
+ && curl -L -o /otel/otel-dotnet-install.sh "https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/v${OTEL_VERSION}/otel-dotnet-auto-install.sh" \
+ && chmod +x /otel/otel-dotnet-install.sh \
+ && /bin/bash /otel/otel-dotnet-install.sh \
+ && chmod +x /otel/instrument.sh
EXPOSE 8080/tcp
EXPOSE 443/tcp
-# Instrument the application and start it
-ENTRYPOINT ["/bin/bash", "-c", "source /otel/instrument.sh && dotnet Elsa.Server.Web.dll"]
\ No newline at end of file
+ENTRYPOINT ["/entrypoint.sh"]
+CMD ["dotnet", "Elsa.Server.Web.dll"]
diff --git a/docker/ElsaServer.Dockerfile b/docker/ElsaServer.Dockerfile
index b31307eb9..2695ca4de 100644
--- a/docker/ElsaServer.Dockerfile
+++ b/docker/ElsaServer.Dockerfile
@@ -19,17 +19,24 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base
WORKDIR /app
COPY --from=build /app/publish ./
-# Install Python 3.11
-RUN apt-get update && apt-get install -y --no-install-recommends \
- python3.11 \
- python3.11-dev \
- libpython3.11 \
- python3-pip && \
- rm -rf /var/lib/apt/lists/*
+# Install runtime dependencies, including CA certificates.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ libpython3.11 \
+ python3.11 \
+ python3.11-dev \
+ python3-pip \
+ && update-ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY docker/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
# Set PYTHONNET_PYDLL environment variable
ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so
EXPOSE 8080/tcp
EXPOSE 443/tcp
-ENTRYPOINT ["dotnet", "Elsa.Server.Web.dll"]
+ENTRYPOINT ["/entrypoint.sh"]
+CMD ["dotnet", "Elsa.Server.Web.dll"]
diff --git a/docker/ElsaServerAndStudio.Dockerfile b/docker/ElsaServerAndStudio.Dockerfile
index 27b33e992..aaadba910 100644
--- a/docker/ElsaServerAndStudio.Dockerfile
+++ b/docker/ElsaServerAndStudio.Dockerfile
@@ -20,17 +20,24 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base
WORKDIR /app
COPY --from=build /app/publish ./
-# Install Python 3.11
-RUN apt-get update && apt-get install -y --no-install-recommends \
- python3.11 \
- python3.11-dev \
- libpython3.11 \
- python3-pip && \
- rm -rf /var/lib/apt/lists/*
+# Install runtime dependencies, including CA certificates.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ libpython3.11 \
+ python3.11 \
+ python3.11-dev \
+ python3-pip \
+ && update-ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY docker/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
# Set PYTHONNET_PYDLL environment variable
ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so
EXPOSE 8080/tcp
EXPOSE 443/tcp
-ENTRYPOINT ["dotnet", "Elsa.ServerAndStudio.Web.dll"]
+ENTRYPOINT ["/entrypoint.sh"]
+CMD ["dotnet", "Elsa.ServerAndStudio.Web.dll"]
diff --git a/docker/ElsaStudio.Dockerfile b/docker/ElsaStudio.Dockerfile
index 7f2eba83c..6e76acb3d 100644
--- a/docker/ElsaStudio.Dockerfile
+++ b/docker/ElsaStudio.Dockerfile
@@ -20,6 +20,16 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base
WORKDIR /app
COPY --from=build /app/publish ./
+# Install CA certificates so HTTPS works out of the box.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates \
+ && update-ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY docker/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
EXPOSE 8080/tcp
EXPOSE 443/tcp
-ENTRYPOINT ["dotnet", "Elsa.Studio.Web.dll"]
+ENTRYPOINT ["/entrypoint.sh"]
+CMD ["dotnet", "Elsa.Studio.Web.dll"]
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100755
index 000000000..c3f492692
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,103 @@
+#!/bin/sh
+set -e
+
+log() {
+ printf '%s\n' "$*" >&2
+}
+
+normalise_dest_name() {
+ name=$(basename "$1")
+ case "$name" in
+ *.crt|*.pem) printf '%s\n' "$name" ;;
+ *) printf '%s.crt\n' "$name" ;;
+ esac
+}
+
+install_extra_certificates() {
+ cert_source="$1"
+ if [ ! -e "$cert_source" ]; then
+ log "EXTRA_CA_CERT path '$cert_source' does not exist; skipping installation."
+ return
+ fi
+
+ target_root="/usr/local/share/ca-certificates/extra"
+ mkdir -p "$target_root"
+ rm -f "$target_root"/* 2>/dev/null || true
+
+ copied=0
+ if [ -f "$cert_source" ]; then
+ dest_name=$(normalise_dest_name "$cert_source")
+ cp "$cert_source" "$target_root/$dest_name"
+ copied=1
+ elif [ -d "$cert_source" ]; then
+ for file in "$cert_source"/*.crt "$cert_source"/*.pem; do
+ [ -f "$file" ] || continue
+ dest_name=$(normalise_dest_name "$file")
+ cp "$file" "$target_root/$dest_name"
+ copied=1
+ done
+ else
+ log "EXTRA_CA_CERT path '$cert_source' is neither a file nor a directory; skipping installation."
+ return
+ fi
+
+ if [ "$copied" -eq 0 ]; then
+ log "No certificate files found at '$cert_source'; skipping installation."
+ return
+ fi
+
+ if command -v update-ca-certificates >/dev/null 2>&1; then
+ if ! update-ca-certificates >/dev/null 2>&1; then
+ update-ca-certificates
+ fi
+ log "Installed custom certificate(s) from '$cert_source'."
+ elif command -v trust >/dev/null 2>&1; then
+ # Shellcheck disable because we intentionally glob.
+ # shellcheck disable=SC2086
+ for cert in "$target_root"/*.crt; do
+ [ -f "$cert" ] || continue
+ trust anchor "$cert"
+ done
+ log "Installed custom certificate(s) using 'trust' utility."
+ else
+ log "No known certificate installation tool found; custom CA may not be applied."
+ fi
+}
+
+maybe_instrument_with_otel() {
+ if [ "${ELSA_SKIP_OTEL_AUTO:-0}" = "1" ]; then
+ return
+ fi
+
+ if [ -z "${OTEL_DOTNET_AUTO_HOME:-}" ]; then
+ return
+ fi
+
+ instrument_script="${OTEL_DOTNET_AUTO_HOME%/}/instrument.sh"
+ if [ ! -f "$instrument_script" ]; then
+ return
+ fi
+
+ if ! command -v bash >/dev/null 2>&1; then
+ log "OpenTelemetry auto-instrumentation requested but bash is unavailable; skipping."
+ return
+ fi
+
+ tmp_wrapper="/tmp/elsa-otel-wrapper.sh"
+ cat <<'WRAPPER' > "$tmp_wrapper"
+#!/usr/bin/env bash
+set -e
+. "${OTEL_DOTNET_AUTO_HOME%/}/instrument.sh"
+exec "$@"
+WRAPPER
+ chmod +x "$tmp_wrapper"
+ exec "$tmp_wrapper" "$@"
+}
+
+if [ -n "${EXTRA_CA_CERT:-}" ]; then
+ install_extra_certificates "$EXTRA_CA_CERT"
+fi
+
+maybe_instrument_with_otel "$@"
+
+exec "$@"
diff --git a/scripts/test-ca-trust.sh b/scripts/test-ca-trust.sh
new file mode 100755
index 000000000..7c078606b
--- /dev/null
+++ b/scripts/test-ca-trust.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [ $# -lt 2 ]; then
+ echo "Usage: $0 [docker-run-arg ...]" >&2
+ exit 1
+fi
+
+IMAGE="$1"
+TLS_APP_DIR="$2"
+shift 2 || true
+DOCKER_ARGS=("$@")
+
+SMOKE_DLL=/tls/TlsSmoke.dll
+PUBLIC_URL="https://example.com"
+LOCAL_PORT=9443
+
+run_smoke() {
+ local url="$1"
+ shift
+ docker run --rm \
+ -v "${TLS_APP_DIR}:/tls:ro" \
+ "${DOCKER_ARGS[@]}" \
+ "$@" \
+ "$IMAGE" \
+ dotnet "$SMOKE_DLL" "$url"
+}
+
+run_public_test() {
+ echo "[CA] Validating public trust store against ${PUBLIC_URL}" >&2
+ run_smoke "$PUBLIC_URL"
+}
+
+start_local_ca_server() {
+ CERT_WORKDIR=$(mktemp -d)
+
+ openssl req -x509 -newkey rsa:2048 -days 2 -nodes -keyout "$CERT_WORKDIR/ca.key" -out "$CERT_WORKDIR/ca.crt" -subj "/CN=ElsaTestCA" >/dev/null 2>&1
+ openssl req -newkey rsa:2048 -nodes -keyout "$CERT_WORKDIR/server.key" -out "$CERT_WORKDIR/server.csr" -subj "/CN=host.docker.internal" >/dev/null 2>&1
+
+ cat < "$CERT_WORKDIR/server.ext"
+subjectAltName = DNS:localhost,DNS:host.docker.internal,IP:127.0.0.1
+extendedKeyUsage = serverAuth
+keyUsage = digitalSignature, keyEncipherment
+CERTEXT
+
+ openssl x509 -req -in "$CERT_WORKDIR/server.csr" -CA "$CERT_WORKDIR/ca.crt" -CAkey "$CERT_WORKDIR/ca.key" -CAcreateserial -out "$CERT_WORKDIR/server.crt" -days 2 -sha256 -extfile "$CERT_WORKDIR/server.ext" >/dev/null 2>&1
+
+ openssl s_server -quiet -accept "$LOCAL_PORT" -www -cert "$CERT_WORKDIR/server.crt" -key "$CERT_WORKDIR/server.key" >/dev/null 2>&1 &
+ SERVER_PID=$!
+
+ for _ in {1..20}; do
+ if nc -z localhost "$LOCAL_PORT" >/dev/null 2>&1; then
+ break
+ fi
+ sleep 0.2
+ done
+
+ echo "$CERT_WORKDIR"
+}
+
+run_extra_ca_test() {
+ local cert_dir
+ cert_dir=$(start_local_ca_server)
+ trap "kill ${SERVER_PID:-0} >/dev/null 2>&1 || true; rm -rf '$cert_dir'" EXIT
+
+ echo "[CA] Validating EXTRA_CA_CERT flow against local CA" >&2
+ run_smoke "https://host.docker.internal:${LOCAL_PORT}" \
+ --add-host host.docker.internal:host-gateway \
+ -v "${cert_dir}:/certs:ro" \
+ -e EXTRA_CA_CERT=/certs/ca.crt
+
+ echo "[CA] Validating SSL_CERT_FILE fallback" >&2
+ run_smoke "https://host.docker.internal:${LOCAL_PORT}" \
+ --add-host host.docker.internal:host-gateway \
+ -v "${cert_dir}:/certs:ro" \
+ -e SSL_CERT_FILE=/certs/ca.crt
+
+ mkdir -p "$cert_dir/dir"
+ cp "$cert_dir/ca.crt" "$cert_dir/dir/custom-ca.crt"
+ openssl rehash "$cert_dir/dir" >/dev/null 2>&1
+
+ echo "[CA] Validating SSL_CERT_DIR fallback" >&2
+ run_smoke "https://host.docker.internal:${LOCAL_PORT}" \
+ --add-host host.docker.internal:host-gateway \
+ -v "${cert_dir}:/certs:ro" \
+ -e SSL_CERT_DIR=/certs/dir
+
+ kill "${SERVER_PID:-0}" >/dev/null 2>&1 || true
+ wait "${SERVER_PID:-0}" 2>/dev/null || true
+ rm -rf "$cert_dir"
+ trap - EXIT
+}
+
+run_public_test
+run_extra_ca_test
diff --git a/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj.DotSettings b/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj.DotSettings
new file mode 100644
index 000000000..5145fa48d
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj.DotSettings
@@ -0,0 +1,2 @@
+
+ True
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedEventArgs.cs b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedEventArgs.cs
new file mode 100644
index 000000000..d31023be6
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedEventArgs.cs
@@ -0,0 +1,8 @@
+using Elsa.Workflows;
+
+namespace Elsa.Testing.Shared;
+
+public class ActivityExecutedEventArgs(ActivityExecutionContext activityExecutionContext) : EventArgs
+{
+ public ActivityExecutionContext ActivityExecutionContext { get; } = activityExecutionContext;
+}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedLogUpdatedEventArgs.cs b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedLogUpdatedEventArgs.cs
new file mode 100644
index 000000000..b747c608e
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedLogUpdatedEventArgs.cs
@@ -0,0 +1,10 @@
+using Elsa.Workflows;
+using Elsa.Workflows.Runtime.Entities;
+
+namespace Elsa.Testing.Shared;
+
+public class ActivityExecutedLogUpdatedEventArgs(WorkflowExecutionContext workflowExecutionContext, ICollection records) : EventArgs
+{
+ public WorkflowExecutionContext WorkflowExecutionContext { get; } = workflowExecutionContext;
+ public ICollection Records { get; } = records;
+}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Component/EventArgs/WorkflowStateCommittedEventArgs.cs b/src/common/Elsa.Testing.Shared.Component/EventArgs/WorkflowStateCommittedEventArgs.cs
new file mode 100644
index 000000000..4c1c0cf9f
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared.Component/EventArgs/WorkflowStateCommittedEventArgs.cs
@@ -0,0 +1,12 @@
+using Elsa.Workflows;
+using Elsa.Workflows.Management.Entities;
+using Elsa.Workflows.State;
+
+namespace Elsa.Testing.Shared;
+
+public class WorkflowStateCommittedEventArgs(WorkflowExecutionContext workflowExecutionContext, WorkflowState workflowState, WorkflowInstance workflowInstance) : EventArgs
+{
+ public WorkflowExecutionContext WorkflowExecutionContext { get; } = workflowExecutionContext;
+ public WorkflowState WorkflowState { get; } = workflowState;
+ public WorkflowInstance WorkflowInstance { get; } = workflowInstance;
+}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs b/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs
index abe71cb1d..b2aad6b04 100644
--- a/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs
+++ b/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs
@@ -2,6 +2,7 @@ using Elsa.Mediator.Contracts;
using Elsa.Testing.Shared.Services;
using Elsa.Workflows.Management.Notifications;
using Elsa.Workflows.Notifications;
+using Elsa.Workflows.Runtime.Notifications;
using JetBrains.Annotations;
namespace Elsa.Testing.Shared.Handlers;
@@ -9,17 +10,38 @@ namespace Elsa.Testing.Shared.Handlers;
[UsedImplicitly]
public class WorkflowEventHandlers(WorkflowEvents workflowEvents) :
INotificationHandler,
- INotificationHandler
+ INotificationHandler,
+ INotificationHandler,
+ INotificationHandler,
+ INotificationHandler
{
public Task HandleAsync(WorkflowFinished notification, CancellationToken cancellationToken)
{
- workflowEvents.OnWorkflowFinished(new WorkflowFinishedEventArgs(notification.Workflow, notification.WorkflowState));
+ workflowEvents.OnWorkflowFinished(new(notification.Workflow, notification.WorkflowState));
return Task.CompletedTask;
}
public Task HandleAsync(WorkflowInstanceSaved notification, CancellationToken cancellationToken)
{
- workflowEvents.OnWorkflowInstanceSaved(new WorkflowInstanceSavedEventArgs(notification.WorkflowInstance));
+ workflowEvents.OnWorkflowInstanceSaved(new(notification.WorkflowInstance));
+ return Task.CompletedTask;
+ }
+
+ public Task HandleAsync(WorkflowStateCommitted notification, CancellationToken cancellationToken)
+ {
+ workflowEvents.OnWorkflowStateCommitted(new(notification.WorkflowExecutionContext, notification.WorkflowState, notification.WorkflowInstance));
+ return Task.CompletedTask;
+ }
+
+ public Task HandleAsync(ActivityExecuted notification, CancellationToken cancellationToken)
+ {
+ workflowEvents.OnActivityExecuted(new(notification.ActivityExecutionContext));
+ return Task.CompletedTask;
+ }
+
+ public Task HandleAsync(ActivityExecutionLogUpdated notification, CancellationToken cancellationToken)
+ {
+ workflowEvents.OnActivityExecutedLogUpdated(new(notification.WorkflowExecutionContext, notification.Records));
return Task.CompletedTask;
}
}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs b/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs
index a90b9b931..2b31409f0 100644
--- a/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs
+++ b/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs
@@ -4,6 +4,12 @@ public class WorkflowEvents
{
public event EventHandler? WorkflowFinished;
public event EventHandler? WorkflowInstanceSaved;
+ public event EventHandler? WorkflowStateCommitted;
+ public event EventHandler? ActivityExecuted;
+ public event EventHandler? ActivityExecutedLogUpdated;
public void OnWorkflowFinished(WorkflowFinishedEventArgs args) => WorkflowFinished?.Invoke(this, args);
public void OnWorkflowInstanceSaved(WorkflowInstanceSavedEventArgs args) => WorkflowInstanceSaved?.Invoke(this, args);
+ public void OnWorkflowStateCommitted(WorkflowStateCommittedEventArgs args) => WorkflowStateCommitted?.Invoke(this, args);
+ public void OnActivityExecuted(ActivityExecutedEventArgs args) => ActivityExecuted?.Invoke(this, args);
+ public void OnActivityExecutedLogUpdated(ActivityExecutedLogUpdatedEventArgs args) => ActivityExecutedLogUpdated?.Invoke(this, args);
}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs
index 14ed38c3f..578802abd 100644
--- a/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs
+++ b/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs
@@ -5,6 +5,7 @@ using Elsa.Workflows;
using Elsa.Workflows.Notifications;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Contracts;
+using Elsa.Workflows.Runtime.Notifications;
using Elsa.Workflows.Runtime.Requests;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -13,7 +14,7 @@ namespace Elsa.Testing.Shared;
public static class DispatchWorkflowExtensions
{
- public static async Task DispatchWorkflowAndRunToCompletion(
+ public static async Task DispatchWorkflowAndRunToCompletion(
this IWorkflow workflowDefinition,
Action? configureServices = null,
Action? configureElsa = null,
@@ -21,7 +22,7 @@ public static class DispatchWorkflowExtensions
TimeSpan? timeout = null)
{
var semaphore = new SemaphoreSlim(0, 1);
- WorkflowFinished? workflowFinishedRecord = null;
+ WorkflowStateCommitted? workflowFinishedRecord = null;
var host = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
@@ -29,8 +30,11 @@ public static class DispatchWorkflowExtensions
configureServices?.Invoke(services);
// This notification handler will capture the WorkflowFinished record (to be returned) and release the semaphore.
- services.AddNotificationHandler(sp => new(notification =>
+ services.AddNotificationHandler(sp => new(notification =>
{
+ if (notification.WorkflowExecutionContext.Status != WorkflowStatus.Finished)
+ return;
+
workflowFinishedRecord = notification;
semaphore.Release();
}));
@@ -75,9 +79,9 @@ public static class DispatchWorkflowExtensions
}
}
- class WorkflowFinishedAction(Action action) : INotificationHandler
+ class WorkflowFinishedAction(Action action) : INotificationHandler
{
- public Task HandleAsync(WorkflowFinished notification, CancellationToken cancellationToken)
+ public Task HandleAsync(WorkflowStateCommitted notification, CancellationToken cancellationToken)
{
action(notification);
return Task.CompletedTask;
diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs
index de3c8e0d6..d3257f06b 100644
--- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs
+++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs
@@ -20,7 +20,7 @@ public class LoggingMiddleware : IActivityExecutionMiddleware
{
_next = next;
_logger = logger;
- _stopwatch = new Stopwatch();
+ _stopwatch = new();
}
///
diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowStateCommitted.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowStateCommitted.cs
new file mode 100644
index 000000000..dcad37410
--- /dev/null
+++ b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowStateCommitted.cs
@@ -0,0 +1,7 @@
+using Elsa.Mediator.Contracts;
+using Elsa.Workflows.Management.Entities;
+using Elsa.Workflows.State;
+
+namespace Elsa.Workflows.Runtime.Notifications;
+
+public record WorkflowStateCommitted(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, WorkflowInstance WorkflowInstance) : INotification;
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs
index 3df97e11c..768c9c373 100644
--- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs
@@ -1,6 +1,8 @@
+using Elsa.Mediator.Contracts;
using Elsa.Workflows.CommitStates;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime.Entities;
+using Elsa.Workflows.Runtime.Notifications;
using Elsa.Workflows.Runtime.Requests;
using Elsa.Workflows.State;
@@ -10,6 +12,7 @@ public class DefaultCommitStateHandler(
IWorkflowInstanceManager workflowInstanceManager,
IBookmarksPersister bookmarkPersister,
IVariablePersistenceManager variablePersistenceManager,
+ IMediator mediator,
ILogRecordSink activityExecutionLogRecordSink,
ILogRecordSink workflowExecutionLogRecordSink) : ICommitStateHandler
{
@@ -26,9 +29,10 @@ public class DefaultCommitStateHandler(
await activityExecutionLogRecordSink.PersistExecutionLogsAsync(workflowExecutionContext, cancellationToken);
await workflowExecutionLogRecordSink.PersistExecutionLogsAsync(workflowExecutionContext, cancellationToken);
await variablePersistenceManager.SaveVariablesAsync(workflowExecutionContext);
- await workflowInstanceManager.SaveAsync(workflowState, cancellationToken);
+ var workflowInstance = await workflowInstanceManager.SaveAsync(workflowState, cancellationToken);
workflowExecutionContext.ExecutionLog.Clear();
workflowExecutionContext.ClearCompletedActivityExecutionContexts();
await workflowExecutionContext.ExecuteDeferredTasksAsync();
+ await mediator.SendAsync(new WorkflowStateCommitted(workflowExecutionContext, workflowState, workflowInstance), cancellationToken);
}
}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
index 62359f7ca..40d8276eb 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
@@ -5,6 +5,8 @@ using Elsa.Identity.Providers;
using Elsa.Testing.Shared.Services;
using Elsa.Workflows.ComponentTests.Decorators;
using Elsa.Workflows.ComponentTests.Materializers;
+using Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach;
+using Elsa.Workflows.ComponentTests.Services;
using Elsa.Workflows.ComponentTests.WorkflowProviders;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime.Distributed.Extensions;
@@ -87,12 +89,14 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
{
services
.AddSingleton()
- .AddScoped()
+ .AddScoped()
+ .AddSingleton()
.AddScoped()
.AddSingleton()
.AddScoped()
.AddNotificationHandlersFrom()
- .AddWorkflowDefinitionProvider()
+ .AddWorkflowsProvider()
+ .AddNotificationHandlersFrom()
.Decorate()
;
});
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Models/TestWorkflowExecutionResult.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Models/TestWorkflowExecutionResult.cs
new file mode 100644
index 000000000..5db0f9ef8
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Models/TestWorkflowExecutionResult.cs
@@ -0,0 +1,10 @@
+using Elsa.Workflows.Runtime.Entities;
+
+namespace Elsa.Workflows.ComponentTests.Models;
+
+///
+/// Represents the result of a test workflow execution, including the workflow execution context and activity execution records.
+///
+/// The workflow execution context after completion.
+/// The collection of activity execution records for the workflow.
+public record TestWorkflowExecutionResult(WorkflowExecutionContext WorkflowExecutionContext, ICollection ActivityExecutionRecords);
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs
new file mode 100644
index 000000000..8fb09b3a7
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs
@@ -0,0 +1,85 @@
+using Elsa.Testing.Shared;
+using Elsa.Testing.Shared.Services;
+using Elsa.Workflows.ComponentTests.Models;
+using Elsa.Workflows.Models;
+using Elsa.Workflows.Runtime;
+using Elsa.Workflows.Runtime.Entities;
+using Elsa.Workflows.Runtime.Messages;
+using System.Collections.Concurrent;
+
+namespace Elsa.Workflows.ComponentTests.Services;
+
+///
+/// Provides functionality to execute workflows asynchronously and await their completion for testing purposes.
+/// Tracks activity execution records and workflow completion signals.
+///
+public class AsyncWorkflowRunner : IDisposable
+{
+ private readonly IWorkflowRuntime _workflowRuntime;
+ private readonly IIdentityGenerator _identityGenerator;
+ private readonly SignalManager _signalManager;
+ private readonly WorkflowEvents _workflowEvents;
+ private readonly ConcurrentDictionary _activityExecutionRecords = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public AsyncWorkflowRunner(IWorkflowRuntime workflowRuntime, IIdentityGenerator identityGenerator, SignalManager signalManager, WorkflowEvents workflowEvents)
+ {
+ _workflowRuntime = workflowRuntime;
+ _identityGenerator = identityGenerator;
+ _signalManager = signalManager;
+ _workflowEvents = workflowEvents;
+
+ _workflowEvents.WorkflowStateCommitted += OnWorkflowStateCommitted;
+ _workflowEvents.ActivityExecutedLogUpdated += OnActivityExecutedLogUpdated;
+ }
+
+ ///
+ /// Runs the specified workflow definition asynchronously and waits for its completion.
+ /// Returns the workflow execution context and activity execution records.
+ ///
+ /// The handle of the workflow definition to execute.
+ /// A containing the workflow execution context and activity execution records.
+ public async Task RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle workflowDefinitionHandle)
+ {
+ var workflowInstanceId = _identityGenerator.GenerateId();
+ var workflowClient = await _workflowRuntime.CreateClientAsync(workflowInstanceId);
+ await workflowClient.CreateInstanceAsync(new()
+ {
+ WorkflowDefinitionHandle = workflowDefinitionHandle
+ });
+ _activityExecutionRecords.Clear();
+ await workflowClient.RunInstanceAsync(RunWorkflowInstanceRequest.Empty);
+ var signalName = GetSignalName(workflowInstanceId);
+ var workflowExecutionContext = await _signalManager.WaitAsync(signalName);
+ return new(workflowExecutionContext, _activityExecutionRecords.Values.ToList());
+ }
+
+ private void OnWorkflowStateCommitted(object? sender, WorkflowStateCommittedEventArgs e)
+ {
+ if (e.WorkflowExecutionContext.Status != WorkflowStatus.Finished)
+ return;
+
+ var signalName = GetSignalName(e.WorkflowExecutionContext.Id);
+ _signalManager.Trigger(signalName, e.WorkflowExecutionContext);
+ }
+
+ private void OnActivityExecutedLogUpdated(object? sender, ActivityExecutedLogUpdatedEventArgs e)
+ {
+ foreach (var record in e.Records)
+ _activityExecutionRecords[record.Id] = record;
+ }
+
+ private static string GetSignalName(string workflowInstanceId) => $"WorkflowInstanceCompleted-{workflowInstanceId}";
+
+ ///
+ /// Unsubscribes from workflow events and releases resources.
+ ///
+ public void Dispose()
+ {
+ _workflowEvents.WorkflowStateCommitted -= OnWorkflowStateCommitted;
+ _workflowEvents.ActivityExecutedLogUpdated -= OnActivityExecutedLogUpdated;
+ GC.SuppressFinalize(this);
+ }
+}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/ForEachWorkflowTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/ForEachWorkflowTests.cs
new file mode 100644
index 000000000..3657edbb2
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/ForEachWorkflowTests.cs
@@ -0,0 +1,29 @@
+using Elsa.Common.Models;
+using Elsa.Workflows.ComponentTests.Abstractions;
+using Elsa.Workflows.ComponentTests.Fixtures;
+using Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach.Workflows;
+using Elsa.Workflows.ComponentTests.Services;
+using Elsa.Workflows.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach;
+
+public class ForEachWorkflowTests : AppComponentTest
+{
+ private readonly AsyncWorkflowRunner _workflowRunner;
+
+ public ForEachWorkflowTests(App app) : base(app)
+ {
+ _workflowRunner = Scope.ServiceProvider.GetRequiredService();
+ }
+
+ [Fact(DisplayName = "ForEach activity executes child activity for each collection item and supports blocking activities")]
+ public async Task ForEachActivity_ExecutesChildActivity_ForEachCollectionItem_AndSupportsBlocking()
+ {
+ var result = await _workflowRunner.RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle.ByDefinitionId(ForEachWorkflow.DefinitionId, VersionOptions.Published));
+ var writeLineExecutionRecords = result.ActivityExecutionRecords.Where(x => x.ActivityId == "WriteLine1").ToList();
+
+ // Assert that the workflow executed the expected number of activities.
+ Assert.Equal(3, writeLineExecutionRecords.Count);
+ }
+}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/Workflows/ForEachWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/Workflows/ForEachWorkflow.cs
new file mode 100644
index 000000000..803c23d60
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/Workflows/ForEachWorkflow.cs
@@ -0,0 +1,31 @@
+using Elsa.Extensions;
+using Elsa.Scheduling.Activities;
+using Elsa.Workflows.Activities;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach.Workflows;
+
+public class ForEachWorkflow : WorkflowBase
+{
+ public static readonly string DefinitionId = Guid.NewGuid().ToString();
+ protected override void Build(IWorkflowBuilder builder)
+ {
+ builder.WithDefinitionId(DefinitionId);
+ builder.Root = new Sequence
+ {
+ Activities =
+ {
+ new ForEach(["a", "b", "c"])
+ {
+ Body = new Sequence
+ {
+ Activities =
+ {
+ new WriteLine(context => $"Processing item: {context.GetVariable("CurrentValue")}"),
+ Delay.FromMilliseconds(100)
+ }
+ }
+ }
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs
index cfb18b6ac..39edc2ad9 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs
@@ -26,11 +26,11 @@ public class DispatchWorkflowsTests : AppComponentTest
public async Task DispatchAndWaitWorkflow_ShouldWaitForChildWorkflowToComplete()
{
var workflowClient = await _workflowRuntime.CreateClientAsync();
- await workflowClient.CreateInstanceAsync(new CreateWorkflowInstanceRequest
+ await workflowClient.CreateInstanceAsync(new()
{
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(DispatchAndWaitWorkflow.DefinitionId, VersionOptions.Published)
});
await workflowClient.RunInstanceAsync(RunWorkflowInstanceRequest.Empty);
- await _signalManager.WaitAsync("Completed");
+ await _signalManager.WaitAsync("Completed");
}
}
\ No newline at end of file
diff --git a/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs b/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs
new file mode 100644
index 000000000..583455911
--- /dev/null
+++ b/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs
@@ -0,0 +1,29 @@
+using Elsa.Extensions;
+using Elsa.Testing.Shared;
+using Elsa.Workflows.Activities;
+using Xunit.Abstractions;
+
+namespace Elsa.Activities.IntegrationTests;
+
+public class ForEachTests
+{
+ private readonly CapturingTextWriter _capturingTextWriter = new();
+ private readonly IServiceProvider _serviceProvider;
+
+ public ForEachTests(ITestOutputHelper testOutputHelper)
+ {
+ _serviceProvider = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
+ }
+
+ [Fact(DisplayName = "ForEach executes each activity for every item in the collection")]
+ public async Task ForEach_ExecutesEachActivity_ForEveryItem()
+ {
+ var expectedLines = new[] {"a", "b", "c"};
+ var forEach = new ForEach(expectedLines)
+ {
+ Body = new WriteLine(context => context.GetVariable("CurrentValue"))
+ };
+ await _serviceProvider.RunActivityAsync(forEach);
+ Assert.Equal(expectedLines, _capturingTextWriter.Lines);
+ }
+}
\ No newline at end of file