Merge branch 'develop/3.6.0' into feat/unit-test-coverage-switch

This commit is contained in:
Sipke Schoorstra 2025-10-20 21:28:07 +02:00
commit 26079490cd
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
49 changed files with 2922 additions and 550 deletions

View file

@ -9,174 +9,175 @@
<ResilienceVersion>9.9.0</ResilienceVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Antlr4.Runtime.Standard" Version="4.13.1" PrivateAssets="All"/>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations" Version="0.5.1"/>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.MySql" Version="0.5.1"/>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" Version="0.5.1"/>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.SqlServer" Version="0.5.1"/>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.SQLite" Version="0.5.1"/>
<PackageVersion Include="AspNetCore.Authentication.ApiKey" Version="9.0.0"/>
<PackageVersion Include="AutoMapper" Version="14.0.0" PrivateAssets="All"/>
<PackageVersion Include="Azure.Identity" Version="1.14.2"/>
<PackageVersion Include="Azure.Messaging.ServiceBus" Version="7.19.0"/>
<PackageVersion Include="Azure.ResourceManager" Version="1.13.1"/>
<PackageVersion Include="Azure.ResourceManager.AppContainers" Version="1.3.0"/>
<PackageVersion Include="Azure.ResourceManager.Resources" Version="1.9.1"/>
<PackageVersion Include="Azure.ResourceManager.ServiceBus" Version="1.1.0"/>
<PackageVersion Include="BenchmarkDotNet" Version="0.15.2"/>
<PackageVersion Include="Bogus" Version="35.6.3"/>
<PackageVersion Include="ConfigureAwait.Fody" Version="3.3.2" PrivateAssets="All"/>
<PackageVersion Include="Confluent.Kafka" Version="2.10.0"/>
<PackageVersion Include="Confluent.SchemaRegistry.Serdes.Avro" Version="2.10.0"/>
<PackageVersion Include="coverlet.collector" Version="6.0.4" PrivateAssets="All"/>
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" PrivateAssets="All"/>
<PackageVersion Include="Cronos" Version="0.11.0"/>
<PackageVersion Include="Dapper" Version="2.1.66"/>
<PackageVersion Include="Datadog.Trace.Bundle" Version="3.16.0"/>
<PackageVersion Include="DistributedLock" Version="2.6.0"/>
<PackageVersion Include="DistributedLock.Core" Version="1.0.8"/>
<PackageVersion Include="DistributedLock.FileSystem" Version="1.0.3"/>
<PackageVersion Include="DistributedLock.Postgres" Version="1.3.0"/>
<PackageVersion Include="DistributedLock.Redis" Version="1.0.3"/>
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.0.6"/>
<PackageVersion Include="Elsa.Studio" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="Elsa.Studio.Agents" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="Elsa.Studio.Core.BlazorWasm" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="Elsa.Studio.Login.BlazorWasm" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="FastEndpoints" Version="7.0.1"/>
<PackageVersion Include="FastEndpoints.Security" Version="7.0.1"/>
<PackageVersion Include="FastEndpoints.Swagger" Version="7.0.1"/>
<PackageVersion Include="FluentMigrator" Version="7.1.0"/>
<PackageVersion Include="FluentMigrator.Runner" Version="7.1.0"/>
<PackageVersion Include="FluentStorage" Version="5.6.0"/>
<PackageVersion Include="FluentStorage.Azure.Blobs" Version="5.3.0"/>
<PackageVersion Include="Fluid.Core" Version="2.25.0"/>
<PackageVersion Include="Fody" Version="6.9.2" PrivateAssets="All"/>
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="All"/>
<PackageVersion Include="Google.Protobuf" Version="3.31.1"/>
<PackageVersion Include="Grpc.Net.Client" Version="2.71.0"/>
<PackageVersion Include="Grpc.Tools" Version="2.72.0" PrivateAssets="All"/>
<PackageVersion Include="Hangfire" Version="1.8.20"/>
<PackageVersion Include="Hangfire.MemoryStorage" Version="1.8.1.1"/>
<PackageVersion Include="Hangfire.PostgreSql" Version="1.20.12"/>
<PackageVersion Include="Hangfire.Storage.SQLite" Version="0.4.2"/>
<PackageVersion Include="Humanizer.Core" Version="2.14.1"/>
<PackageVersion Include="IronCompress" Version="1.6.3"/>
<PackageVersion Include="JetBrains.Annotations" Version="2024.3.0"/>
<PackageVersion Include="Jint" Version="4.4.1"/>
<PackageVersion Include="LinqKit.Core" Version="1.2.8"/>
<PackageVersion Include="MailKit" Version="4.12.1"/>
<PackageVersion Include="MassTransit" Version="8.4.1"/>
<PackageVersion Include="MassTransit.Azure.ServiceBus.Core" Version="8.4.1"/>
<PackageVersion Include="MassTransit.Extensions.DependencyInjection" Version="7.3.1"/>
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.4.1"/>
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.14.0"/>
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2"/>
<PackageVersion Include="Microsoft.Data.Sqlite" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite.Design" Version="1.1.6"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Http" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="$(ResilienceVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Options" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Resilience" Version="$(ResilienceVersion)"/>
<PackageVersion Include="Microsoft.Identity.Client" Version="4.72.1"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All"/>
<PackageVersion Include="Moq" Version="4.20.72"/>
<PackageVersion Include="MongoDB.Driver" Version="3.4.0"/>
<PackageVersion Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="2.1.0"/>
<PackageVersion Include="MongoDB.Driver.Extensions" Version="2.0.2"/>
<PackageVersion Include="MySql.Data" Version="9.3.0"/>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageVersion Include="Npgsql" Version="9.0.3"/>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageVersion Include="NSubstitute" Version="5.3.0"/>
<PackageVersion Include="NuGet.Packaging" Version="6.14.0"/>
<PackageVersion Include="NuGet.Protocol" Version="6.14.0"/>
<PackageVersion Include="Nuke.Components" Version="9.0.4"/>
<PackageVersion Include="Open.Linq.AsyncExtensions" Version="1.2.0"/>
<PackageVersion Include="OpenTelemetry" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.AutoInstrumentation" Version="1.11.0"/>
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.12.0-beta.1"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.StackExchangeRedis" Version="1.12.0-beta.1"/>
<PackageVersion Include="Oracle.EntityFrameworkCore" Version="9.23.80"/>
<PackageVersion Include="Polly" Version="8.6.2"/>
<PackageVersion Include="Polly.Extensions" Version="8.6.2"/>
<PackageVersion Include="Polly.Extensions.Http" Version="3.0.0"/>
<PackageVersion Include="PolySharp" Version="1.15.0" PrivateAssets="All"/>
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0"/>
<PackageVersion Include="Proto.Actor" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster.CodeGen" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster.AzureContainerApps" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster.Kubernetes" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster.TestProvider" Version="1.7.0"/>
<PackageVersion Include="Proto.OpenTelemetry" Version="1.7.0"/>
<PackageVersion Include="Proto.Persistence" Version="1.7.0"/>
<PackageVersion Include="Proto.Persistence.Sqlite" Version="1.7.0"/>
<PackageVersion Include="Proto.Persistence.SqlServer" Version="1.7.0"/>
<PackageVersion Include="Proto.Remote" Version="1.7.0"/>
<PackageVersion Include="pythonnet" Version="3.1.0-preview2024-09-06"/>
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
<PackageVersion Include="Quartz.Serialization.Json" Version="3.14.0"/>
<PackageVersion Include="Refit" Version="8.0.0"/>
<PackageVersion Include="Refit.HttpClientFactory" Version="8.0.0"/>
<PackageVersion Include="Scrutor" Version="6.1.0"/>
<PackageVersion Include="Serilog" Version="4.3.0"/>
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.2"/>
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0"/>
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0"/>
<PackageVersion Include="ShortGuid" Version="2.0.1"/>
<PackageVersion Include="StackExchange.Redis" Version="2.8.37"/>
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1"/>
<PackageVersion Include="System.Data.SqlClient" Version="4.9.0"/>
<PackageVersion Include="System.Linq.Async" Version="6.0.3"/>
<PackageVersion Include="System.Linq.Dynamic.Core" Version="1.6.7"/>
<PackageVersion Include="System.Net.Http" Version="4.3.4"/>
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1"/>
<PackageVersion Include="System.Formats.Asn1" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="System.Text.Json" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Testcontainers" Version="4.6.0"/>
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0"/>
<PackageVersion Include="Testcontainers.RabbitMq" Version="4.6.0"/>
<PackageVersion Include="Testcontainers.Redis" Version="4.6.0"/>
<PackageVersion Include="ThrottleDebounce" Version="2.0.1"/>
<PackageVersion Include="WebhooksCore" Version="0.0.1"/>
<PackageVersion Include="xunit" Version="2.9.3"/>
<PackageVersion Include="xunit.abstractions" Version="2.0.3"/>
<PackageVersion Include="xunit.extensibility.core" Version="2.9.3"/>
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" PrivateAssets="All"/>
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0"/>
<PackageVersion Include="Antlr4.Runtime.Standard" Version="4.13.1" PrivateAssets="All" />
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations" Version="0.5.1" />
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.MySql" Version="0.5.1" />
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" Version="0.5.1" />
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.SqlServer" Version="0.5.1" />
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.SQLite" Version="0.5.1" />
<PackageVersion Include="AspNetCore.Authentication.ApiKey" Version="9.0.0" />
<PackageVersion Include="AutoMapper" Version="14.0.0" PrivateAssets="All" />
<PackageVersion Include="Azure.Identity" Version="1.14.2" />
<PackageVersion Include="Azure.Messaging.ServiceBus" Version="7.19.0" />
<PackageVersion Include="Azure.ResourceManager" Version="1.13.1" />
<PackageVersion Include="Azure.ResourceManager.AppContainers" Version="1.3.0" />
<PackageVersion Include="Azure.ResourceManager.Resources" Version="1.9.1" />
<PackageVersion Include="Azure.ResourceManager.ServiceBus" Version="1.1.0" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.2" />
<PackageVersion Include="Bogus" Version="35.6.3" />
<PackageVersion Include="ConfigureAwait.Fody" Version="3.3.2" PrivateAssets="All" />
<PackageVersion Include="Confluent.Kafka" Version="2.10.0" />
<PackageVersion Include="Confluent.SchemaRegistry.Serdes.Avro" Version="2.10.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" PrivateAssets="All" />
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" PrivateAssets="All" />
<PackageVersion Include="Cronos" Version="0.11.0" />
<PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="Datadog.Trace.Bundle" Version="3.16.0" />
<PackageVersion Include="DistributedLock" Version="2.6.0" />
<PackageVersion Include="DistributedLock.Core" Version="1.0.8" />
<PackageVersion Include="DistributedLock.FileSystem" Version="1.0.3" />
<PackageVersion Include="DistributedLock.Postgres" Version="1.3.0" />
<PackageVersion Include="DistributedLock.Redis" Version="1.0.3" />
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.0.6" />
<PackageVersion Include="Elsa.Studio" Version="$(ElsaStudioVersion)" />
<PackageVersion Include="Elsa.Studio.Agents" Version="$(ElsaStudioVersion)" />
<PackageVersion Include="Elsa.Studio.Core.BlazorWasm" Version="$(ElsaStudioVersion)" />
<PackageVersion Include="Elsa.Studio.Login.BlazorWasm" Version="$(ElsaStudioVersion)" />
<PackageVersion Include="FastEndpoints" Version="7.0.1" />
<PackageVersion Include="FastEndpoints.Security" Version="7.0.1" />
<PackageVersion Include="FastEndpoints.Swagger" Version="7.0.1" />
<PackageVersion Include="FluentMigrator" Version="7.1.0" />
<PackageVersion Include="FluentMigrator.Runner" Version="7.1.0" />
<PackageVersion Include="FluentStorage" Version="5.6.0" />
<PackageVersion Include="FluentStorage.Azure.Blobs" Version="5.3.0" />
<PackageVersion Include="Fluid.Core" Version="2.25.0" />
<PackageVersion Include="Fody" Version="6.9.2" PrivateAssets="All" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="All" />
<PackageVersion Include="Google.Protobuf" Version="3.31.1" />
<PackageVersion Include="Grpc.Net.Client" Version="2.71.0" />
<PackageVersion Include="Grpc.Tools" Version="2.72.0" PrivateAssets="All" />
<PackageVersion Include="Hangfire" Version="1.8.20" />
<PackageVersion Include="Hangfire.MemoryStorage" Version="1.8.1.1" />
<PackageVersion Include="Hangfire.PostgreSql" Version="1.20.12" />
<PackageVersion Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageVersion Include="Humanizer.Core" Version="2.14.1" />
<PackageVersion Include="IronCompress" Version="1.6.3" />
<PackageVersion Include="JetBrains.Annotations" Version="2024.3.0" />
<PackageVersion Include="Jint" Version="4.4.1" />
<PackageVersion Include="LinqKit.Core" Version="1.2.8" />
<PackageVersion Include="MailKit" Version="4.12.1" />
<PackageVersion Include="MassTransit" Version="8.4.1" />
<PackageVersion Include="MassTransit.Azure.ServiceBus.Core" Version="8.4.1" />
<PackageVersion Include="MassTransit.Extensions.DependencyInjection" Version="7.3.1" />
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.4.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.14.0" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite.Design" Version="1.1.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="$(ResilienceVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Resilience" Version="$(ResilienceVersion)" />
<PackageVersion Include="Microsoft.Identity.Client" Version="4.72.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="MongoDB.Driver" Version="3.4.0" />
<PackageVersion Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="2.1.0" />
<PackageVersion Include="MongoDB.Driver.Extensions" Version="2.0.2" />
<PackageVersion Include="MySql.Data" Version="9.3.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Npgsql" Version="9.0.3" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="NuGet.Packaging" Version="6.14.0" />
<PackageVersion Include="NuGet.Protocol" Version="6.14.0" />
<PackageVersion Include="Nuke.Components" Version="9.0.4" />
<PackageVersion Include="Open.Linq.AsyncExtensions" Version="1.2.0" />
<PackageVersion Include="OpenTelemetry" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.AutoInstrumentation" Version="1.11.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.12.0-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.StackExchangeRedis" Version="1.12.0-beta.1" />
<PackageVersion Include="Oracle.EntityFrameworkCore" Version="9.23.80" />
<PackageVersion Include="Polly" Version="8.6.2" />
<PackageVersion Include="Polly.Extensions" Version="8.6.2" />
<PackageVersion Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageVersion Include="PolySharp" Version="1.15.0" PrivateAssets="All" />
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" />
<PackageVersion Include="Proto.Actor" Version="1.7.0" />
<PackageVersion Include="Proto.Cluster" Version="1.7.0" />
<PackageVersion Include="Proto.Cluster.CodeGen" Version="1.7.0" />
<PackageVersion Include="Proto.Cluster.AzureContainerApps" Version="1.7.0" />
<PackageVersion Include="Proto.Cluster.Kubernetes" Version="1.7.0" />
<PackageVersion Include="Proto.Cluster.TestProvider" Version="1.7.0" />
<PackageVersion Include="Proto.OpenTelemetry" Version="1.7.0" />
<PackageVersion Include="Proto.Persistence" Version="1.7.0" />
<PackageVersion Include="Proto.Persistence.Sqlite" Version="1.7.0" />
<PackageVersion Include="Proto.Persistence.SqlServer" Version="1.7.0" />
<PackageVersion Include="Proto.Remote" Version="1.7.0" />
<PackageVersion Include="pythonnet" Version="3.1.0-preview2024-09-06" />
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageVersion Include="Quartz.Serialization.Json" Version="3.14.0" />
<PackageVersion Include="Refit" Version="8.0.0" />
<PackageVersion Include="Refit.HttpClientFactory" Version="8.0.0" />
<PackageVersion Include="Scrutor" Version="6.1.0" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.2" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="ShortGuid" Version="2.0.1" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.37" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageVersion Include="System.Data.SqlClient" Version="4.9.0" />
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<PackageVersion Include="System.Linq.Dynamic.Core" Version="1.6.7" />
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageVersion Include="System.Formats.Asn1" Version="$(MicrosoftVersion)" />
<PackageVersion Include="System.Text.Json" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Testcontainers" Version="4.6.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
<PackageVersion Include="Testcontainers.RabbitMq" Version="4.6.0" />
<PackageVersion Include="Testcontainers.Redis" Version="4.6.0" />
<PackageVersion Include="ThrottleDebounce" Version="2.0.1" />
<PackageVersion Include="WebhooksCore" Version="0.0.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
<PackageVersion Include="xunit.assert" Version="2.9.3" />
<PackageVersion Include="xunit.extensibility.core" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" PrivateAssets="All" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
</Project>

View file

@ -311,6 +311,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "qa", "qa", "{0478E6EA-DCB2-
doc\qa\test-guidelines.md = doc\qa\test-guidelines.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Management.UnitTests", "test\unit\Elsa.Workflows.Management.UnitTests\Elsa.Workflows.Management.UnitTests.csproj", "{20C75446-CAE0-48D1-85D9-459541C4D8B6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.UnitTests", "test\unit\Elsa.Expressions.UnitTests\Elsa.Expressions.UnitTests.csproj", "{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -619,6 +623,14 @@ Global
{2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA}.Release|Any CPU.Build.0 = Release|Any CPU
{20C75446-CAE0-48D1-85D9-459541C4D8B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{20C75446-CAE0-48D1-85D9-459541C4D8B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{20C75446-CAE0-48D1-85D9-459541C4D8B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{20C75446-CAE0-48D1-85D9-459541C4D8B6}.Release|Any CPU.Build.0 = Release|Any CPU
{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -714,6 +726,8 @@ Global
{DC9CCAD0-7363-4691-B964-FF5B3AEA3F95} = {18453B51-25EB-4317-A4B3-B10518252E92}
{2DA466EB-CBF0-46EC-8B86-45CAF2B68BBA} = {18453B51-25EB-4317-A4B3-B10518252E92}
{0478E6EA-DCB2-4667-ADC2-37C62C9C2574} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6}
{20C75446-CAE0-48D1-85D9-459541C4D8B6} = {18453B51-25EB-4317-A4B3-B10518252E92}
{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -45,7 +45,7 @@ Each test layer has distinct goals and clear boundaries — see [**Which parts o
## Quick Start for Contributors
**Before you writ e a test:**
**Before you write a test:**
1. ✅ Understand what you're testing (see [**Which parts of Elsa to test**](#which-parts-of-elsa-to-test-and-which-test-types-to-use))
2. ✅ Choose the right test layer (unit vs integration vs component)
3. ✅ Use existing helpers (don't reinvent - see [**Test Helpers Reference**](#test-helpers-reference-quick-lookup))
@ -68,6 +68,24 @@ Each test layer has distinct goals and clear boundaries — see [**Which parts o
---
## Test Project Organization
Tests are organized by module and test type:
- **`test/unit/Elsa.Activities.UnitTests`** - Activity unit tests
- **`test/unit/Elsa.Expressions.UnitTests`** - Expression evaluation unit tests
- **`test/unit/Elsa.Workflows.Core.UnitTests`** - Core workflow unit tests (ActivityExecutionContext extensions, etc.)
- **`test/unit/Elsa.Workflows.Management.UnitTests`** - Workflow management unit tests
- **`test/integration/Elsa.Workflows.IntegrationTests`** - Workflow integration tests
- **`test/component/Elsa.Workflows.ComponentTests`** - End-to-end component tests
**Shared test infrastructure:**
- **`src/common/Elsa.Testing.Shared`** - Shared test helpers (`ActivityTestFixture`, fake strategies, extensions)
- **`src/common/Elsa.Testing.Shared.Integration`** - Integration test helpers (`RunActivityExtensions`, `PopulateRegistriesAsync`)
- **`src/common/Elsa.Testing.Shared.Component`** - Component test helpers (event handlers, workflow events)
---
## Which parts of Elsa to test, and which test types to use
This section maps Elsa aspects to the exact kinds of tests you should write, with examples and code patterns referencing repository conventions.
@ -76,8 +94,9 @@ This section maps Elsa aspects to the exact kinds of tests you should write, wit
#### **Unit tests:**
- Test the activity class logic only (no persistence, no scheduler). Cover configuration permutations and boundary inputs.
- Use [`ActivityTestHelper`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs), `ExecuteActivityAsync` method to run the activity and obtain an [`ActivityExecutionContext`](../../src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs) for assertions.
- In case the activity sets outputs, assert using `context.GetExecutionOutput()`([extension method](../../src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs)), otherwise, use `context.Get()`.
- Use [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) to configure and execute the activity and obtain an [`ActivityExecutionContext`](../../src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs) for assertions.
- Use fluent methods: `ConfigureServices()` to add custom services, `ConfigureContext()` to set up context state
- Assert using `context.GetActivityOutput()`([extension method](../../src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs)) for outputs, or `context.Get()` for variables.
**Example (does not set output):**
```csharp
@ -90,7 +109,8 @@ public async Task Should_Set_Variable_Integer()
var setVariable = new SetVariable<int>(variable, new Input<int>(expected));
// Act
var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable);
var fixture = new ActivityTestFixture(setVariable);
var context = await fixture.ExecuteAsync();
// Assert
var result = context.Get(variable);
@ -98,39 +118,56 @@ public async Task Should_Set_Variable_Integer()
}
```
**Example (sets output):**
**Example (sets output using fluent configuration):**
```csharp
[Fact]
public async Task Should_Send_GET_Request_And_Handle_Success_Response()
public async Task Should_Send_Get_Request_And_Handle_Success_Response()
{
// Arrange
var expectedUrl = new Uri("https://example.com");
var sendHttpRequest = new SendHttpRequest
{
// Arrange
// Extra arrange steps omitted for brevity
var sendHttpRequest = new SendHttpRequest
{
Url = new Input<Uri?>(expectedUrl),
Method = new Input<string>("GET"),
ExpectedStatusCodes = new List<HttpStatusCodeCase>()
};
Url = new Input<Uri?>(expectedUrl),
Method = new Input<string>("GET")
};
// Act
var context = await ActivityTestHelper.ExecuteActivityAsync(sendHttpRequest, services =>
{
// http configuration omitted for brevity
});
// Act
var fixture = new ActivityTestFixture(sendHttpRequest)
.WithHttpServices(responseHandler); // HTTP-specific extension
var context = await fixture.ExecuteAsync();
// Assert
var statusCodeOutput = context.GetExecutionOutput(_ => sendHttpRequest.StatusCode);
Assert.Equal(200, statusCodeOutput);
}
// Assert
var statusCodeOutput = context.GetActivityOutput(_ => sendHttpRequest.StatusCode);
Assert.Equal(200, statusCodeOutput);
}
```
**Example (checking scheduled activities):**
```csharp
[Fact]
public async Task Should_Schedule_Child_Activity()
{
// Arrange
var childActivity = new WriteLine("Hello");
var parentActivity = new Sequence { Activities = [childActivity] };
// Act
var fixture = new ActivityTestFixture(parentActivity);
var context = await fixture.ExecuteAsync();
// Assert
Assert.True(context.HasScheduledActivity(childActivity));
}
```
#### **Integration tests:**
- Place the activity inside a minimal workflow definition and run via [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs). Assert outputs/variables and that the activity integrates correctly with preceding/following activities.
- If activity creates bookmarks or relies on scheduler semantics, integration tests should resume bookmarks via the engine APIs to validate resumption.
Pattern note: [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) returns a [`RunWorkflowResult`](../../src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs) (or equivalent) containing the [`WorkflowInstance`](../../src/modules/Elsa.Workflows.Management/Entities/WorkflowInstance.cs) and output variables when run to completion.
Pattern note: [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) returns a [`RunWorkflowResult`](../../src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs) (or equivalent) containing the [`WorkflowInstance`](../../src/modules/Elsa.Workflows.Management/Entities/WorkflowInstance.cs) and output variables when run to completion.
Use returned state for deterministic assertions where possible.
---
### Workflow execution (invoker, middleware, bookmarks)
@ -167,14 +204,38 @@ Assert.Equal(WorkflowStatus.Finished, resumed.WorkflowInstance.Status);
## Test Helpers Reference (Quick Lookup)
| Helper | Purpose | Use When |
|-------------------------------------------|--------------------------------------------|-------------------------------------------------------------------------|
| `TestApplicationBuilder` | Build test service provider | All tests as entry point, <br/>except activities unit tests (see below) |
| `ActivityTestHelper.ExecuteActivityAsync` | Run single activity, with isolated context | Unit testing activities |
| `IWorkflowRunner.RunAsync` | Execute workflow in-process | Integration / Component tests |
| `PopulateRegistriesAsync` | Register types for JSON deserialization. | Loading JSON workflows. <br/>Integration tests only |
| `IWorkflowInstanceStore` | Query persisted instances | Component tests (persistence) |
| `RunWorkflowUntilEndAsync` | Drive workflow to completion. | Complex resumption scenarios. <br/>Integration tests only |
| Helper | Purpose | Use When |
|-------------------------------------------------|--------------------------------------------------------------|-------------------------------------------------------------------------|
| `TestApplicationBuilder` | Build test service provider | All tests as entry point, <br/>except activities unit tests (see below) |
| `ActivityTestFixture` | Configure and run single activity in isolation | Unit testing activities |
| `ActivityTestFixture.ConfigureServices` | Fluent API to add services to fixture | Activity unit tests requiring custom services |
| `ActivityTestFixture.ConfigureContext` | Fluent API to configure execution context before execution | Setting up test-specific context state |
| `ActivityTestFixture.BuildAsync` | Build context without executing activity | When you need context setup without execution |
| `ActivityTestFixture.ExecuteAsync` | Execute activity and return context | Standard activity unit test execution |
| `context.GetActivityOutput` | Get output from activity using expression selector | Asserting activity outputs in unit tests |
| `context.HasScheduledActivity` | Check if activity is scheduled | Verifying scheduling behavior in unit tests |
| `IWorkflowRunner.RunAsync` | Execute workflow in-process | Integration / Component tests |
| `RunActivityExtensions.RunActivityAsync` | Run single activity as workflow | Integration tests for single activities |
| `PopulateRegistriesAsync` | Register types for JSON deserialization | Loading JSON workflows. <br/>Integration tests only |
| `IWorkflowInstanceStore` | Query persisted instances | Component tests (persistence) |
| `AsyncWorkflowRunner.RunAndAwaitWorkflowCompletionAsync` | Drive workflow to completion with activity tracking | Complex async scenarios. <br/>Component tests only |
| `FakeActivityExecutionContextSchedulerStrategy` | Fake scheduler for unit tests | Automatically configured in `ActivityTestFixture` |
| `FakeWorkflowExecutionContextSchedulerStrategy` | Fake workflow scheduler for unit tests | Automatically configured in `ActivityTestFixture` |
---
## Scheduler Strategies for Testing
When testing activities in isolation, Elsa provides fake scheduler strategy implementations that bypass the production scheduling logic:
### Available Strategies
- **`FakeActivityExecutionContextSchedulerStrategy`** ([source](../../src/common/Elsa.Testing.Shared/FakeActivityExecutionContextSchedulerStrategy.cs)) - Schedules activities immediately within the activity execution context for deterministic testing
- **`FakeWorkflowExecutionContextSchedulerStrategy`** ([source](../../src/common/Elsa.Testing.Shared/FakeWorkflowExecutionContextSchedulerStrategy.cs)) - Schedules activities immediately at the workflow level for deterministic testing
These strategies are automatically configured when using [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) and ensure that scheduled activities execute synchronously in tests, making assertions deterministic.
**Note:** You do not need to manually configure these strategies - `ActivityTestFixture` handles this for you.
---
@ -198,9 +259,9 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu
## Deterministic patterns to avoid flaky tests
1. **For activities unit tests, prefer returned state from [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows.
1. **For activity unit tests, prefer returned state from [`ActivityTestFixture.ExecuteAsync`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs).** Always inspect on the returned context — it is deterministic for synchronous workflows.
2. **Resume bookmarks explicitly.** Do not wait for external schedulers — call the engine's resume/trigger APIs in your test to continue execution.
3. **For integration tests, Locate instances deterministically.** Use an instance id returned by [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) or attach a `CorrelationId` test variable and query [`IWorkflowInstanceStore.FindByCorrelationIdAsync(...)`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs). Avoid using "latest" queries.
3. **For integration tests, locate instances deterministically.** Use an instance id returned by [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) or attach a `CorrelationId` test variable and query [`IWorkflowInstanceStore.FindByCorrelationIdAsync(...)`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs). Avoid using "latest" queries.
---
@ -210,7 +271,7 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu
---
## Practical test recipes & snippets (copy/paste-ready)
## Practical test recipes & snippets
### Unit test (activity) — pattern
@ -221,7 +282,8 @@ public async Task MyActivity_Test()
var activity = new ActivityToTest();
// Act
var context = await ActivityTestHelper.ExecuteActivityAsync(activity);
var fixture = new ActivityTestFixture(activity);
var context = await fixture.ExecuteAsync();
// assert behavior of activity in isolation
}
@ -255,10 +317,35 @@ public async Task Workflow_Persists_Instance_And_Journal()
.Build();
var runner = sp.GetRequiredService<AsyncWorkflowRunner>();
var result = await runner.RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle.ByDefinitionId(someDefinitionId, VersionOptions.Published));
var result = await runner.RunAndAwaitWorkflowCompletionAsync(
WorkflowDefinitionHandle.ByDefinitionId(someDefinitionId, VersionOptions.Published)
);
result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished);
}
```
### Component test with async workflow execution
For testing workflows that complete asynchronously (e.g., with timers, external triggers), use [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs):
```csharp
[Fact]
public async Task Workflow_Completes_Asynchronously()
{
var sp = new TestApplicationBuilder(testOutput).Build();
var runner = sp.GetRequiredService<AsyncWorkflowRunner>();
var result = await runner.RunAndAwaitWorkflowCompletionAsync(
WorkflowDefinitionHandle.ByDefinitionId(workflowId, VersionOptions.Published)
);
result.WorkflowExecutionContext.Status.Should().Be(WorkflowStatus.Finished);
result.ActivityExecutionRecords.Should().HaveCount(expectedCount);
}
```
`AsyncWorkflowRunner` tracks activity execution records and awaits workflow completion signals, making it ideal for testing asynchronous workflow behavior deterministically.
---
## FAQ (quick pointers)
@ -271,7 +358,7 @@ Leave the definitions next to the tests that use them.
**Q: Which helper should I use to run a workflow?**
A: Prefer [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for in-process deterministic runs. For activities use `ExecuteActivityAsync` via [`ActivityTestHelper`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs).
A: Prefer [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for in-process deterministic runs. For activities use [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs).
**Q: How do I check persisted journal entries?**
@ -279,7 +366,7 @@ A: Query [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/
**Q: Do I need a new helper to wait for workflow completion?**
A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for integration tests and [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs) for activity unit tests, as well as integration helpers that cover all necessary scenarios.
A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) for integration tests and [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) for activity unit tests, as well as integration helpers that cover all necessary scenarios. For async workflows in component tests, use [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs).
---
@ -287,9 +374,9 @@ A: No. The repo provides [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Cont
Search the `test/` tree for examples that follow the above patterns:
- Unit test activity examples: `test/unit/Elsa.Activities.UnitTests` (look for [`ExecuteActivityAsync`](../../test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestHelper.cs) usage).
- Integration workflow examples: `test/integration/Elsa.*.IntegrationTests` (look for [`PopulateRegistriesAsync()`](../../src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs) and [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) usage).
- Component scenarios exercising persistence: `test/component/Elsa.Workflows.ComponentTests (look for [`AppComponentTest`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Abstractions/AppComponentTest.cs) scaffolds and [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs) assertions).
- **Unit test activity examples:** `test/unit/Elsa.Activities.UnitTests` (look for [`ActivityTestFixture`](../../src/common/Elsa.Testing.Shared/ActivityTestFixture.cs) usage)
- **Integration workflow examples:** `test/integration/Elsa.*.IntegrationTests` (look for [`PopulateRegistriesAsync()`](../../src/common/Elsa.Testing.Shared.Integration/ServiceProviderExtensions.cs) and [`IWorkflowRunner.RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) usage)
- **Component scenarios exercising persistence:** `test/component/Elsa.Workflows.ComponentTests` (look for [`AsyncWorkflowRunner`](../../test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs) and [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs) assertions)

View file

@ -0,0 +1,20 @@
using Elsa.Workflows;
namespace Elsa.Testing.Shared;
/// <summary>
/// Provides extension methods for <see cref="ActivityExecutionContext"/> to enhance its functionality.
/// </summary>
public static class ActivityExecutionContextExtensions
{
/// <summary>
/// Determines whether the specified activity is scheduled in the workflow execution context.
/// </summary>
/// <param name="activityExecutionContext">The activity execution context instance used for checking the scheduled activity.</param>
/// <param name="activity">The activity to check for scheduling.</param>
/// <returns>True if the specified activity is scheduled; otherwise, false.</returns>
public static bool HasScheduledActivity(this ActivityExecutionContext activityExecutionContext, IActivity activity)
{
return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null;
}
}

View file

@ -0,0 +1,174 @@
using Elsa.Common;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Services;
using Elsa.Extensions;
using Elsa.Http.ContentWriters;
using Elsa.Http.Parsers;
using Elsa.Mediator.Contracts;
using Elsa.Resilience;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.CommitStates;
using Elsa.Workflows.Management.Providers;
using Elsa.Workflows.Management.Services;
using Elsa.Workflows.Memory;
using Elsa.Workflows.PortResolvers;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Elsa.Http;
namespace Elsa.Testing.Shared;
/// <summary>
/// A test fixture for unit testing activities in isolation.
/// Provides a fluent API to configure services, variables, and execution context.
/// </summary>
public class ActivityTestFixture
{
private Action<ActivityExecutionContext>? _configureContextAction;
/// <summary>
/// Initializes a new instance of the <see cref="ActivityTestFixture"/> class.
/// </summary>
/// <param name="activity">The activity to test</param>
public ActivityTestFixture(IActivity activity)
{
Activity = activity;
Services = new ServiceCollection();
AddCoreWorkflowServices(Services);
}
/// <summary>
/// Represents the activity being tested within the context of the activity test fixture.
/// Provides access to the activity for configuration, execution, and validation purposes.
/// </summary>
public IActivity Activity { get; }
/// <summary>
/// Gets the service collection for registering additional services.
/// Use this to add services required by the activity under test.
/// </summary>
[UsedImplicitly]
public IServiceCollection Services { get; private set; }
/// <summary>
/// Configures the service collection using a fluent action.
/// </summary>
/// <param name="configure">Action to configure the service collection</param>
/// <returns>The fixture instance for method chaining</returns>
public ActivityTestFixture ConfigureServices(Action<IServiceCollection> configure)
{
configure(Services);
return this;
}
/// <summary>
/// Configures the activity execution context before execution.
/// Multiple calls to this method will chain the configuration actions together.
/// </summary>
/// <param name="configure">Action to configure the activity execution context</param>
/// <returns>The fixture instance for method chaining</returns>
[UsedImplicitly]
public ActivityTestFixture ConfigureContext(Action<ActivityExecutionContext> configure)
{
_configureContextAction += configure;
return this;
}
/// <summary>
/// Executes the activity and returns the execution context.
/// </summary>
/// <returns>The ActivityExecutionContext after execution</returns>
public async Task<ActivityExecutionContext> ExecuteAsync()
{
var context = await BuildAsync();
// Set up variables and inputs, then execute the activity
await SetupExistingVariablesAsync(Activity, context);
await context.EvaluateInputPropertiesAsync();
await Activity.ExecuteAsync(context);
return context;
}
/// <summary>
/// Builds the ActivityExecutionContext without executing the activity.
/// </summary>
public async Task<ActivityExecutionContext> BuildAsync()
{
var serviceProvider = Services.BuildServiceProvider();
var activityRegistry = serviceProvider.GetRequiredService<IActivityRegistry>();
var workflowGraphBuilder = serviceProvider.GetRequiredService<IWorkflowGraphBuilder>();
await activityRegistry.RegisterAsync(Activity.GetType());
var workflow = Workflow.FromActivity(Activity);
var workflowGraph = await workflowGraphBuilder.BuildAsync(workflow);
// Create workflow execution context using the static factory method
var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync(
serviceProvider,
workflowGraph,
$"test-instance-{Guid.NewGuid()}",
CancellationToken.None
);
// Create ActivityExecutionContext for the actual activity we want to test
var context = await workflowExecutionContext.CreateActivityExecutionContextAsync(Activity);
// Apply any context configuration action
_configureContextAction?.Invoke(context);
return context;
}
/// <summary>
/// Sets up existing variables found on the activity in the execution context.
/// This is necessary because in unit tests, variables need to be initialized.
/// </summary>
private static Task SetupExistingVariablesAsync(IActivity activity, ActivityExecutionContext context)
{
var activityType = activity.GetType();
var variableProperties = activityType.GetProperties()
.Where(p => p.PropertyType.IsGenericType &&
p.PropertyType.GetGenericTypeDefinition() == typeof(Variable<>))
.ToList();
foreach (var variable in variableProperties.Select(property => (Variable)property.GetValue(activity)!))
{
variable.Set(context.ExpressionExecutionContext, variable.Value);
}
return Task.CompletedTask;
}
private static void AddCoreWorkflowServices(IServiceCollection services)
{
services.AddLogging();
services.AddSingleton<ISystemClock>(_ => Substitute.For<ISystemClock>());
services.AddSingleton<INotificationSender>(_ => Substitute.For<INotificationSender>());
services.AddSingleton<IActivityVisitor, ActivityVisitor>();
services.AddScoped<IExpressionEvaluator, ExpressionEvaluator>();
services.AddSingleton<IWellKnownTypeRegistry, WellKnownTypeRegistry>();
services.AddSingleton<IActivityDescriber, ActivityDescriber>();
services.AddSingleton<IPropertyDefaultValueResolver, PropertyDefaultValueResolver>();
services.AddSingleton<IActivityFactory, ActivityFactory>();
services.AddSingleton<IPropertyUIHandlerResolver, PropertyUIHandlerResolver>();
services.AddSingleton<IActivityRegistry, ActivityRegistry>();
services.AddScoped<IActivityRegistryLookupService, ActivityRegistryLookupService>();
services.AddScoped<IIdentityGraphService, IdentityGraphService>();
services.AddScoped<IWorkflowGraphBuilder, WorkflowGraphBuilder>();
services.AddScoped<IActivityResolver, PropertyBasedActivityResolver>();
services.AddScoped<IActivityResolver, SwitchActivityResolver>();
services.AddScoped<DefaultActivityInputEvaluator>();
services.AddSingleton<IExpressionDescriptorProvider, DefaultExpressionDescriptorProvider>();
services.AddSingleton<IExpressionDescriptorRegistry, ExpressionDescriptorRegistry>();
services.AddSingleton<IIdentityGenerator>(_ => Substitute.For<IIdentityGenerator>());
services.AddSingleton<IHasher>(_ => Substitute.For<IHasher>());
services.AddSingleton<ICommitStateHandler>(_ => Substitute.For<ICommitStateHandler>());
services.AddSingleton<IActivitySchedulerFactory, ActivitySchedulerFactory>();
services.AddSingleton<IWorkflowExecutionContextSchedulerStrategy, FakeWorkflowExecutionContextSchedulerStrategy>();
services.AddSingleton<IActivityExecutionContextSchedulerStrategy, FakeActivityExecutionContextSchedulerStrategy>();
}
}

View file

@ -0,0 +1,43 @@
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Xunit;
namespace Elsa.Testing.Shared;
/// <summary>
/// General extension methods for ActivityTestFixture.
/// </summary>
public static class ActivityTestFixtureExtensions
{
/// <summary>
/// Validates that an activity has the expected attribute configuration.
/// This is useful for ensuring activities are properly decorated with metadata.
/// </summary>
/// <param name="fixture">The test fixture (unused but enables extension method syntax)</param>
/// <param name="expectedNamespace">Expected namespace (e.g., "Elsa")</param>
/// /// <param name="expectedKind">Expected activity kind</param>
/// <param name="expectedCategory">Expected category (e.g., "HTTP")</param>
/// <param name="expectedDisplayName">Expected display name</param>
/// <param name="expectedDescription">Expected description</param>
public static void AssertActivityAttributes(
this ActivityTestFixture fixture,
string expectedNamespace,
ActivityKind expectedKind,
string? expectedCategory = null,
string? expectedDisplayName = null,
string? expectedDescription = null
)
{
var activityType = fixture.Activity.GetType();
var activityAttribute = activityType.GetCustomAttributes(typeof(ActivityAttribute), false)
.Cast<ActivityAttribute>().FirstOrDefault();
Assert.NotNull(activityAttribute);
Assert.Equal(expectedNamespace, activityAttribute.Namespace);
Assert.Equal(expectedKind, activityAttribute.Kind);
if (expectedCategory != null) Assert.Equal(expectedCategory, activityAttribute.Category);
if (expectedDescription != null) Assert.Equal(expectedDescription, activityAttribute.Description);
if (expectedDisplayName != null) Assert.Equal(expectedDisplayName, activityAttribute.DisplayName);
}
}

View file

@ -9,7 +9,9 @@
<ItemGroup>
<PackageReference Include="xunit.abstractions" />
<PackageReference Include="xunit.assert" />
<PackageReference Include="xunit.extensibility.core" />
<PackageReference Include="NSubstitute" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,46 @@
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
namespace Elsa.Testing.Shared;
/// <summary>
/// Provides a fake implementation of the <see cref="IActivityExecutionContextSchedulerStrategy"/> interface,
/// designed for testing purposes. This class allows scheduling of activities within an activity execution context
/// and enables control over workflows in a simulated environment.
/// </summary>
public class FakeActivityExecutionContextSchedulerStrategy : IActivityExecutionContextSchedulerStrategy
{
/// <summary>
/// Schedules the provided activity for execution within the given activity execution context.
/// </summary>
/// <param name="context">The current activity execution context in which to schedule the activity.</param>
/// <param name="activity">The activity to be scheduled. Can be null, in which case no operation is performed.</param>
/// <param name="owner">The owner activity execution context, if any.</param>
/// <param name="options">Optional scheduling options to influence how the activity is scheduled.</param>
public Task ScheduleActivityAsync(ActivityExecutionContext context, IActivity? activity, ActivityExecutionContext? owner, ScheduleWorkOptions? options = null)
{
if (activity == null)
return Task.CompletedTask;
var activityNode = new ActivityNode(activity!, "");
return ScheduleActivityAsync(context, activityNode, owner, options);
}
/// <summary>
/// Schedules the provided activity for execution within the specified activity execution context using the given scheduling strategy.
/// </summary>
/// <param name="context">The activity execution context in which the activity will be scheduled.</param>
/// <param name="activityNode">The activity node to be scheduled. If null, no operation is performed.</param>
/// <param name="owner">The owner activity execution context, if any, that triggered the scheduling request.</param>
/// <param name="options">Optional scheduling parameters to customize the scheduling behavior.</param>
public Task ScheduleActivityAsync(ActivityExecutionContext context, ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null)
{
if (activityNode == null)
return Task.CompletedTask;
context.WorkflowExecutionContext.Schedule(activityNode!, owner ?? context, options);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,26 @@
using Elsa.Workflows;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
namespace Elsa.Testing.Shared;
/// <summary>
/// Bypasses validation and schedules the activity immediately.
/// </summary>
public class FakeWorkflowExecutionContextSchedulerStrategy : IWorkflowExecutionContextSchedulerStrategy
{
/// <summary>
/// Schedules an activity within the workflow execution context.
/// </summary>
/// <param name="context">The workflow execution context within which the activity will be scheduled.</param>
/// <param name="activityNode">The activity node representing the activity to be scheduled.</param>
/// <param name="owner">The execution context owning the activity execution.</param>
/// <param name="options">Optional scheduling options for configuring the behavior of the scheduling process.</param>
/// <returns>Returns an <see cref="ActivityWorkItem"/> representing the scheduled activity work item.</returns>
public ActivityWorkItem Schedule(WorkflowExecutionContext context, ActivityNode activityNode, ActivityExecutionContext owner, ScheduleWorkOptions? options = null)
{
var workItem = new ActivityWorkItem(activityNode.Activity, owner);
context.Scheduler.Schedule(workItem);
return workItem;
}
}

View file

@ -4,37 +4,25 @@ using Elsa.Expressions.Models;
namespace Elsa.Expressions.Services;
/// <inheritdoc />
public class ExpressionEvaluator : IExpressionEvaluator
public class ExpressionEvaluator(IExpressionDescriptorRegistry registry, IServiceProvider serviceProvider) : IExpressionEvaluator
{
private readonly IExpressionDescriptorRegistry _registry;
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// Initializes a new instance of the <see cref="ExpressionEvaluator"/> class.
/// </summary>
public ExpressionEvaluator(IExpressionDescriptorRegistry registry, IServiceProvider serviceProvider)
{
_registry = registry;
_serviceProvider = serviceProvider;
}
/// <inheritdoc />
public async ValueTask<T?> EvaluateAsync<T>(Expression expression, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default)
public async ValueTask<T?> EvaluateAsync<T>(Expression expression, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = null)
{
return (T?)await EvaluateAsync(expression, typeof(T), context, options);
}
/// <inheritdoc />
public async ValueTask<object?> EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = default)
public async ValueTask<object?> EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options = null)
{
var expressionType = expression.Type;
var expressionDescriptor = _registry.Find(expressionType);
var expressionDescriptor = registry.Find(expressionType);
if (expressionDescriptor == null)
throw new Exception($"Could not find an descriptor for expression type \"{expressionType}\".");
throw new($"Could not find a descriptor for expression type \"{expressionType}\".");
var handler = expressionDescriptor.HandlerFactory(_serviceProvider);
options ??= new ExpressionEvaluatorOptions();
var handler = expressionDescriptor.HandlerFactory(serviceProvider);
options ??= new();
return await handler.EvaluateAsync(expression, returnType, context, options);
}
}

View file

@ -27,9 +27,16 @@ public class Parallel : Container
/// <inheritdoc />
protected override async ValueTask ScheduleChildrenAsync(ActivityExecutionContext context)
{
// If there are no activities, complete immediately
if (Activities.Count == 0)
{
await context.CompleteActivityAsync();
return;
}
context.SetProperty(ScheduledChildrenProperty, Activities.Count);
foreach (var activity in Activities)
foreach (var activity in Activities)
await context.ScheduleActivityAsync(activity, OnChildCompleted);
}

View file

@ -314,10 +314,8 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
/// <param name="options">The options used to schedule the activity.</param>
public async ValueTask ScheduleActivityAsync(IActivity? activity, ActivityExecutionContext? owner, ScheduleWorkOptions? options = null)
{
var activityNode = activity != null
? WorkflowExecutionContext.FindNodeByActivity(activity) ?? throw new InvalidOperationException("The specified activity is not part of the workflow.")
: null;
await ScheduleActivityAsync(activityNode, owner, options);
var schedulerStrategy = GetRequiredService<IActivityExecutionContextSchedulerStrategy>();
await schedulerStrategy.ScheduleActivityAsync(this, activity, owner, options);
}
/// <summary>
@ -326,55 +324,10 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
/// <param name="activityNode">The activity node to schedule.</param>
/// <param name="owner">The activity execution context that owns the scheduled activity.</param>
/// <param name="options">The options used to schedule the activity.</param>
public async ValueTask ScheduleActivityAsync(ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null)
public async Task ScheduleActivityAsync(ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null)
{
if (this.GetIsBackgroundExecution())
{
// Validate that the specified activity is part of the workflow.
if (activityNode != null && !WorkflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity))
throw new InvalidOperationException("The specified activity is not part of the workflow.");
var scheduledActivity = new ScheduledActivity
{
ActivityNodeId = activityNode?.NodeId,
OwnerActivityInstanceId = owner?.Id,
Options = options != null
? new ScheduledActivityOptions
{
CompletionCallback = options?.CompletionCallback?.Method.Name,
Tag = options?.Tag,
ExistingActivityInstanceId = options?.ExistingActivityExecutionContext?.Id,
PreventDuplicateScheduling = options?.PreventDuplicateScheduling ?? false,
Variables = options?.Variables?.ToList(),
Input = options?.Input
}
: null
};
var scheduledActivities = this.GetBackgroundScheduledActivities().ToList();
scheduledActivities.Add(scheduledActivity);
this.SetBackgroundScheduledActivities(scheduledActivities);
return;
}
var completionCallback = options?.CompletionCallback;
owner ??= this;
if (activityNode == null)
{
if (completionCallback != null)
{
Tag = options?.Tag;
var completedContext = new ActivityCompletedContext(this, this);
await completionCallback(completedContext);
}
else
await owner.CompleteActivityAsync();
return;
}
WorkflowExecutionContext.Schedule(activityNode, owner, options);
var schedulerStrategy = GetRequiredService<IActivityExecutionContextSchedulerStrategy>();
await schedulerStrategy.ScheduleActivityAsync(this, activityNode, owner, options);
}
/// <summary>

View file

@ -0,0 +1,36 @@
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
namespace Elsa.Workflows;
/// <summary>
/// Defines a strategy for scheduling activities in a workflow execution context.
/// </summary>
public interface IActivityExecutionContextSchedulerStrategy
{
/// <summary>
/// Schedules an activity for execution based on the specified context, activity, owner, and scheduling options.
/// </summary>
/// <param name="context">The execution context of the activity.</param>
/// <param name="activity">The activity to be scheduled. Can be null if no specific activity is targeted.</param>
/// <param name="owner">The context of the owning activity, if applicable. Can be null.</param>
/// <param name="options">Optional scheduling options for the activity.</param>
Task ScheduleActivityAsync(
ActivityExecutionContext context,
IActivity? activity,
ActivityExecutionContext? owner,
ScheduleWorkOptions? options = null);
/// <summary>
/// Schedules an activity node for execution within the specified activity execution context.
/// </summary>
/// <param name="context">The execution context in which the activity node will be scheduled.</param>
/// <param name="activityNode">The activity node to be scheduled. Can be null if no specific node is targeted.</param>
/// <param name="owner">The context of the owning activity execution, if applicable. Can be null.</param>
/// <param name="options">Optional settings for scheduling the activity node.</param>
Task ScheduleActivityAsync(
ActivityExecutionContext context,
ActivityNode? activityNode,
ActivityExecutionContext? owner = null,
ScheduleWorkOptions? options = null);
}

View file

@ -0,0 +1,23 @@
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
namespace Elsa.Workflows;
/// <summary>
/// Defines a strategy interface for scheduling activities within the context of a workflow execution.
/// </summary>
public interface IWorkflowExecutionContextSchedulerStrategy
{
/// <summary>
/// Schedules an <see cref="ActivityWorkItem"/> in the context of the provided workflow execution.
/// </summary>
/// <param name="context">The execution context of the workflow in which the activity is being scheduled.</param>
/// <param name="activityNode">The node representing the activity to be scheduled.</param>
/// <param name="owner">The <see cref="ActivityExecutionContext"/> that owns the scheduled activity.</param>
/// <param name="options">Optional parameters for scheduling the work item.</param>
/// <returns>The scheduled <see cref="ActivityWorkItem"/>.</returns>
ActivityWorkItem Schedule(WorkflowExecutionContext context,
ActivityNode activityNode,
ActivityExecutionContext owner,
ScheduleWorkOptions? options = null);
}

View file

@ -459,7 +459,15 @@ public static partial class ActivityExecutionContextExtensions
return context.Metadata.TryGetValue(ExtensionsMetadataKey, out var value) ? value as Dictionary<string, object?> : null;
}
public static object? GetExecutionOutput<TProp>(this ActivityExecutionContext context, Expression<Func<Activity, TProp>> property)
/// <summary>
/// Gets the output of an activity even when the context is not the same as the activity's context.
/// This is useful when you want to get the output of a previously executed activity.
/// </summary>
/// <param name="context"></param>
/// <param name="property"></param>
/// <typeparam name="TProp"></typeparam>
/// <returns></returns>
public static object? GetActivityOutput<TProp>(this ActivityExecutionContext context, Expression<Func<TProp>> property)
{
if (property.Body is not MemberExpression memberExpr)
{
@ -467,10 +475,10 @@ public static partial class ActivityExecutionContextExtensions
}
var propertyName = memberExpr.Member.Name;
// Treat both null and 0 (for numeric types) as "unset" values.
var value = context.Get(propertyName);
if (value != null && !(value is int i && i == 0) && !(value is long l && l == 0L) && !(value is double d && d == 0.0) && !(value is float f && f == 0.0f))
if (value != null)
return value;
var registry = context.WorkflowExecutionContext.GetActivityOutputRegister();
@ -478,6 +486,6 @@ public static partial class ActivityExecutionContextExtensions
return registry.FindOutputByActivityInstanceId(context.Id, propertyName);
}
internal static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue<bool>("HasEvaluatedProperties", out var value) && value;
internal static void SetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties["HasEvaluatedProperties"] = true;
public static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue<bool>("HasEvaluatedProperties", out var value) && value;
public static void SetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties["HasEvaluatedProperties"] = true;
}

View file

@ -109,30 +109,8 @@ public static class WorkflowExecutionContextExtensions
ActivityExecutionContext owner,
ScheduleWorkOptions? options = null)
{
// Validate that the specified activity is part of the workflow.
if (!workflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity))
throw new InvalidOperationException("The specified activity is not part of the workflow.");
var scheduler = workflowExecutionContext.Scheduler;
if (options?.PreventDuplicateScheduling == true)
{
// Check if the activity is already scheduled for the specified owner.
var existingWorkItem = scheduler.Find(x => x.Activity.NodeId == activityNode.NodeId && x.Owner == owner);
if (existingWorkItem != null)
return existingWorkItem;
}
var activity = activityNode.Activity;
var tag = options?.Tag;
var workItem = new ActivityWorkItem(activity, owner, tag, options?.Variables, options?.ExistingActivityExecutionContext, options?.Input);
var completionCallback = options?.CompletionCallback;
workflowExecutionContext.AddCompletionCallback(owner, activityNode, completionCallback, tag);
scheduler.Schedule(workItem);
return workItem;
var schedulerStrategy = workflowExecutionContext.GetRequiredService<IWorkflowExecutionContextSchedulerStrategy>();
return schedulerStrategy.Schedule(workflowExecutionContext, activityNode, owner, options);
}
/// <summary>

View file

@ -170,6 +170,8 @@ public class WorkflowsFeature : FeatureBase
.AddScoped<IWorkflowGraphBuilder, WorkflowGraphBuilder>()
.AddScoped<IWorkflowStateExtractor, WorkflowStateExtractor>()
.AddScoped<IActivitySchedulerFactory, ActivitySchedulerFactory>()
.AddSingleton<IWorkflowExecutionContextSchedulerStrategy, WorkflowExecutionContextSchedulerStrategy>()
.AddSingleton<IActivityExecutionContextSchedulerStrategy, ActivityExecutionContextSchedulerStrategy>()
.AddScoped(CommitStateHandler)
.AddSingleton<IHasher, Hasher>()
.AddSingleton<IStimulusHasher, StimulusHasher>()

View file

@ -0,0 +1,71 @@
using Elsa.Extensions;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
namespace Elsa.Workflows;
/// <inheritdoc />
public class ActivityExecutionContextSchedulerStrategy : IActivityExecutionContextSchedulerStrategy
{
/// <inheritdoc />
public async Task ScheduleActivityAsync(ActivityExecutionContext context, IActivity? activity, ActivityExecutionContext? owner, ScheduleWorkOptions? options = null)
{
var activityNode = activity != null
? context.WorkflowExecutionContext.FindNodeByActivity(activity) ?? throw new InvalidOperationException("The specified activity is not part of the workflow.")
: null;
await ScheduleActivityAsync(context, activityNode, owner, options);
}
/// <inheritdoc />
public async Task ScheduleActivityAsync(ActivityExecutionContext context, ActivityNode? activityNode, ActivityExecutionContext? owner = null, ScheduleWorkOptions? options = null)
{
var workflowExecutionContext = context.WorkflowExecutionContext;
if (context.GetIsBackgroundExecution())
{
// Validate that the specified activity is part of the workflow.
if (activityNode != null && !workflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity))
throw new InvalidOperationException("The specified activity is not part of the workflow.");
var scheduledActivity = new ScheduledActivity
{
ActivityNodeId = activityNode?.NodeId,
OwnerActivityInstanceId = owner?.Id,
Options = options != null
? new ScheduledActivityOptions
{
CompletionCallback = options?.CompletionCallback?.Method.Name,
Tag = options?.Tag,
ExistingActivityInstanceId = options?.ExistingActivityExecutionContext?.Id,
PreventDuplicateScheduling = options?.PreventDuplicateScheduling ?? false,
Variables = options?.Variables?.ToList(),
Input = options?.Input
}
: null
};
var scheduledActivities = context.GetBackgroundScheduledActivities().ToList();
scheduledActivities.Add(scheduledActivity);
context.SetBackgroundScheduledActivities(scheduledActivities);
return;
}
var completionCallback = options?.CompletionCallback;
owner ??= context;
if (activityNode == null)
{
if (completionCallback != null)
{
context.Tag = options?.Tag;
var completedContext = new ActivityCompletedContext(context, context);
await completionCallback(completedContext);
}
else
await owner.CompleteActivityAsync();
return;
}
workflowExecutionContext.Schedule(activityNode, owner, options);
}
}

View file

@ -0,0 +1,37 @@
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
namespace Elsa.Workflows;
/// <inheritdoc />
public class WorkflowExecutionContextSchedulerStrategy : IWorkflowExecutionContextSchedulerStrategy
{
/// <inheritdoc />
public ActivityWorkItem Schedule(WorkflowExecutionContext context, ActivityNode activityNode, ActivityExecutionContext owner, ScheduleWorkOptions? options = null)
{
// Validate that the specified activity is part of the workflow.
if (!context.NodeActivityLookup.ContainsKey(activityNode.Activity))
throw new InvalidOperationException("The specified activity is not part of the workflow.");
var scheduler = context.Scheduler;
if (options?.PreventDuplicateScheduling == true)
{
// Check if the activity is already scheduled for the specified owner.
var existingWorkItem = scheduler.Find(x => x.Activity.NodeId == activityNode.NodeId && x.Owner == owner);
if (existingWorkItem != null)
return existingWorkItem;
}
var activity = activityNode.Activity;
var tag = options?.Tag;
var workItem = new ActivityWorkItem(activity, owner, tag, options?.Variables, options?.ExistingActivityExecutionContext, options?.Input);
var completionCallback = options?.CompletionCallback;
context.AddCompletionCallback(owner, activityNode, completionCallback, tag);
scheduler.Schedule(workItem);
return workItem;
}
}

View file

@ -0,0 +1,189 @@
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Models;
using Xunit.Abstractions;
using Parallel = Elsa.Workflows.Activities.Parallel;
namespace Elsa.Activities.IntegrationTests;
public class ParallelTests
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _serviceProvider;
public ParallelTests(ITestOutputHelper testOutputHelper)
{
_serviceProvider = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
}
[Fact(DisplayName = "Parallel executes all child activities and completes")]
public async Task Parallel_ExecutesAllChildren_AndCompletes()
{
// Arrange
var parallel = new Parallel(
new WriteLine("Activity 1"),
new WriteLine("Activity 2"),
new WriteLine("Activity 3")
);
// Act
var result = await _serviceProvider.RunActivityAsync(parallel);
// Assert
var journal = result.Journal;
var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel);
Assert.NotNull(parallelContext);
Assert.Equal(ActivityStatus.Completed, parallelContext.Status);
Assert.Equal(3, _capturingTextWriter.Lines.Count);
Assert.Contains("Activity 1", _capturingTextWriter.Lines);
Assert.Contains("Activity 2", _capturingTextWriter.Lines);
Assert.Contains("Activity 3", _capturingTextWriter.Lines);
}
[Fact(DisplayName = "Parallel completes when empty")]
public async Task Parallel_Completes_WhenEmpty()
{
// Arrange
var parallel = new Parallel();
// Act
var result = await _serviceProvider.RunActivityAsync(parallel);
// Assert
var journal = result.Journal;
var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel);
Assert.NotNull(parallelContext);
Assert.Equal(ActivityStatus.Completed, parallelContext.Status);
Assert.Empty(_capturingTextWriter.Lines);
}
[Fact(DisplayName = "Parallel executes single child activity and completes")]
public async Task Parallel_ExecutesSingleChild_AndCompletes()
{
// Arrange
var parallel = new Parallel(
new WriteLine("Single Activity")
);
// Act
var result = await _serviceProvider.RunActivityAsync(parallel);
// Assert
var journal = result.Journal;
var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel);
Assert.NotNull(parallelContext);
Assert.Equal(ActivityStatus.Completed, parallelContext.Status);
Assert.Single(_capturingTextWriter.Lines);
Assert.Equal("Single Activity", _capturingTextWriter.Lines.Single());
}
[Fact(DisplayName = "Parallel executes multiple different activity types")]
public async Task Parallel_ExecutesMixedActivityTypes_AndCompletes()
{
// Arrange
var parallel = new Parallel(
new WriteLine("First"),
new SetName("TestName"),
new WriteLine("Second")
);
// Act
var result = await _serviceProvider.RunActivityAsync(parallel);
// Assert
var journal = result.Journal;
var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel);
Assert.NotNull(parallelContext);
Assert.Equal(ActivityStatus.Completed, parallelContext.Status);
Assert.Equal(2, _capturingTextWriter.Lines.Count);
Assert.Contains("First", _capturingTextWriter.Lines);
Assert.Contains("Second", _capturingTextWriter.Lines);
}
[Fact(DisplayName = "Parallel completes only after all children complete")]
public async Task Parallel_CompletesOnlyAfterAllChildrenComplete()
{
// Arrange
var parallel = new Parallel(
new WriteLine("Child 1"),
new WriteLine("Child 2"),
new WriteLine("Child 3"),
new WriteLine("Child 4")
);
// Act
var result = await _serviceProvider.RunActivityAsync(parallel);
// Assert
var journal = result.Journal;
var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel);
Assert.NotNull(parallelContext);
Assert.Equal(ActivityStatus.Completed, parallelContext.Status);
Assert.Equal(4, _capturingTextWriter.Lines.Count);
}
[Fact(DisplayName = "Parallel executes nested Parallel activities")]
public async Task Parallel_ExecutesNestedParallel_AndCompletes()
{
// Arrange
var innerParallel = new Parallel(
new WriteLine("Inner 1"),
new WriteLine("Inner 2")
);
var outerParallel = new Parallel(
new WriteLine("Outer 1"),
innerParallel,
new WriteLine("Outer 2")
);
// Act
var result = await _serviceProvider.RunActivityAsync(outerParallel);
// Assert
var journal = result.Journal;
var outerParallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity == outerParallel);
var innerParallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity == innerParallel);
Assert.NotNull(outerParallelContext);
Assert.NotNull(innerParallelContext);
Assert.Equal(ActivityStatus.Completed, outerParallelContext.Status);
Assert.Equal(ActivityStatus.Completed, innerParallelContext.Status);
Assert.Equal(4, _capturingTextWriter.Lines.Count);
Assert.Contains("Outer 1", _capturingTextWriter.Lines);
Assert.Contains("Outer 2", _capturingTextWriter.Lines);
Assert.Contains("Inner 1", _capturingTextWriter.Lines);
Assert.Contains("Inner 2", _capturingTextWriter.Lines);
}
[Fact(DisplayName = "Parallel remains in Running state when a child activity faults")]
public async Task Parallel_RemainsRunning_WhenChildFaults()
{
// Arrange
var parallel = new Parallel(
new WriteLine("Before Fault"),
new Fault { Message = new("Test fault") },
new WriteLine("After Fault")
);
// Act
var result = await _serviceProvider.RunActivityAsync(parallel);
// Assert
var journal = result.Journal;
var parallelContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is Parallel);
Assert.NotNull(parallelContext);
Assert.Equal(ActivityStatus.Running, parallelContext.Status);
Assert.Equal(1, parallelContext.AggregateFaultCount);
// The non-faulted activities should still execute
Assert.Contains("Before Fault", _capturingTextWriter.Lines);
Assert.Contains("After Fault", _capturingTextWriter.Lines);
}
}

View file

@ -0,0 +1,21 @@
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
namespace Elsa.Workflows.IntegrationTests.Evaluation;
/// <summary>
/// Shared test helpers for input evaluation tests.
/// </summary>
public static class EvaluationTestHelpers
{
/// <summary>
/// Creates an ActivityExecutionContext for any activity.
/// </summary>
public static async Task<ActivityExecutionContext> CreateContextAsync<TActivity>(TActivity activity)
where TActivity : IActivity
{
var fixture = new ActivityTestFixture(activity);
return await fixture.BuildAsync();
}
}

View file

@ -0,0 +1,85 @@
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Exceptions;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers;
namespace Elsa.Workflows.IntegrationTests.Evaluation;
public class InputEvaluationErrorTests
{
[Theory(DisplayName = "Wraps evaluation exceptions in InputEvaluationException")]
[InlineData(typeof(InvalidOperationException), "Expression evaluation failed")]
[InlineData(typeof(ArgumentException), "Detailed inner error message")]
[InlineData(typeof(TimeoutException), "Expression evaluation timed out")]
public async Task WrapsEvaluationExceptions(Type exceptionType, string errorMessage)
{
// Arrange
var exception = (Exception)Activator.CreateInstance(exceptionType, errorMessage)!;
var writeLine = new WriteLine("Test");
var context = await CreateContextWithMockEvaluatorAsync(writeLine, exception);
// Act & Assert
var wrappedException = await Assert.ThrowsAsync<InputEvaluationException>(
async () => await context.EvaluateInputPropertiesAsync());
Assert.Equal("Text", wrappedException.InputName);
Assert.Contains("Failed to evaluate activity input 'Text'", wrappedException.Message);
Assert.IsType(exceptionType, wrappedException.InnerException);
Assert.Equal(errorMessage, wrappedException.InnerException.Message);
Assert.NotNull(wrappedException.InnerException.StackTrace);
}
[Fact(DisplayName = "Handles empty expression gracefully without throwing")]
public async Task HandlesEmptyExpressionGracefully()
{
// Arrange
var writeLine = new WriteLine("");
var context = await CreateContextAsync(writeLine);
// Act
var exception = await Record.ExceptionAsync(
async () => await context.EvaluateInputPropertiesAsync());
// Assert
Assert.Null(exception);
}
private static async Task<ActivityExecutionContext> CreateContextWithMockEvaluatorAsync(
WriteLine writeLine,
Exception thrownException)
{
var mockHandler = Substitute.For<IExpressionHandler>();
mockHandler.EvaluateAsync(
Arg.Any<Expression>(),
Arg.Any<Type>(),
Arg.Any<ExpressionExecutionContext>(),
Arg.Any<ExpressionEvaluatorOptions>())
.Throws(thrownException);
var mockDescriptor = new ExpressionDescriptor
{
Type = "Literal",
HandlerFactory = _ => mockHandler
};
var mockProvider = Substitute.For<IExpressionDescriptorProvider>();
mockProvider.GetDescriptors().Returns([mockDescriptor]);
var fixture = new ActivityTestFixture(writeLine)
.ConfigureServices(services =>
{
services.RemoveWhere(d => d.ServiceType == typeof(IExpressionDescriptorProvider));
services.AddSingleton(mockProvider);
});
return await fixture.BuildAsync();
}
}

View file

@ -0,0 +1,73 @@
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Models;
using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers;
namespace Elsa.Workflows.IntegrationTests.Evaluation;
public class InputEvaluationTests
{
[Theory(DisplayName = "Evaluates literal input values correctly")]
[InlineData("Literal Value")]
[InlineData("")]
[InlineData("Special chars: !@#$%")]
public async Task EvaluatesLiteralInput(string inputValue)
{
// Arrange
var writeLine = new WriteLine(inputValue);
var context = await CreateContextAsync(writeLine);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.True(context.ActivityState.ContainsKey("Text"));
Assert.Equal(inputValue, context.ActivityState["Text"]);
}
[Fact(DisplayName = "Evaluates delegate input expressions")]
public async Task EvaluatesDelegateInput()
{
// Arrange
const string original = "Original";
var writeLine = new WriteLine(new Input<string>(() => $"TRANSFORMED_{original}"));
var context = await CreateContextAsync(writeLine);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.Equal("TRANSFORMED_Original", context.ActivityState["Text"]);
}
[Fact(DisplayName = "Handles null input gracefully")]
public async Task HandlesNullInput()
{
// Arrange
var writeLine = new WriteLine(new Input<string>(default(string)!));
var context = await CreateContextAsync(writeLine);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.Null(context.ActivityState["Text"]);
}
[Fact(DisplayName = "Stores evaluated value in activity state")]
public async Task StoresEvaluatedValueInActivityState()
{
// Arrange
const string expectedValue = "Test Value";
var writeLine = new WriteLine(expectedValue);
var context = await CreateContextAsync(writeLine);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.NotNull(writeLine.Text);
Assert.True(context.ActivityState.ContainsKey("Text"));
Assert.Equal(expectedValue, context.ActivityState["Text"]);
}
}

View file

@ -0,0 +1,87 @@
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers;
namespace Elsa.Workflows.IntegrationTests.Evaluation;
public class InputPropertyEvaluationTests
{
[Fact(DisplayName = "Evaluates all auto-evaluate inputs")]
public async Task EvaluatesAllAutoEvaluateInputs()
{
// Arrange
const string expectedText = "Hello, World!";
var writeLine = new WriteLine(expectedText);
var context = await CreateContextAsync(writeLine);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.Contains(expectedText, context.ActivityState.Values);
}
[Fact(DisplayName = "Evaluates specific input by name")]
public async Task EvaluatesSpecificInputByName()
{
// Arrange
const string expectedValue = "Test Value";
var writeLine = new WriteLine(expectedValue);
var context = await CreateContextAsync(writeLine);
// Act
var result = await context.EvaluateInputPropertyAsync("Text");
// Assert
Assert.Equal(expectedValue, result);
Assert.Contains(expectedValue, context.ActivityState.Values);
}
[Fact(DisplayName = "Evaluates specific input by property expression")]
public async Task EvaluatesSpecificInputByExpression()
{
// Arrange
const int expectedValue = 42;
var variable = new Variable<int>("myVar", expectedValue, "myVar");
var setVariable = new SetVariable<int>(variable, new Input<int>(expectedValue));
var context = await CreateContextAsync(setVariable);
// Act
var result = await context.EvaluateInputPropertyAsync<SetVariable<int>, int>(x => x.Value);
// Assert
Assert.Equal(expectedValue, result);
}
[Fact(DisplayName = "Throws when input name not found")]
public async Task ThrowsWhenInputNameNotFound()
{
// Arrange
var writeLine = new WriteLine("Test");
var context = await CreateContextAsync(writeLine);
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(
async () => await context.EvaluateInputPropertyAsync("NonExistentInput"));
Assert.Contains("No input with name NonExistentInput could be found", exception.Message);
}
[Fact(DisplayName = "Sets HasEvaluatedProperties flag after evaluation")]
public async Task SetsHasEvaluatedPropertiesFlag()
{
// Arrange
var writeLine = new WriteLine("Test");
var context = await CreateContextAsync(writeLine);
Assert.False(context.GetHasEvaluatedProperties());
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.True(context.GetHasEvaluatedProperties());
}
}

View file

@ -0,0 +1,152 @@
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers;
namespace Elsa.Workflows.IntegrationTests.Evaluation;
public class InputStateStorageTests
{
[Theory(DisplayName = "Stores primitive values in activity state")]
[InlineData(42)]
[InlineData(0)]
[InlineData(-100)]
public async Task StoresPrimitiveIntValues(int expectedValue)
{
// Arrange
var variable = new Variable<int>("intVar", 0, "intVar");
var setVariable = new SetVariable<int>(variable, new Input<int>(expectedValue));
var context = await CreateContextAsync(setVariable);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.Contains(expectedValue, context.ActivityState.Values);
}
[Theory(DisplayName = "Stores boolean values in activity state")]
[InlineData(true)]
[InlineData(false)]
public async Task StoresBooleanValues(bool expectedValue)
{
// Arrange
var variable = new Variable<bool>("boolVar", false, "boolVar");
var setVariable = new SetVariable<bool>(variable, new Input<bool>(expectedValue));
var context = await CreateContextAsync(setVariable);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.Contains(expectedValue, context.ActivityState.Values);
}
[Theory(DisplayName = "Stores string values in activity state")]
[InlineData("Test String")]
[InlineData("")]
public async Task StoresStringValues(string expectedValue)
{
// Arrange
var variable = new Variable<string>("stringVar", "", "stringVar");
var setVariable = new SetVariable<string>(variable, new Input<string>(expectedValue));
var context = await CreateContextAsync(setVariable);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.Contains(expectedValue, context.ActivityState.Values);
}
[Fact(DisplayName = "Stores values using input descriptor name as key")]
public async Task StoresValuesByInputDescriptorName()
{
// Arrange
const string textValue = "Named Input Test";
var writeLine = new WriteLine(textValue);
var context = await CreateContextAsync(writeLine);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.True(context.ActivityState.ContainsKey("Text"));
Assert.Equal(textValue, context.ActivityState["Text"]);
}
[Fact(DisplayName = "Stores null values correctly")]
public async Task StoresNullValues()
{
// Arrange
var writeLine = new WriteLine(new Input<string>(default(string)!));
var context = await CreateContextAsync(writeLine);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.True(context.ActivityState.ContainsKey("Text"));
Assert.Null(context.ActivityState["Text"]);
}
[Fact(DisplayName = "Overwrites previous values on re-evaluation")]
public async Task OverwritesPreviousValuesOnReEvaluation()
{
// Arrange
const string initialValue = "Initial";
const string updatedValue = "Updated";
var writeLine = new WriteLine(initialValue);
var context = await CreateContextAsync(writeLine);
// Act - First evaluation
await context.EvaluateInputPropertiesAsync();
var firstValue = context.ActivityState["Text"];
// Modify and re-evaluate
writeLine.Text = new(updatedValue);
await context.EvaluateInputPropertyAsync("Text");
var secondValue = context.ActivityState["Text"];
// Assert
Assert.Equal(initialValue, firstValue);
Assert.Equal(updatedValue, secondValue);
}
[Fact(DisplayName = "Stores multiple inputs separately")]
public async Task StoresMultipleInputsSeparately()
{
// Arrange
var variable = new Variable<int>("multiVar", 100, "multiVar");
var setVariable = new SetVariable<int>(variable, new Input<int>(200));
var context = await CreateContextAsync(setVariable);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.True(context.ActivityState.Count >= 1);
}
[Fact(DisplayName = "Maintains state across multiple evaluations")]
public async Task MaintainsStateAcrossMultipleEvaluations()
{
// Arrange
const string value1 = "First";
var writeLine1 = new WriteLine(value1);
var context = await CreateContextAsync(writeLine1);
// Act - First evaluation
await context.EvaluateInputPropertiesAsync();
var stateCountAfterFirst = context.ActivityState.Count;
// Second evaluation of same input
await context.EvaluateInputPropertyAsync("Text");
var stateCountAfterSecond = context.ActivityState.Count;
// Assert
Assert.Equal(stateCountAfterFirst, stateCountAfterSecond);
Assert.Equal(value1, context.ActivityState["Text"]);
}
}

View file

@ -0,0 +1,103 @@
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using static Elsa.Workflows.IntegrationTests.Evaluation.EvaluationTestHelpers;
namespace Elsa.Workflows.IntegrationTests.Evaluation;
public class WrappedInputEvaluationTests
{
[Fact(DisplayName = "Sets memory block reference with deterministic ID")]
public async Task SetsMemoryBlockReferenceWithDeterministicId()
{
// Arrange
const string expectedValue = "Test Value";
var writeLine = new WriteLine(new Input<string>(expectedValue));
var context = await CreateContextAsync(writeLine);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
var memoryReference = writeLine.Text.MemoryBlockReference();
Assert.NotNull(memoryReference);
var storedValue = memoryReference.Get(context.ExpressionExecutionContext);
Assert.Equal(expectedValue, storedValue);
}
[Fact(DisplayName = "Handles missing memory block reference ID")]
public async Task HandlesMissingMemoryBlockReferenceId()
{
// Arrange
const string expectedValue = "Memory Test";
var writeLine = new WriteLine(new Input<string>(expectedValue));
var context = await CreateContextAsync(writeLine);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
var memoryReference = writeLine.Text.MemoryBlockReference();
Assert.NotNull(memoryReference);
Assert.NotNull(memoryReference.Id);
Assert.False(string.IsNullOrEmpty(memoryReference.Id));
}
[Fact(DisplayName = "Stores evaluated value in ExpressionExecutionContext")]
public async Task StoresEvaluatedValueInExpressionExecutionContext()
{
// Arrange
const int expectedValue = 123;
var variable = new Variable<int>("testVar", 0, "testVar");
var setVariable = new SetVariable<int>(variable, new Input<int>(expectedValue));
var context = await CreateContextAsync(setVariable);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
var memoryReference = setVariable.Value.MemoryBlockReference();
var storedValue = memoryReference.Get(context.ExpressionExecutionContext);
Assert.Equal(expectedValue, storedValue);
}
[Theory(DisplayName = "Evaluates different expression types correctly")]
[InlineData("Literal", "Literal Test")]
[InlineData("Delegate", "Delegate Result")]
[InlineData("Variable", "Variable Value")]
public async Task EvaluatesExpressionTypes(string expressionType, string expectedValue)
{
// Arrange
WriteLine writeLine;
Variable<string>? variable = null;
switch (expressionType)
{
case "Literal":
var literal = new Literal<string>(expectedValue);
writeLine = new(new Input<string>(literal));
break;
case "Delegate":
writeLine = new(new Input<string>(() => expectedValue));
break;
case "Variable":
variable = new("myVar", expectedValue, "myVar");
writeLine = new(new Input<string>(variable));
break;
default:
throw new ArgumentException("Invalid expression type");
}
var context = await CreateContextAsync(writeLine);
variable?.Set(context.ExpressionExecutionContext, expectedValue);
// Act
await context.EvaluateInputPropertiesAsync();
// Assert
Assert.Equal(expectedValue, context.ActivityState["Text"]);
}
}

View file

@ -1,7 +1,8 @@
using Elsa.Activities.UnitTests.Helpers;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
// ReSharper disable MethodHasAsyncOverload
namespace Elsa.Activities.UnitTests.Console;
@ -17,18 +18,16 @@ public class WriteLineTests
_mockProvider.GetTextWriter().Returns(_mockTextWriter);
}
[Fact]
public async Task Should_Write_String_Literal_To_Output()
[Theory]
[InlineData("Hello, World!")]
[InlineData("")]
public async Task Should_Write_Text_To_Output(string expectedText)
{
// Arrange
const string expectedText = "Hello, World!";
var writeLine = new WriteLine(expectedText);
// Act
await ActivityTestHelper.ExecuteActivityAsync(writeLine, services =>
{
services.AddSingleton(_mockProvider);
});
await ExecuteAsync(writeLine);
// Assert
_mockTextWriter.Received(1).WriteLine(expectedText);
@ -38,35 +37,15 @@ public class WriteLineTests
public async Task Should_Write_Null_Value_To_Output()
{
// Arrange
var writeLine = new WriteLine(new Input<string>((string?)null));
var writeLine = new WriteLine(new Input<string>(default(string)!));
// Act
await ActivityTestHelper.ExecuteActivityAsync(writeLine, services =>
{
services.AddSingleton(_mockProvider);
});
await ExecuteAsync(writeLine);
// Assert
_mockTextWriter.Received(1).WriteLine(Arg.Is<string?>(s => s == null));
}
[Fact]
public async Task Should_Write_Empty_String_To_Output()
{
// Arrange
const string expectedText = "";
var writeLine = new WriteLine(expectedText);
// Act
await ActivityTestHelper.ExecuteActivityAsync(writeLine, services =>
{
services.AddSingleton(_mockProvider);
});
// Assert
_mockTextWriter.Received(1).WriteLine(expectedText);
}
[Fact]
public async Task Should_Use_Default_Provider_When_None_Configured()
{
@ -75,9 +54,15 @@ public class WriteLineTests
var writeLine = new WriteLine(textToWrite);
// Act & Assert - Should not throw exception when no provider is configured
var exception = await Record.ExceptionAsync(async () =>
await ActivityTestHelper.ExecuteActivityAsync(writeLine));
var exception = await Record.ExceptionAsync(() => ExecuteAsync(writeLine));
Assert.Null(exception);
}
private async Task ExecuteAsync(IActivity activity)
{
await new ActivityTestFixture(activity)
.ConfigureServices(services => services.AddSingleton(_mockProvider))
.ExecuteAsync();
}
}

View file

@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Http\Elsa.Http.csproj" />

View file

@ -0,0 +1,141 @@
using System.Net;
using System.Text;
using Elsa.Http;
using Elsa.Http.ContentWriters;
using Elsa.Http.Parsers;
using Elsa.Resilience;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Activities.UnitTests.Helpers;
/// <summary>
/// Extension methods for configuring HTTP-related services in ActivityTestFixture.
/// </summary>
public static class ActivityTestFixtureHttpExtensions
{
/// <summary>
/// Configures services commonly needed for HTTP activity testing.
/// This is a convenience method that combines HTTP services, mock HTTP client factory, and mock resilient invoker.
/// </summary>
/// <param name="fixture">The test fixture to configure</param>
/// <param name="responseHandler">Handler for HTTP requests, or null to use a default OK response</param>
/// <returns>The fixture instance for method chaining</returns>
public static ActivityTestFixture WithHttpServices(
this ActivityTestFixture fixture,
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>>? responseHandler = null)
{
fixture.ConfigureServices(services =>
{
var defaultHandler = responseHandler ?? ((_, _) => Task.FromResult(CreateHttpResponse(HttpStatusCode.OK)));
var mockHttpClientFactory = CreateMockHttpClientFactory(defaultHandler);
services.AddSingleton(mockHttpClientFactory);
services.AddSingleton(CreateMockResilientActivityInvoker());
AddHttpServices(services);
services.AddLogging();
});
return fixture;
}
/// <summary>
/// Creates a simple HTTP response message with specified status code and optional JSON content.
/// </summary>
/// <param name="statusCode">The HTTP status code for the response</param>
/// <param name="jsonContent">Optional JSON content for the response body</param>
/// <param name="additionalHeaders">Optional additional headers to add to the response</param>
/// <returns>An HttpResponseMessage configured with the specified parameters</returns>
public static HttpResponseMessage CreateHttpResponse(
HttpStatusCode statusCode,
string? jsonContent = null,
Dictionary<string, string>? additionalHeaders = null)
{
var response = new HttpResponseMessage(statusCode);
if (jsonContent != null)
{
response.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
}
if (additionalHeaders != null)
{
foreach (var header in additionalHeaders)
{
response.Headers.Add(header.Key, header.Value);
}
}
return response;
}
/// <summary>
/// Adds all HTTP-related services to the service collection.
/// This includes content factories, parsers, and HTTP client services.
/// </summary>
private static void AddHttpServices(IServiceCollection services)
{
// Add all required HTTP services
services.AddSingleton<IHttpContentFactory, JsonContentFactory>();
services.AddSingleton<IHttpContentFactory, TextContentFactory>();
services.AddSingleton<IHttpContentFactory, XmlContentFactory>();
services.AddSingleton<IHttpContentFactory, FormUrlEncodedHttpContentFactory>();
// Add HTTP content parsers
AddHttpContentParsers(services);
// Add other required services
services.AddHttpClient();
}
/// <summary>
/// Adds HTTP content parsers to the service collection.
/// These parsers are responsible for parsing different content types in HTTP responses.
/// </summary>
private static void AddHttpContentParsers(IServiceCollection services)
{
services.AddSingleton<IHttpContentParser, JsonHttpContentParser>();
services.AddSingleton<IHttpContentParser, PlainTextHttpContentParser>();
services.AddSingleton<IHttpContentParser, XmlHttpContentParser>();
services.AddSingleton<IHttpContentParser, TextHtmlHttpContentParser>();
services.AddSingleton<IHttpContentParser, FileHttpContentParser>();
}
/// <summary>
/// Creates a mock IResilientActivityInvoker that directly executes the provided action.
/// Useful for testing activities that depend on resilient execution without the complexity of retry policies.
/// </summary>
private static IResilientActivityInvoker CreateMockResilientActivityInvoker()
{
var mock = Substitute.For<IResilientActivityInvoker>();
// Configure the mock to simply execute the provided action directly
mock.InvokeAsync(
Arg.Any<IResilientActivity>(),
Arg.Any<ActivityExecutionContext>(),
Arg.Any<Func<Task<HttpResponseMessage>>>(),
Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Func<Task<HttpResponseMessage>>>(2);
return action.Invoke();
});
return mock;
}
/// <summary>
/// Creates a mock HTTP client factory with a test handler for controlled HTTP responses.
/// </summary>
private static IHttpClientFactory CreateMockHttpClientFactory(
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responseHandler)
{
var mockHttpClientFactory = Substitute.For<IHttpClientFactory>();
var testHandler = new TestHttpMessageHandler(responseHandler);
var httpClient = new HttpClient(testHandler);
mockHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
return mockHttpClientFactory;
}
}

View file

@ -1,186 +0,0 @@
using Elsa.Common;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Services;
using Elsa.Extensions;
using Elsa.Http.ContentWriters;
using Elsa.Http.Parsers;
using Elsa.Mediator.Contracts;
using Elsa.Resilience;
using Elsa.Workflows;
using Elsa.Workflows.CommitStates;
using Elsa.Workflows.Management.Providers;
using Elsa.Workflows.Management.Services;
using Elsa.Workflows.PortResolvers;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Elsa.Http;
namespace Elsa.Activities.UnitTests.Helpers;
/// <summary>
/// Helper class for unit testing activities in isolation.
/// Provides methods to execute activities with minimal setup, abstracting away the complexity
/// of creating WorkflowExecutionContext and ActivityExecutionContext.
/// </summary>
public static class ActivityTestHelper
{
/// <summary>
/// Executes an activity in isolation for unit testing purposes and returns the execution context.
/// This method handles all the complexity of setting up the execution context,
/// evaluating inputs, and executing the activity.
/// </summary>
/// <param name="activity">The activity to execute</param>
/// <param name="configureServices">An optional action to configure the service collection for dependency injection during activity execution.</param>
/// <returns>The ActivityExecutionContext used for execution</returns>
public static async Task<ActivityExecutionContext> ExecuteActivityAsync(IActivity activity, Action<IServiceCollection>? configureServices = null)
{
var context = await CreateMinimalActivityExecutionContext(activity, configureServices);
// Set up variables and inputs, then execute the activity
await SetupExistingVariablesAsync(activity, context);
await context.EvaluateInputPropertiesAsync();
await activity.ExecuteAsync(context);
return context;
}
/// <summary>
/// Adds all HTTP-related services to the service collection.
/// This includes content factories, parsers, and HTTP client services.
/// Use this method when testing HTTP activities that require full HTTP service support.
/// </summary>
/// <param name="services">The service collection to add HTTP services to</param>
public static void AddHttpServices(IServiceCollection services)
{
// Add all required HTTP services
services.AddSingleton<IHttpContentFactory, JsonContentFactory>();
services.AddSingleton<IHttpContentFactory, TextContentFactory>();
services.AddSingleton<IHttpContentFactory, XmlContentFactory>();
services.AddSingleton<IHttpContentFactory, FormUrlEncodedHttpContentFactory>();
// Add HTTP content parsers
AddHttpContentParsers(services);
// Add other required services
services.AddHttpClient();
}
/// <summary>
/// Creates a mock IResilientActivityInvoker that directly executes the provided action.
/// Useful for testing activities that depend on resilient execution without the complexity of retry policies.
/// </summary>
/// <returns>A mock IResilientActivityInvoker configured to execute actions directly</returns>
public static IResilientActivityInvoker CreateMockResilientActivityInvoker()
{
var mock = Substitute.For<IResilientActivityInvoker>();
// Configure the mock to simply execute the provided action directly
mock.InvokeAsync(
Arg.Any<IResilientActivity>(),
Arg.Any<ActivityExecutionContext>(),
Arg.Any<Func<Task<HttpResponseMessage>>>(),
Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var action = callInfo.ArgAt<Func<Task<HttpResponseMessage>>>(2);
return action.Invoke();
});
return mock;
}
/// <summary>
/// Creates a minimal ActivityExecutionContext suitable for isolated unit testing of activities.
/// This helper method creates a real WorkflowExecutionContext using the minimal workflow pattern
/// to provide proper context for activities.
/// </summary>
private static async Task<ActivityExecutionContext> CreateMinimalActivityExecutionContext(IActivity activity, Action<IServiceCollection>? configureServices)
{
// Create a minimal service provider with the required services for expression evaluation
var services = new ServiceCollection();
// Add core services
services.AddLogging();
services.AddSingleton<ISystemClock>(_ => Substitute.For<ISystemClock>());
services.AddSingleton<INotificationSender>(_ => Substitute.For<INotificationSender>());
services.AddSingleton<IActivityVisitor, ActivityVisitor>();
services.AddScoped<IExpressionEvaluator, ExpressionEvaluator>();
services.AddSingleton<IWellKnownTypeRegistry, WellKnownTypeRegistry>();
services.AddSingleton<IActivityDescriber, ActivityDescriber>();
services.AddSingleton<IPropertyDefaultValueResolver, PropertyDefaultValueResolver>();
services.AddSingleton<IActivityFactory, ActivityFactory>();
services.AddSingleton<IPropertyUIHandlerResolver, PropertyUIHandlerResolver>();
services.AddSingleton<IActivityRegistry, ActivityRegistry>();
services.AddScoped<IActivityRegistryLookupService, ActivityRegistryLookupService>();
services.AddScoped<IIdentityGraphService, IdentityGraphService>();
services.AddScoped<IWorkflowGraphBuilder, WorkflowGraphBuilder>();
services.AddScoped<IActivityResolver, PropertyBasedActivityResolver>();
services.AddScoped<IActivityResolver, SwitchActivityResolver>();
services.AddScoped<DefaultActivityInputEvaluator>();
// Add the default expression descriptor provider which includes Literal expressions
services.AddSingleton<IExpressionDescriptorProvider, DefaultExpressionDescriptorProvider>();
services.AddSingleton<IExpressionDescriptorRegistry, ExpressionDescriptorRegistry>();
services.AddSingleton<IIdentityGenerator>(_ => Substitute.For<IIdentityGenerator>());
services.AddSingleton<IHasher>(_ => Substitute.For<IHasher>());
services.AddSingleton<ICommitStateHandler>(_ => Substitute.For<ICommitStateHandler>());
services.AddSingleton<IActivitySchedulerFactory>(_ => Substitute.For<IActivitySchedulerFactory>());
// Call the configure services action if provided.
configureServices?.Invoke(services);
var serviceProvider = services.BuildServiceProvider();
var activityRegistry = serviceProvider.GetRequiredService<IActivityRegistry>();
var workflowGraphBuilder = serviceProvider.GetRequiredService<IWorkflowGraphBuilder>();
await activityRegistry.RegisterAsync(activity.GetType());
var workflow = Workflow.FromActivity(activity);
var workflowGraph = await workflowGraphBuilder.BuildAsync(workflow);
// Create workflow execution context using the static factory method
var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync(
serviceProvider,
workflowGraph,
$"test-instance-{Guid.NewGuid()}",
CancellationToken.None
);
// Create ActivityExecutionContext for the actual activity we want to test
return await workflowExecutionContext.CreateActivityExecutionContextAsync(activity);
}
/// <summary>
/// Sets up existing variables found on the activity in the execution context.
/// This is necessary because in unit tests, variables need to be initialized.
/// </summary>
private static Task SetupExistingVariablesAsync(IActivity activity, ActivityExecutionContext context)
{
var activityType = activity.GetType();
var variableProperties = activityType.GetProperties()
.Where(p => p.PropertyType.IsGenericType &&
p.PropertyType.GetGenericTypeDefinition() == typeof(Variable<>))
.ToList();
foreach (var variable in variableProperties.Select(property => (Variable)property.GetValue(activity)!))
{
variable.Set(context.ExpressionExecutionContext, variable.Value);
}
return Task.CompletedTask;
}
/// <summary>
/// Adds HTTP content parsers to the service collection.
/// These parsers are responsible for parsing different content types in HTTP responses.
/// </summary>
/// <param name="services">The service collection to add parsers to</param>
private static void AddHttpContentParsers(IServiceCollection services)
{
services.AddSingleton<IHttpContentParser, JsonHttpContentParser>();
services.AddSingleton<IHttpContentParser, PlainTextHttpContentParser>();
services.AddSingleton<IHttpContentParser, XmlHttpContentParser>();
services.AddSingleton<IHttpContentParser, TextHtmlHttpContentParser>();
services.AddSingleton<IHttpContentParser, FileHttpContentParser>();
}
}

View file

@ -4,24 +4,14 @@ namespace Elsa.Activities.UnitTests.Helpers;
/// Custom test HTTP message handler that allows full control over HTTP responses for testing.
/// Use this class to simulate different HTTP scenarios without making actual network calls.
/// </summary>
public class TestHttpMessageHandler : HttpMessageHandler
/// <param name="sendAsyncFunc">Function that defines how to handle HTTP requests and generate responses</param>
public class TestHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> sendAsyncFunc) : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _sendAsyncFunc;
/// <summary>
/// Initializes a new instance of TestHttpMessageHandler.
/// </summary>
/// <param name="sendAsyncFunc">Function that defines how to handle HTTP requests and generate responses</param>
public TestHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> sendAsyncFunc)
{
_sendAsyncFunc = sendAsyncFunc;
}
/// <summary>
/// Handles HTTP requests using the provided function.
/// </summary>
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return _sendAsyncFunc(request, cancellationToken);
return sendAsyncFunc(request, cancellationToken);
}
}

View file

@ -0,0 +1,288 @@
using System.Net;
using Elsa.Activities.UnitTests.Helpers;
using Elsa.Extensions;
using Elsa.Http;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using NSubstitute;
namespace Elsa.Activities.UnitTests.Http;
public class SendHttpRequestTests
{
[Theory]
[InlineData("GET", "https://api.example.com/data", "{\"result\": \"success\"}", 200)]
[InlineData("POST", "https://api.example.com/create", "{\"id\": 123}", 201)]
[InlineData("PUT", "https://api.example.com/update", "{\"updated\": true}", 200)]
public async Task Should_Send_Request_And_Handle_Success_Response(string method, string url, string jsonResponse, int expectedStatusCode)
{
// Arrange
var expectedUrl = new Uri(url);
var expectedMethod = new HttpMethod(method);
var expectedHttpStatusCode = (HttpStatusCode)expectedStatusCode;
var requestCapture = new RequestCapture();
var responseHandler = CreateResponseHandler(expectedHttpStatusCode, jsonResponse, requestCapture);
var sendHttpRequest = CreateSendHttpRequest(expectedUrl, method);
// Act
var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler);
// Assert
Assert.NotNull(requestCapture.CapturedRequest);
Assert.Equal(expectedMethod, requestCapture.CapturedRequest.Method);
Assert.Equal(expectedUrl, requestCapture.CapturedRequest.RequestUri);
var statusCodeOutput = context.GetActivityOutput(() => sendHttpRequest.StatusCode);
Assert.Equal(expectedStatusCode, statusCodeOutput);
}
[Theory]
[InlineData("Bearer token123")]
[InlineData("Basic YWRtaW46cGFzcw==")]
[InlineData("ApiKey abc123")]
public async Task Should_Add_Authorization_Header(string authorizationHeader)
{
// Arrange
var expectedUrl = new Uri("https://api.example.com/secure");
var requestCapture = new RequestCapture();
var responseHandler = CreateResponseHandler(HttpStatusCode.OK, null, requestCapture);
var sendHttpRequest = CreateSendHttpRequest(expectedUrl, authorization: authorizationHeader);
// Act
await ExecuteActivityAsync(sendHttpRequest, responseHandler);
// Assert
Assert.NotNull(requestCapture.CapturedRequest);
Assert.NotNull(requestCapture.CapturedRequest.Headers.Authorization);
Assert.Equal(authorizationHeader, requestCapture.CapturedRequest.Headers.Authorization.ToString());
}
[Theory]
[InlineData(new[]{200, 404}, new[]{"mockActivity200", "mockActivity404"}, "mockUnmatchedActivity", HttpStatusCode.NotFound, "mockActivity404")]
[InlineData(new[]{200, 404}, new[]{"mockActivity200", "mockActivity404"}, "mockUnmatchedActivity", HttpStatusCode.InternalServerError, "mockUnmatchedActivity")]
public async Task Should_Schedule_Activity_According_To_Handlers(int[] statusCodes, string[] activityNames, string handler, HttpStatusCode expectedStatusCode, string expectedScheduledActivityName)
{
// Arrange
var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithStatusHandlers(
expectedStatusCodes: [(statusCodes[0], activityNames[0]), (statusCodes[1], activityNames[1])],
unmatchedHandler: handler
);
var responseHandler = CreateResponseHandler(expectedStatusCode);
// Act
var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler);
// Assert that the correct activity was scheduled.
var expectedActivity = childActivities[expectedScheduledActivityName];
var hasScheduledActivity = context.HasScheduledActivity(expectedActivity);
Assert.True(hasScheduledActivity);
}
[Fact]
public async Task Should_Schedule_FailedToConnect_Activity_On_HttpRequestException()
{
// Arrange
var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithErrorHandlers(
failedToConnect: "mockFailedToConnect"
);
var responseHandler = CreateExceptionHandler<HttpRequestException>("Connection failed");
// Act
var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler);
// Assert
var expectedScheduledActivity = childActivities["mockFailedToConnect"];
var hasScheduledExpectedActivity = context.HasScheduledActivity(expectedScheduledActivity);
Assert.True(hasScheduledExpectedActivity);
}
[Fact]
public async Task Should_Schedule_Timeout_Activity_On_TaskCanceledException()
{
// Arrange
var (sendHttpRequest, childActivities) = CreateSendHttpRequestWithErrorHandlers(
timeout: "mockTimeout"
);
var responseHandler = CreateExceptionHandler<TaskCanceledException>("Request timed out");
// Act
var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler);
// Assert
var expectedScheduledActivity = childActivities["mockTimeout"];
var hasScheduledExpectedActivity = context.HasScheduledActivity(expectedScheduledActivity);
Assert.True(hasScheduledExpectedActivity);
}
[Fact]
public async Task Should_Schedule_No_Activity_When_No_Status_Code_Cases_Match_And_No_Unmatched_Handler()
{
// Arrange
var (configured, _) = CreateSendHttpRequestWithStatusHandlers([(200, "handler200")], unmatchedHandler: null);
var responseHandler = CreateResponseHandler(HttpStatusCode.InternalServerError); // 500 - no match
// Act
var context = await ExecuteActivityAsync(configured, responseHandler);
// Assert
var allScheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
Assert.Empty(allScheduledActivities);
}
[Fact]
public async Task Should_Set_Response_Headers_Output()
{
// Arrange
var expectedHeaders = new Dictionary<string, string>
{
{ "Custom-Header", "CustomValue" },
{ "X-Rate-Limit", "100" }
};
var responseHandler = CreateResponseHandler(HttpStatusCode.OK, additionalHeaders: expectedHeaders);
var sendHttpRequest = CreateSendHttpRequest(new("https://api.example.com/headers"));
// Act
var context = await ExecuteActivityAsync(sendHttpRequest, responseHandler);
// Assert
var responseHeadersObj = context.GetActivityOutput(() => sendHttpRequest.ResponseHeaders);
var responseHeaders = responseHeadersObj as HttpHeaders;
Assert.NotNull(responseHeaders);
Assert.True(responseHeaders.ContainsKey("Custom-Header"));
Assert.True(responseHeaders.ContainsKey("X-Rate-Limit"));
}
[Fact]
public void Should_Have_Correct_Activity_Attributes()
{
var fixture = new ActivityTestFixture(new SendHttpRequest());
fixture.AssertActivityAttributes(
expectedNamespace: "Elsa",
expectedCategory: "HTTP",
expectedDisplayName: "HTTP Request",
expectedDescription: "Send an HTTP request.",
expectedKind: ActivityKind.Task
);
}
// Private helper methods placed after all public members
private static Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> CreateResponseHandler(
HttpStatusCode statusCode,
string? content = null,
RequestCapture? requestCapture = null,
Dictionary<string, string>? additionalHeaders = null)
{
return (request, _) =>
{
if (requestCapture != null)
requestCapture.CapturedRequest = request;
return Task.FromResult(ActivityTestFixtureHttpExtensions.CreateHttpResponse(statusCode, content, additionalHeaders));
};
}
private sealed class RequestCapture
{
public HttpRequestMessage? CapturedRequest { get; set; }
}
private static SendHttpRequest CreateSendHttpRequest(
Uri url,
string method = "GET",
object? content = null,
string? contentType = null,
string? authorization = null)
{
return new()
{
Url = new(url),
Method = new(method),
Content = content != null ? new Input<object?>(content) : null!,
ContentType = contentType != null ? new Input<string?>(contentType) : null!,
Authorization = authorization != null ? new Input<string?>(authorization) : null!,
ExpectedStatusCodes = new List<HttpStatusCodeCase>()
};
}
private static Task<ActivityExecutionContext> ExecuteActivityAsync(
SendHttpRequest sendHttpRequest,
Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responseHandler)
{
return new ActivityTestFixture(sendHttpRequest).WithHttpServices(responseHandler).ExecuteAsync();
}
private static (SendHttpRequest sendHttpRequest, Dictionary<string, IActivity> childActivities) CreateSendHttpRequestWithStatusHandlers(
(int statusCode, string activityName)[] expectedStatusCodes,
string? unmatchedHandler)
{
var childActivities = new Dictionary<string, IActivity>();
// Create mock activities for expected status codes
var expectedStatusCodeCases = expectedStatusCodes.Select(x =>
{
var mockActivity = Substitute.For<IActivity>();
childActivities[x.activityName] = mockActivity;
return new HttpStatusCodeCase(x.statusCode, mockActivity);
}).ToList();
// Create mock activity for unmatched handler
var unmatchedActivity = Substitute.For<IActivity>();
if (unmatchedHandler is not null)
{
childActivities[unmatchedHandler] = unmatchedActivity;
}
var sendHttpRequest = new SendHttpRequest
{
Url = new(new Uri("https://api.example.com/test")),
Method = new("GET"),
ExpectedStatusCodes = expectedStatusCodeCases,
UnmatchedStatusCode = unmatchedHandler is not null ? unmatchedActivity : null
};
return (sendHttpRequest, childActivities);
}
private static (SendHttpRequest sendHttpRequest, Dictionary<string, IActivity> childActivities) CreateSendHttpRequestWithErrorHandlers(
string? failedToConnect = null,
string? timeout = null)
{
var childActivities = new Dictionary<string, IActivity>();
IActivity? failedToConnectActivity = null;
IActivity? timeoutActivity = null;
if (failedToConnect != null)
{
failedToConnectActivity = Substitute.For<IActivity>();
childActivities[failedToConnect] = failedToConnectActivity;
}
if (timeout != null)
{
timeoutActivity = Substitute.For<IActivity>();
childActivities[timeout] = timeoutActivity;
}
var sendHttpRequest = new SendHttpRequest
{
Url = new(new Uri("https://api.example.com/error")),
Method = new("GET"),
ExpectedStatusCodes = new List<HttpStatusCodeCase>(),
FailedToConnect = failedToConnectActivity,
Timeout = timeoutActivity
};
return (sendHttpRequest, childActivities);
}
private static Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> CreateExceptionHandler<TException>(string message)
where TException : Exception
{
return (_, _) => throw ((TException)Activator.CreateInstance(typeof(TException), message)!);
}
}

View file

@ -0,0 +1,97 @@
using Elsa.Testing.Shared;
using Elsa.Workflows;
using NSubstitute;
namespace Elsa.Activities.UnitTests.Parallel;
public class ParallelTests
{
[Theory(DisplayName = "Parallel should schedule multiple activities")]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public async Task Should_Schedule_Multiple_Activities(int activityCount)
{
// Arrange
var activities = Enumerable.Range(1, activityCount)
.Select(i => new WriteLine($"Activity {i}"))
.Cast<IActivity>()
.ToArray();
var parallel = new Workflows.Activities.Parallel(activities);
// Act
var context = await new ActivityTestFixture(parallel).ExecuteAsync();
// Assert - All activities should be scheduled
foreach (var activity in activities)
{
Assert.True(context.HasScheduledActivity(activity));
}
}
[Fact(DisplayName = "Parallel should not schedule activities when empty")]
public async Task Should_Not_Schedule_Activities_When_Empty()
{
// Arrange
var parallel = new Workflows.Activities.Parallel();
// Act
var context = await new ActivityTestFixture(parallel).ExecuteAsync();
// Assert - No activities should be scheduled (completion is tested in integration tests)
var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
Assert.Empty(scheduledActivities);
}
[Fact(DisplayName = "Parallel should schedule a single activity")]
public async Task Should_Execute_Single_Activity()
{
// Arrange
var activity = Substitute.For<IActivity>();
var parallel = new Workflows.Activities.Parallel(activity);
// Act
var context = await new ActivityTestFixture(parallel).ExecuteAsync();
// Assert
Assert.True(context.HasScheduledActivity(activity));
}
[Fact(DisplayName = "Parallel should schedule mixed activity types")]
public async Task Should_Schedule_Mixed_Activity_Types()
{
// Arrange
var writeLine = new WriteLine("Test output");
var testVar = new Variable<int>("testVar", 0, "testVar");
var setVariable = new SetVariable<int>(testVar, new Input<int>(42));
var mockActivity = Substitute.For<IActivity>();
var parallel = new Workflows.Activities.Parallel(writeLine, setVariable, mockActivity);
// Act
var context = await new ActivityTestFixture(parallel).ExecuteAsync();
// Assert - All activities should be scheduled
Assert.True(context.HasScheduledActivity(writeLine));
Assert.True(context.HasScheduledActivity(setVariable));
Assert.True(context.HasScheduledActivity(mockActivity));
}
[Fact(DisplayName = "Parallel should track scheduled children count internally")]
public async Task Should_Set_Scheduled_Children_Property()
{
// Arrange
var activity1 = new WriteLine("Activity 1");
var activity2 = new WriteLine("Activity 2");
var activity3 = new WriteLine("Activity 3");
var parallel = new Workflows.Activities.Parallel(activity1, activity2, activity3);
// Act
var context = await new ActivityTestFixture(parallel).ExecuteAsync();
// Assert - The parallel activity should track that 3 children were scheduled
var scheduledChildrenCount = context.GetProperty<int>("ScheduledChildren");
Assert.Equal(3, scheduledChildrenCount);
}
}

View file

@ -1,4 +1,5 @@
using Elsa.Activities.UnitTests.Helpers;
using Elsa.Testing.Shared;
using Elsa.Workflows;
namespace Elsa.Activities.UnitTests.Primitives;
@ -13,7 +14,7 @@ public class SetVariableTests
var setVariable = new SetVariable<int>(variable, new Input<int>(expected));
// Act
var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable);
var context = await ExecuteAsync(setVariable);
// Assert
var result = variable.Get(context);
@ -27,7 +28,7 @@ public class SetVariableTests
var setVariable = new SetVariable<string>(null!, new Input<string>("test value"));
// Act & Assert
var exception = await Record.ExceptionAsync(async () => await ActivityTestHelper.ExecuteActivityAsync(setVariable));
var exception = await Record.ExceptionAsync(() => ExecuteAsync(setVariable));
Assert.NotNull(exception);
}
@ -40,10 +41,15 @@ public class SetVariableTests
var setVariable = new SetVariable<string?>(variable, new Input<string?>((string?)null));
// Act
var context = await ActivityTestHelper.ExecuteActivityAsync(setVariable);
var context = await ExecuteAsync(setVariable);
// Assert
var result = variable.Get(context);
Assert.Null(result);
}
private static Task<ActivityExecutionContext> ExecuteAsync(IActivity activity)
{
return new ActivityTestFixture(activity).ExecuteAsync();
}
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Expressions\Elsa.Expressions.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,169 @@
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
namespace Elsa.Expressions.UnitTests.Services;
public class ExpressionEvaluatorTests
{
[Fact(DisplayName = "Evaluates expression with generic type parameter")]
public async Task EvaluatesExpressionWithGenericTypeParameter()
{
// Arrange
var context = await CreateContextAsync();
var evaluator = context.GetRequiredService<IExpressionEvaluator>();
var expression = new Expression("Literal", "Test Value");
// Act
var result = await evaluator.EvaluateAsync<string>(expression, context.ExpressionExecutionContext);
// Assert
Assert.Equal("Test Value", result);
}
[Fact(DisplayName = "Evaluates expression with type parameter")]
public async Task EvaluatesExpressionWithTypeParameter()
{
// Arrange
var context = await CreateContextAsync();
var evaluator = context.GetRequiredService<IExpressionEvaluator>();
var expression = new Expression("Literal", 42);
// Act
var result = await evaluator.EvaluateAsync(expression, typeof(int), context.ExpressionExecutionContext);
// Assert
Assert.Equal(42, result);
}
[Fact(DisplayName = "Throws when expression type not found in registry")]
public async Task ThrowsWhenExpressionTypeNotFound()
{
// Arrange
var context = await CreateContextAsync();
var evaluator = context.GetRequiredService<IExpressionEvaluator>();
var expression = new Expression("NonExistentType", "value");
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(async () =>
await evaluator.EvaluateAsync<string>(expression, context.ExpressionExecutionContext));
Assert.Contains("Could not find a descriptor for expression type", exception.Message);
Assert.Contains("NonExistentType", exception.Message);
}
[Fact(DisplayName = "Resolves expression handler via descriptor factory")]
public async Task ResolvesExpressionHandlerViaDescriptorFactory()
{
// Arrange
var mockHandler = CreateMockHandler("Mocked Result");
var context = await CreateContextWithMockHandlerAsync("CustomType", mockHandler);
var evaluator = context.GetRequiredService<IExpressionEvaluator>();
var expression = new Expression("CustomType", "test");
// Act
var result = await evaluator.EvaluateAsync<string>(expression, context.ExpressionExecutionContext);
// Assert
Assert.Equal("Mocked Result", result);
await mockHandler.Received(1).EvaluateAsync(
Arg.Is<Expression>(e => e.Type == "CustomType" && e.Value as string == "test"),
Arg.Any<Type>(),
Arg.Any<ExpressionExecutionContext>(),
Arg.Any<ExpressionEvaluatorOptions>());
}
[Fact(DisplayName = "Passes non-null options as default when not provided")]
public async Task PassesNonNullOptionsAsDefault()
{
// Arrange
var mockHandler = CreateMockHandler("Result");
var context = await CreateContextWithMockHandlerAsync("TestType", mockHandler);
var evaluator = context.GetRequiredService<IExpressionEvaluator>();
var expression = new Expression("TestType", "value");
// Act
await evaluator.EvaluateAsync<string>(expression, context.ExpressionExecutionContext);
// Assert
await mockHandler.Received(1).EvaluateAsync(
Arg.Any<Expression>(),
Arg.Any<Type>(),
Arg.Any<ExpressionExecutionContext>(),
Arg.Is<ExpressionEvaluatorOptions>(o => o != null));
}
[Fact(DisplayName = "Wraps handler exceptions in evaluation context")]
public async Task WrapsHandlerExceptions()
{
// Arrange
var mockHandler = CreateMockHandlerThatThrows(new InvalidOperationException("Handler failed"));
var context = await CreateContextWithMockHandlerAsync("FailingType", mockHandler);
var evaluator = context.GetRequiredService<IExpressionEvaluator>();
var expression = new Expression("FailingType", "bad value");
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await evaluator.EvaluateAsync<string>(expression, context.ExpressionExecutionContext));
Assert.Equal("Handler failed", exception.Message);
}
private static Task<ActivityExecutionContext> CreateContextAsync()
{
var activity = new WriteLine("Hello, World!");
return new ActivityTestFixture(activity).BuildAsync();
}
private static async Task<ActivityExecutionContext> CreateContextWithMockHandlerAsync(string expressionType, IExpressionHandler mockHandler)
{
var mockDescriptor = new ExpressionDescriptor
{
Type = expressionType,
HandlerFactory = _ => mockHandler
};
var mockProvider = Substitute.For<IExpressionDescriptorProvider>();
mockProvider.GetDescriptors().Returns([mockDescriptor]);
var activity = new WriteLine("Hello, World!");
var fixture = new ActivityTestFixture(activity)
.ConfigureServices(services =>
{
services.RemoveWhere(d => d.ServiceType == typeof(IExpressionDescriptorProvider));
services.AddSingleton(mockProvider);
});
return await fixture.BuildAsync();
}
private static IExpressionHandler CreateMockHandler(object returnValue)
{
var mockHandler = Substitute.For<IExpressionHandler>();
mockHandler.EvaluateAsync(
Arg.Any<Expression>(),
Arg.Any<Type>(),
Arg.Any<ExpressionExecutionContext>(),
Arg.Any<ExpressionEvaluatorOptions>())
.Returns(returnValue);
return mockHandler;
}
private static IExpressionHandler CreateMockHandlerThatThrows(Exception exception)
{
var mockHandler = Substitute.For<IExpressionHandler>();
mockHandler.EvaluateAsync(
Arg.Any<Expression>(),
Arg.Any<Type>(),
Arg.Any<ExpressionExecutionContext>(),
Arg.Any<ExpressionEvaluatorOptions>())
.Throws(exception);
return mockHandler;
}
}

View file

@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
</ItemGroup>

View file

@ -0,0 +1,39 @@
using Elsa.Extensions;
using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
public class FaultHandlingTests
{
[Fact]
public async Task Fault_SetsExceptionAndStatus()
{
// Arrange
var context = await CreateContextAsync();
var exception = new InvalidOperationException("Test error");
// Act
context.Fault(exception);
// Assert
Assert.Equal(ActivityStatus.Faulted, context.Status);
Assert.Equal(exception, context.Exception);
Assert.Equal(1, context.AggregateFaultCount);
}
[Fact]
public async Task RecoverFromFault_ResetsFaultCount()
{
// Arrange
var context = await CreateContextAsync();
var exception = new InvalidOperationException("Test error");
context.Fault(exception);
// Act
context.RecoverFromFault();
// Assert
Assert.Equal(0, context.AggregateFaultCount);
Assert.Equal(ActivityStatus.Running, context.Status);
}
}

View file

@ -0,0 +1,41 @@
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
public class HierarchyNavigationTests
{
[Theory]
[InlineData("GetAncestors")]
[InlineData("GetDescendants")]
public async Task HierarchyNavigation_ReturnsEmpty_WhenNoRelatives(string methodName)
{
// Arrange
var context = await CreateContextAsync();
// Act
var result = methodName switch
{
"GetAncestors" => context.GetAncestors().ToList(),
"GetDescendants" => context.GetDescendants().ToList(),
_ => throw new ArgumentException($"Unknown method: {methodName}")
};
// Assert
Assert.Empty(result);
}
[Fact]
public async Task FindParent_ReturnsNull_WhenNoMatch()
{
// Arrange
var context = await CreateContextAsync();
// Act
var parent = context.FindParent(x => x.Activity is Sequence);
// Assert
Assert.Null(parent);
}
}

View file

@ -0,0 +1,50 @@
using Elsa.Extensions;
using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
public class MetadataTests
{
[Fact]
public async Task SetExtensionsMetadata_StoresValue()
{
// Arrange
var context = await CreateContextAsync();
// Act
context.SetExtensionsMetadata("testKey", "testValue");
// Assert
var metadata = context.GetExtensionsMetadata();
Assert.NotNull(metadata);
Assert.Equal("testValue", metadata["testKey"]);
}
[Fact]
public async Task GetExtensionsMetadata_ReturnsNull_WhenNotSet()
{
// Arrange
var context = await CreateContextAsync();
// Act
var metadata = context.GetExtensionsMetadata();
// Assert
Assert.Null(metadata);
}
[Fact]
public async Task SetExtensionsMetadata_UpdatesExistingValue()
{
// Arrange
var context = await CreateContextAsync();
context.SetExtensionsMetadata("testKey", "value1");
// Act
context.SetExtensionsMetadata("testKey", "value2");
// Assert
var metadata = context.GetExtensionsMetadata();
Assert.Equal("value2", metadata!["testKey"]);
}
}

View file

@ -0,0 +1,35 @@
using Elsa.Extensions;
using Elsa.Workflows.Attributes;
using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
public class OutcomeTests
{
[Theory]
[InlineData(typeof(TestActivityWithPort), "Done")]
[InlineData(typeof(TestActivityWithNamedPort), "CustomOutcome")]
public async Task GetOutcomeName_ReturnsExpectedName(Type activityType, string expectedOutcomeName)
{
// Arrange
var activity = (Activity)Activator.CreateInstance(activityType)!;
var context = await CreateContextAsync(activity);
// Act
var outcomeName = context.GetOutcomeName("Done");
// Assert
Assert.Equal(expectedOutcomeName, outcomeName);
}
private class TestActivityWithPort : Activity
{
public IActivity? Done { get; set; }
}
private class TestActivityWithNamedPort : Activity
{
[Port("CustomOutcome")]
public IActivity? Done { get; set; }
}
}

View file

@ -0,0 +1,24 @@
using Elsa.Extensions;
using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
public class PropertyEvaluationTests
{
[Theory]
[InlineData(false)] // Initial state
[InlineData(true)] // After setting flag
public async Task HasEvaluatedProperties_TracksState(bool shouldSetFlag)
{
// Arrange
var context = await CreateContextAsync();
if (shouldSetFlag)
context.SetHasEvaluatedProperties();
// Act
var result = context.GetHasEvaluatedProperties();
// Assert
Assert.Equal(shouldSetFlag, result);
}
}

View file

@ -0,0 +1,39 @@
using Elsa.Extensions;
using Elsa.Workflows.Models;
using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
public class ResultTests
{
[Fact]
public async Task SetResult_SetsResultProperty_WhenActivityImplementsIActivityWithResult()
{
// Arrange
var activity = new ActivityWithResult();
var context = await CreateContextAsync(activity);
var testValue = "ResultValue";
// Act
context.SetResult(testValue);
// Assert
var result = context.Get(activity.Result);
Assert.Equal(testValue, result);
}
[Fact]
public async Task SetResult_ThrowsException_WhenActivityDoesNotImplementIActivityWithResult()
{
// Arrange
var context = await CreateContextAsync();
// Act & Assert
Assert.Throws<Exception>(() => context.SetResult("value"));
}
private class ActivityWithResult : Activity, IActivityWithResult
{
public Output? Result { get; set; } = new Output<object?>();
}
}

View file

@ -0,0 +1,22 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.Activities;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
/// <summary>
/// Provides helper methods for ActivityExecutionContextExtensions unit tests.
/// </summary>
public static class TestHelpers
{
/// <summary>
/// Creates an ActivityExecutionContext for testing purposes.
/// </summary>
/// <param name="activity">Optional activity to use. If null, a default WriteLine activity is created.</param>
/// <returns>A configured ActivityExecutionContext ready for testing.</returns>
public static Task<ActivityExecutionContext> CreateContextAsync(IActivity? activity = null)
{
activity ??= new WriteLine("test");
var fixture = new ActivityTestFixture(activity);
return fixture.BuildAsync();
}
}

View file

@ -0,0 +1,23 @@
using Elsa.Extensions;
using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
public class TriggerTests
{
[Theory]
[InlineData(true, null)] // null means use context.Activity.Id
[InlineData(false, "different-id")]
public async Task IsTriggerOfWorkflow_ReturnsExpectedResult(bool expectedResult, string? triggerActivityId)
{
// Arrange
var context = await CreateContextAsync();
context.WorkflowExecutionContext.TriggerActivityId = triggerActivityId ?? context.Activity.Id;
// Act
var result = context.IsTriggerOfWorkflow();
// Assert
Assert.Equal(expectedResult, result);
}
}

View file

@ -0,0 +1,68 @@
using Elsa.Extensions;
using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
public class VariableTests
{
[Fact]
public async Task CreateVariable_CreatesNewVariable_WithSpecifiedValue()
{
// Arrange
var context = await CreateContextAsync();
// Act
var variable = context.CreateVariable("testVar", 42);
// Assert
Assert.NotNull(variable);
Assert.Equal("testVar", variable.Name);
var value = context.GetVariable<int>("testVar");
Assert.Equal(42, value);
}
[Fact]
public async Task SetVariable_CreatesOrUpdatesVariable()
{
// Arrange
var context = await CreateContextAsync();
// Act
context.SetVariable("testVar", 10);
context.SetVariable("testVar", 20);
// Assert
var value = context.GetVariable<int>("testVar");
Assert.Equal(20, value);
}
[Fact]
public async Task GetVariable_ReturnsNull_WhenVariableDoesNotExist()
{
// Arrange
var context = await CreateContextAsync();
// Act
var value = context.GetVariable<string>("nonExistent");
// Assert
Assert.Null(value);
}
[Fact]
public async Task GetVariableValues_ReturnsAllVariables_AcrossScopes()
{
// Arrange
var context = await CreateContextAsync();
context.SetVariable("var1", "value1");
context.SetVariable("var2", 42);
// Act
var values = context.GetVariableValues();
// Assert
Assert.NotEmpty(values);
Assert.True(values.ContainsKey("var1Variable") || values.ContainsKey("var1"));
Assert.True(values.ContainsKey("var2Variable") || values.ContainsKey("var2"));
}
}

View file

@ -0,0 +1,72 @@
using Elsa.Extensions;
using static Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions.TestHelpers;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
public class WorkflowInputTests
{
[Theory]
[InlineData("TestKey", "TestValue", true, "TestValue", null)] // Key exists, string value
[InlineData("NonExistentKey", null, false, null, null)] // Key doesn't exist
[InlineData("NumberKey", null, true, "42", 42)] // Type conversion from int to string
public async Task TryGetWorkflowInput_HandlesVariousScenarios(
string key,
string? stringValue,
bool expectedResult,
string? expectedValue,
object? inputValue)
{
// Arrange
var context = await CreateContextAsync();
if (stringValue != null)
context.WorkflowExecutionContext.Input[key] = stringValue;
else if (inputValue != null)
context.WorkflowExecutionContext.Input[key] = inputValue;
// Act
var result = context.TryGetWorkflowInput<string>(key, out var value);
// Assert
Assert.Equal(expectedResult, result);
Assert.Equal(expectedValue, value);
}
[Fact]
public async Task GetWorkflowInput_ReturnsValue_WhenKeyExists()
{
// Arrange
var context = await CreateContextAsync();
context.WorkflowExecutionContext.Input["TestKey"] = "TestValue";
// Act
var value = context.GetWorkflowInput<string>("TestKey");
// Assert
Assert.Equal("TestValue", value);
}
[Fact]
public async Task GetWorkflowInput_UsesTypeName_WhenKeyNotProvided()
{
// Arrange
var context = await CreateContextAsync();
context.WorkflowExecutionContext.Input["String"] = "TestValue";
// Act
var value = context.GetWorkflowInput<string>();
// Assert
Assert.Equal("TestValue", value);
}
[Fact]
public async Task GetWorkflowInput_ThrowsException_WhenKeyDoesNotExist()
{
// Arrange
var context = await CreateContextAsync();
// Act & Assert
Assert.Throws<KeyNotFoundException>(() => context.GetWorkflowInput<string>("NonExistentKey"));
}
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,225 @@
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
using Elsa.Workflows.Management.Services;
using NSubstitute;
namespace Elsa.Workflows.Management.UnitTests.Services;
public class ExpressionDescriptorRegistryTests
{
[Fact]
public void Should_Add_Descriptors_Via_Providers()
{
// Arrange
var descriptor1 = CreateDescriptor("Type1");
var descriptor2 = CreateDescriptor("Type2");
var provider1 = CreateProvider(descriptor1);
var provider2 = CreateProvider(descriptor2);
// Act
var registry = CreateRegistry(provider1, provider2);
// Assert
var allDescriptors = registry.ListAll().ToList();
Assert.Contains(allDescriptors, d => d.Type == "Type1");
Assert.Contains(allDescriptors, d => d.Type == "Type2");
Assert.Equal(2, allDescriptors.Count);
}
[Fact]
public void Should_Find_Descriptor_By_Type()
{
// Arrange
var descriptor = CreateDescriptor("TestType");
var registry = CreateRegistry(CreateProvider(descriptor));
// Act
var found = registry.Find("TestType");
// Assert
Assert.NotNull(found);
Assert.Equal("TestType", found.Type);
}
[Fact]
public void Should_Find_Descriptor_By_Predicate()
{
// Arrange
var descriptor1 = CreateDescriptor("Type1");
var descriptor2 = CreateDescriptor("Type2");
var registry = CreateRegistry(CreateProvider(descriptor1, descriptor2));
// Act
var found = registry.Find(d => d.Type == "Type2");
// Assert
Assert.NotNull(found);
Assert.Equal("Type2", found.Type);
}
[Theory]
[InlineData("NonExistentType")]
[InlineData("")]
[InlineData("WrongType")]
public void Should_Return_Null_When_Descriptor_Not_Found_By_Type(string searchType)
{
// Arrange
var descriptor = CreateDescriptor("ExistingType");
var registry = CreateRegistry(CreateProvider(descriptor));
// Act
var found = registry.Find(searchType);
// Assert
Assert.Null(found);
}
[Fact]
public void Should_Return_Null_When_Descriptor_Not_Found_By_Predicate()
{
// Arrange
var descriptor = CreateDescriptor("Type1");
var registry = CreateRegistry(CreateProvider(descriptor));
// Act
var found = registry.Find(d => d.Type == "NonExistent");
// Assert
Assert.Null(found);
}
[Fact]
public void Should_List_All_Registered_Descriptors()
{
// Arrange
var descriptor1 = CreateDescriptor("Type1");
var descriptor2 = CreateDescriptor("Type2");
var descriptor3 = CreateDescriptor("Type3");
var registry = CreateRegistry(CreateProvider(descriptor1, descriptor2, descriptor3));
// Act
var all = registry.ListAll().ToList();
// Assert
Assert.Equal(3, all.Count);
Assert.Contains(all, d => d.Type == "Type1");
Assert.Contains(all, d => d.Type == "Type2");
Assert.Contains(all, d => d.Type == "Type3");
}
[Fact]
public void Should_Handle_Duplicate_Registrations_Last_Wins()
{
// Arrange - Two providers register the same type
var handler1 = Substitute.For<IExpressionHandler>();
var handler2 = Substitute.For<IExpressionHandler>();
var descriptor1 = CreateDescriptor("DuplicateType", handler1);
var descriptor2 = CreateDescriptor("DuplicateType", handler2);
var provider1 = CreateProvider(descriptor1);
var provider2 = CreateProvider(descriptor2);
// Act - provider2 is registered after provider1
var registry = CreateRegistry(provider1, provider2);
// Assert - Last registration should win
var found = registry.Find("DuplicateType");
Assert.NotNull(found);
Assert.Same(handler2, found.HandlerFactory(null!));
}
[Fact]
public void Should_Add_Single_Descriptor_Via_Add_Method()
{
// Arrange
var registry = CreateRegistry(CreateProvider());
var newDescriptor = CreateDescriptor("NewType");
// Act
registry.Add(newDescriptor);
// Assert
var found = registry.Find("NewType");
Assert.NotNull(found);
Assert.Equal("NewType", found.Type);
}
[Fact]
public void Should_Add_Multiple_Descriptors_Via_AddRange_Method()
{
// Arrange
var registry = CreateRegistry(CreateProvider());
var descriptors = new[]
{
CreateDescriptor("Range1"),
CreateDescriptor("Range2"),
CreateDescriptor("Range3")
};
// Act
registry.AddRange(descriptors);
// Assert
Assert.NotNull(registry.Find("Range1"));
Assert.NotNull(registry.Find("Range2"));
Assert.NotNull(registry.Find("Range3"));
}
[Theory]
[InlineData(true)] // Empty provider list
[InlineData(false)] // Provider with empty descriptors
public void Should_Handle_Empty_Scenarios(bool emptyProviderList)
{
// Arrange & Act
var registry = emptyProviderList
? CreateRegistry()
: CreateRegistry(CreateProvider());
// Assert
var all = registry.ListAll().ToList();
Assert.Empty(all);
}
[Fact]
public void Should_Enumerate_Descriptors_Multiple_Times()
{
// Arrange
var descriptor = CreateDescriptor("TestType");
var registry = CreateRegistry(CreateProvider(descriptor));
// Act
var first = registry.ListAll().ToList();
var second = registry.ListAll().ToList();
// Assert
Assert.Equal(first.Count, second.Count);
Assert.Single(first);
Assert.Single(second);
}
private static ExpressionDescriptor CreateDescriptor(string type)
{
var handler = Substitute.For<IExpressionHandler>();
return CreateDescriptor(type, handler);
}
private static ExpressionDescriptor CreateDescriptor(string type, IExpressionHandler handler)
{
return new()
{
Type = type,
HandlerFactory = _ => handler
};
}
private static IExpressionDescriptorProvider CreateProvider(params ExpressionDescriptor[] descriptors)
{
var provider = Substitute.For<IExpressionDescriptorProvider>();
provider.GetDescriptors().Returns(descriptors);
return provider;
}
private static ExpressionDescriptorRegistry CreateRegistry(params IExpressionDescriptorProvider[] providers)
{
return new(providers);
}
}