Add WorkflowStateCommitted notification support and update state handling logic

- Introduced `WorkflowStateCommitted` notification to encapsulate workflow execution context, state, and instance details.
- Updated `DefaultCommitStateHandler` to publish `WorkflowStateCommitted` via `IMediator`.
- Adjusted `DispatchWorkflowExtensions` to use `WorkflowStateCommitted` for workflow completion.

Updates KubernetesClient and Microsoft packages (#6917)

* Remove Proto.Cluster.Kubernetes dependency due to vulnerability

- Temporarily removed `Proto.Cluster.Kubernetes` package and provider integration because of a vulnerability in its dependency (https://avd.aquasec.com/nvd/2025/cve-2025-9708).
- Adjusted related cluster provider and remote configuration logic.
- Updated `PortAttribute` default parameter for clarity.

* Revert "Remove Proto.Cluster.Kubernetes dependency due to vulnerability"

This reverts commit 0720d970968e4f7338825407258b34ddffb1d2a4.

* Add KubernetesClient package and update MicrosoftVersion to 9.0.9

- Added `KubernetesClient` package to the project dependencies.
- Updated `MicrosoftVersion` to `9.0.9` in `Directory.Packages.props`.
Update Polly packages

- Bump Polly and Polly.Extensions package versions to 8.6.3.

Update `Microsoft.AspNetCore.Authorization` to use `MicrosoftVersion` property

Ensure Docker images ship CA trust and add TLS smoke tests (#6918)

Remove TlsSmoke project and related solution references

- Deleted `TlsSmoke` project files (`Program.cs` and `TlsSmoke.csproj`).
- Removed `TlsSmoke` project reference from the solution file (`Elsa.sln`).

Add comprehensive Copilot coding agent instructions for repository onboarding (#6920)

* Initial plan

* Add comprehensive .github/copilot-instructions.md with validated build instructions

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
Add ForEach tests, introduce asynchronous workflow runner and enhance workflow events. (#6926)

* Introduce asynchronous workflow runner and enhance workflow events.

- Added `AsyncWorkflowRunner` to enable asynchronous workflow execution and result tracking.
- Introduced new event arguments, such as `ActivityExecutedEventArgs` and `WorkflowStateCommittedEventArgs`.
- Expanded `WorkflowEvents` class to include `ActivityExecuted`, `ActivityExecutedLogUpdated`, and `WorkflowStateCommitted` events.
- Refactored event arguments into the `Elsa.Testing.Shared.EventArgs` namespace.
- Enhanced tests with `AsyncWorkflowRunner` and new event-driven workflow scenarios.

* Refactor event argument classes to unify namespace and simplify inheritance

* Add shared component DotSettings file to support namespace exclusions
Refactor `WaitAsync` call in `DispatchWorkflowsTests` to remove unnecessary generic type.
This commit is contained in:
Sipke Schoorstra 2025-09-25 20:58:21 +02:00
parent 4f4263e544
commit f24b4394cf
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
28 changed files with 873 additions and 57 deletions

230
.github/copilot-instructions.md vendored Normal file
View file

@ -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.

72
.github/workflows/docker-ca.yml vendored Normal file
View file

@ -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

View file

@ -5,7 +5,7 @@
</PropertyGroup>
<PropertyGroup>
<ElsaStudioVersion>3.6.0-preview.1175</ElsaStudioVersion>
<MicrosoftVersion>9.0.8</MicrosoftVersion>
<MicrosoftVersion>9.0.9</MicrosoftVersion>
<ResilienceVersion>9.7.0</ResilienceVersion>
</PropertyGroup>
<ItemGroup>

View file

@ -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

View file

@ -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)

View file

@ -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 \
# 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 \
libpython3.11 \
python3-pip && \
rm -rf /var/lib/apt/lists/*
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"]
ENTRYPOINT ["/entrypoint.sh"]
CMD ["dotnet", "Elsa.Server.Web.dll"]

View file

@ -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 \
# 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 \
libpython3.11 \
python3-pip && \
rm -rf /var/lib/apt/lists/*
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"]

View file

@ -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 \
# 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 \
libpython3.11 \
python3-pip && \
rm -rf /var/lib/apt/lists/*
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"]

View file

@ -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"]

103
docker/entrypoint.sh Executable file
View file

@ -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 "$@"

95
scripts/test-ca-trust.sh Executable file
View file

@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
if [ $# -lt 2 ]; then
echo "Usage: $0 <image> <tls_app_dir> [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 <<CERTEXT > "$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

View file

@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=eventargs/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -0,0 +1,8 @@
using Elsa.Workflows;
namespace Elsa.Testing.Shared;
public class ActivityExecutedEventArgs(ActivityExecutionContext activityExecutionContext) : EventArgs
{
public ActivityExecutionContext ActivityExecutionContext { get; } = activityExecutionContext;
}

View file

@ -0,0 +1,10 @@
using Elsa.Workflows;
using Elsa.Workflows.Runtime.Entities;
namespace Elsa.Testing.Shared;
public class ActivityExecutedLogUpdatedEventArgs(WorkflowExecutionContext workflowExecutionContext, ICollection<ActivityExecutionRecord> records) : EventArgs
{
public WorkflowExecutionContext WorkflowExecutionContext { get; } = workflowExecutionContext;
public ICollection<ActivityExecutionRecord> Records { get; } = records;
}

View file

@ -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;
}

View file

@ -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<WorkflowFinished>,
INotificationHandler<WorkflowInstanceSaved>
INotificationHandler<WorkflowInstanceSaved>,
INotificationHandler<WorkflowStateCommitted>,
INotificationHandler<ActivityExecuted>,
INotificationHandler<ActivityExecutionLogUpdated>
{
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;
}
}

View file

@ -4,6 +4,12 @@ public class WorkflowEvents
{
public event EventHandler<WorkflowFinishedEventArgs>? WorkflowFinished;
public event EventHandler<WorkflowInstanceSavedEventArgs>? WorkflowInstanceSaved;
public event EventHandler<WorkflowStateCommittedEventArgs>? WorkflowStateCommitted;
public event EventHandler<ActivityExecutedEventArgs>? ActivityExecuted;
public event EventHandler<ActivityExecutedLogUpdatedEventArgs>? 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);
}

View file

@ -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<WorkflowFinished?> DispatchWorkflowAndRunToCompletion(
public static async Task<WorkflowStateCommitted?> DispatchWorkflowAndRunToCompletion(
this IWorkflow workflowDefinition,
Action<IServiceCollection>? configureServices = null,
Action<IModule>? 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<WorkflowFinishedAction, WorkflowFinished>(sp => new(notification =>
services.AddNotificationHandler<WorkflowFinishedAction, WorkflowStateCommitted>(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<WorkflowFinished> action) : INotificationHandler<WorkflowFinished>
class WorkflowFinishedAction(Action<WorkflowStateCommitted> action) : INotificationHandler<WorkflowStateCommitted>
{
public Task HandleAsync(WorkflowFinished notification, CancellationToken cancellationToken)
public Task HandleAsync(WorkflowStateCommitted notification, CancellationToken cancellationToken)
{
action(notification);
return Task.CompletedTask;

View file

@ -20,7 +20,7 @@ public class LoggingMiddleware : IActivityExecutionMiddleware
{
_next = next;
_logger = logger;
_stopwatch = new Stopwatch();
_stopwatch = new();
}
/// <inheritdoc />

View file

@ -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;

View file

@ -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<ActivityExecutionRecord> activityExecutionLogRecordSink,
ILogRecordSink<WorkflowExecutionLogRecord> 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);
}
}

View file

@ -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<SignalManager>()
.AddScoped<WorkflowEvents>()
.AddScoped<AsyncWorkflowRunner>()
.AddSingleton<WorkflowEvents>()
.AddScoped<WorkflowDefinitionEvents>()
.AddSingleton<TriggerChangeTokenSignalEvents>()
.AddScoped<IWorkflowMaterializer, TestWorkflowMaterializer>()
.AddNotificationHandlersFrom<WorkflowServer>()
.AddWorkflowDefinitionProvider<TestWorkflowProvider>()
.AddWorkflowsProvider<TestWorkflowProvider>()
.AddNotificationHandlersFrom<WorkflowEventHandlers>()
.Decorate<IChangeTokenSignaler, EventPublishingChangeTokenSignaler>()
;
});

View file

@ -0,0 +1,10 @@
using Elsa.Workflows.Runtime.Entities;
namespace Elsa.Workflows.ComponentTests.Models;
/// <summary>
/// Represents the result of a test workflow execution, including the workflow execution context and activity execution records.
/// </summary>
/// <param name="WorkflowExecutionContext">The workflow execution context after completion.</param>
/// <param name="ActivityExecutionRecords">The collection of activity execution records for the workflow.</param>
public record TestWorkflowExecutionResult(WorkflowExecutionContext WorkflowExecutionContext, ICollection<ActivityExecutionRecord> ActivityExecutionRecords);

View file

@ -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;
/// <summary>
/// Provides functionality to execute workflows asynchronously and await their completion for testing purposes.
/// Tracks activity execution records and workflow completion signals.
/// </summary>
public class AsyncWorkflowRunner : IDisposable
{
private readonly IWorkflowRuntime _workflowRuntime;
private readonly IIdentityGenerator _identityGenerator;
private readonly SignalManager _signalManager;
private readonly WorkflowEvents _workflowEvents;
private readonly ConcurrentDictionary<string, ActivityExecutionRecord> _activityExecutionRecords = new();
/// <summary>
/// Initializes a new instance of the <see cref="AsyncWorkflowRunner"/> class.
/// </summary>
public AsyncWorkflowRunner(IWorkflowRuntime workflowRuntime, IIdentityGenerator identityGenerator, SignalManager signalManager, WorkflowEvents workflowEvents)
{
_workflowRuntime = workflowRuntime;
_identityGenerator = identityGenerator;
_signalManager = signalManager;
_workflowEvents = workflowEvents;
_workflowEvents.WorkflowStateCommitted += OnWorkflowStateCommitted;
_workflowEvents.ActivityExecutedLogUpdated += OnActivityExecutedLogUpdated;
}
/// <summary>
/// Runs the specified workflow definition asynchronously and waits for its completion.
/// Returns the workflow execution context and activity execution records.
/// </summary>
/// <param name="workflowDefinitionHandle">The handle of the workflow definition to execute.</param>
/// <returns>A <see cref="TestWorkflowExecutionResult"/> containing the workflow execution context and activity execution records.</returns>
public async Task<TestWorkflowExecutionResult> 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<WorkflowExecutionContext>(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}";
/// <summary>
/// Unsubscribes from workflow events and releases resources.
/// </summary>
public void Dispose()
{
_workflowEvents.WorkflowStateCommitted -= OnWorkflowStateCommitted;
_workflowEvents.ActivityExecutedLogUpdated -= OnActivityExecutedLogUpdated;
GC.SuppressFinalize(this);
}
}

View file

@ -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<AsyncWorkflowRunner>();
}
[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);
}
}

View file

@ -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<string>(["a", "b", "c"])
{
Body = new Sequence
{
Activities =
{
new WriteLine(context => $"Processing item: {context.GetVariable<string>("CurrentValue")}"),
Delay.FromMilliseconds(100)
}
}
}
}
};
}
}

View file

@ -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<string>("Completed");
await _signalManager.WaitAsync("Completed");
}
}

View file

@ -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<string>(expectedLines)
{
Body = new WriteLine(context => context.GetVariable<string>("CurrentValue"))
};
await _serviceProvider.RunActivityAsync(forEach);
Assert.Equal(expectedLines, _capturingTextWriter.Lines);
}
}