diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs index 71081ab497..bff0f91825 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs @@ -33,6 +33,8 @@ public async ValueTask InvokeAsync(ActivityExecutionContext context) { context.CancellationToken.ThrowIfCancellationRequested(); + var incidentStrategyResolver = context.GetRequiredService(); + var workflowExecutionContext = context.WorkflowExecutionContext; // Evaluate input properties. @@ -46,11 +48,29 @@ public async ValueTask InvokeAsync(ActivityExecutionContext context) return; } - // Check if the activity can be executed. - if (!await context.Activity.CanExecuteAsync(context)) + try + { + // Check if the activity can be executed. + if (!await context.Activity.CanExecuteAsync(context)) + { + context.TransitionTo(ActivityStatus.Pending); + context.AddExecutionLogEntry("Precondition Failed", "Cannot execute at this time"); + return; + } + } + catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested) + { + await context.CancelActivityAsync(); + return; + } + catch (Exception ex) { - context.TransitionTo(ActivityStatus.Pending); - context.AddExecutionLogEntry("Precondition Failed", "Cannot execute at this time"); + logger.LogWarning(ex, "An unhandled exception was thrown while evaluating whether activity {ActivityType} {ActivityTypeId} can execute. Transitioning to faulted state.", + context.Activity.Name, context.Activity.Id); + + context.Fault(ex); + var strategy = await incidentStrategyResolver.ResolveStrategyAsync(context); + strategy.HandleIncident(context); return; } @@ -65,7 +85,24 @@ public async ValueTask InvokeAsync(ActivityExecutionContext context) context.TransitionTo(ActivityStatus.Running); // Execute activity. - await ExecuteActivityAsync(context); + try + { + await ExecuteActivityAsync(context); + } + catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested) + { + await context.CancelActivityAsync(); + return; + } + catch (Exception ex) + { + logger.LogWarning(ex, "An unhandled exception was thrown while executing the activity {ActivityType} {ActivityTypeId}. Transitioning to faulted state.", + context.Activity.Name, context.Activity.Id); + context.Fault(ex); + var strategy = await incidentStrategyResolver.ResolveStrategyAsync(context); + strategy.HandleIncident(context); + return; + } var currentActivityStatus = context.Status; var activityDidComplete = previousActivityStatus != ActivityStatus.Completed && currentActivityStatus == ActivityStatus.Completed; diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs index 8498bc4ebb..810bad0143 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs @@ -213,7 +213,9 @@ private static string GetFriendlyActivityName(Type t) { if (!t.IsGenericType) return t.Name; - var baseName = t.Name.Substring(0, t.Name.IndexOf('`')); + + var genericIndex = t.Name.IndexOf('`'); + var baseName = genericIndex > 0 ? t.Name.Substring(0, genericIndex) : t.Name; var argNames = string.Join(", ", t.GetGenericArguments().Select(a => a.Name)); return $"{baseName}<{argNames}>"; } diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/ActivityExecutionMiddlewareTestsBase.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/ActivityExecutionMiddlewareTestsBase.cs new file mode 100644 index 0000000000..f4b1df6b45 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/ActivityExecutionMiddlewareTestsBase.cs @@ -0,0 +1,123 @@ +using Elsa.Extensions; +using Elsa.Mediator.Contracts; +using Elsa.Testing.Shared; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.CommitStates; +using Elsa.Workflows.Pipelines.ActivityExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit.Abstractions; + +namespace Elsa.Workflows.Core.UnitTests.Middleware.Activities; + +/// +/// Base class for testing activity execution middleware components. Provides a test activity and an +/// activity execution context that can be used in the tests. Also allows you to configure the +/// for the tests by overriding the method. +/// +/// The type of the activity execution middleware being tested. +public abstract class ActivityExecutionMiddlewareTestsBase : IAsyncLifetime where T : class, IActivityExecutionMiddleware +{ + [Activity(Type = "TestActivity", Namespace = "UnitTests")] + public class TestActivity : Activity + { + public Exception? ExecuteThrows { get; set; } + public Exception? ExecuteFaults { get; set; } + public Exception? CanExecuteThrows { get; set; } + public bool AutoComplete { get; set; } = false; + + protected override ValueTask CanExecuteAsync(ActivityExecutionContext context) + { + if (CanExecuteThrows is not null) + { + throw CanExecuteThrows; + } + + return ValueTask.FromResult(true); + } + + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + if (ExecuteThrows is not null) + { + throw ExecuteThrows; + } + + if (ExecuteFaults is not null) + { + context.Fault(ExecuteFaults); + } + + if (AutoComplete) + { + await context.CompleteActivityAsync(); + } + } + } + + protected readonly TestActivity _activity; + protected readonly ITestOutputHelper _testOutputHelper; + protected readonly INotificationSender _notificationSender; + private readonly ActivityTestFixture _activityTestFixture; + + protected ActivityExecutionContext ExecutionContext { get; private set; } + + protected Action PipelineFactory { get; set; } = b => b.UseMiddleware(); + + protected IActivityExecutionPipeline Pipeline => ExecutionContext.GetRequiredService(); + + protected ActivityExecutionMiddlewareTestsBase(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + _activity = new TestActivity(); + _notificationSender = Substitute.For(); + + ExecutionContext = default!; + + _activityTestFixture = new ActivityTestFixture(_activity) + .ConfigureServices(ConfigureServices); + } + + /// + /// Allows you to configure the + /// + protected virtual void ConfigureServices(IServiceCollection services) + { + // Skip doing any commits + var activityCommitStrategy = Substitute.For(); + activityCommitStrategy + .ShouldCommit(Arg.Any()) + .Returns(CommitAction.Skip); + + services + .AddTransient() + .AddTransient() + .RemoveAll() + .AddTransient(_ => _notificationSender) + .AddTransient() + .AddTransient(_ => Substitute.For()) + .Configure(options => options.DefaultActivityCommitStrategy = activityCommitStrategy) + .AddTransient(sp => new ActivityExecutionPipeline(sp, PipelineFactory)) + .AddSingleton(_activity) + .AddLogging(config => config.AddProvider(new XunitLoggerProvider(_testOutputHelper))) + .RemoveAll() + .AddSingleton() + .AddSingleton(sp => + { + var registry = sp.GetRequiredService(); + registry.Add(_activity.GetType(), new Workflows.Models.ActivityDescriptor()); + return registry; + }); + } + + + async Task IAsyncLifetime.InitializeAsync() + { + ExecutionContext = await _activityTestFixture.BuildAsync(); + } + + Task IAsyncLifetime.DisposeAsync() + => Task.CompletedTask; +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/ActivityInvokerMiddlewareTestsBase.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/ActivityInvokerMiddlewareTestsBase.cs new file mode 100644 index 0000000000..3b0f8e1793 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/ActivityInvokerMiddlewareTestsBase.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using Elsa.Mediator.Contracts; +using NSubstitute; +using Xunit.Abstractions; + +namespace Elsa.Workflows.Core.UnitTests.Middleware.Activities; + +/// +/// Abstract class used to test activity execution middleware components. +/// It provides a set of standard tests that validate the behavior of the middleware component when executing an activity, such as: +/// +/// +public abstract class ActivityInvokerMiddlewareTestsBase : ActivityExecutionMiddlewareTestsBase where T : class, IActivityExecutionMiddleware +{ + protected ActivityInvokerMiddlewareTestsBase(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + } + + + [Fact] + public async Task ActivityExceuteThrows_IncidentsCount_IsOne() + { + // Setup + _activity.ExecuteThrows = new Exception("EXCEPTION!"); + + // Act + await Pipeline.ExecuteAsync(ExecutionContext); + + // Assert + Assert.Single(ExecutionContext.WorkflowExecutionContext.Incidents); + } + + [Fact] + public async Task ActivityExceuteThrows_ActivityStatus_IsFaulted() + { + // Setup + _activity.ExecuteThrows = new Exception("EXCEPTION!"); + + // Act + await Pipeline.ExecuteAsync(ExecutionContext); + + // Assert + Assert.Equal(ActivityStatus.Faulted, ExecutionContext.Status); + Assert.Equal(1, ExecutionContext.AggregateFaultCount); + } + + [Fact] + public async Task ActivityExecuteFaults_ActivityStatus_IsFualted() + { + // Setup + _activity.ExecuteFaults = new Exception("EXCEPTION!"); + + // Act + await Pipeline.ExecuteAsync(ExecutionContext); + + // Assert + Assert.Equal(ActivityStatus.Faulted, ExecutionContext.Status); + Assert.Equal(1, ExecutionContext.AggregateFaultCount); + } + + [Fact] + public async Task ActivityCanExceuteThrows_IncidentsCount_IsOne() + { + // Setup + _activity.CanExecuteThrows = new Exception("EXCEPTION!"); + + // Act + await Pipeline.ExecuteAsync(ExecutionContext); + + // Assert + Assert.Single(ExecutionContext.WorkflowExecutionContext.Incidents); + } + + [Fact] + public async Task ActivityCanExceuteThrows_ActivityStatus_IsFaulted() + { + // Setup + _activity.CanExecuteThrows = new Exception("EXCEPTION!"); + + // Act + await Pipeline.ExecuteAsync(ExecutionContext); + + // Assert + Assert.Equal(ActivityStatus.Faulted, ExecutionContext.Status); + } + +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/DefaultActivityInvokerMiddlewareTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/DefaultActivityInvokerMiddlewareTests.cs new file mode 100644 index 0000000000..b3d27c2667 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/DefaultActivityInvokerMiddlewareTests.cs @@ -0,0 +1,16 @@ +using Elsa.Workflows.Middleware.Activities; +using Xunit.Abstractions; + +namespace Elsa.Workflows.Core.UnitTests.Middleware.Activities; + +/// +/// Validates the behavior of the component by using the standard tests +/// from . +/// +public class DefaultActivityInvokerMiddlewareTests : ActivityInvokerMiddlewareTestsBase +{ + public DefaultActivityInvokerMiddlewareTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/NotificationPublishingMiddlewareTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/NotificationPublishingMiddlewareTests.cs new file mode 100644 index 0000000000..63e2e0afa4 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Middleware/Activities/NotificationPublishingMiddlewareTests.cs @@ -0,0 +1,35 @@ +using Elsa.Workflows.Middleware.Activities; +using Elsa.Workflows.Notifications; +using Elsa.Workflows.Pipelines.ActivityExecution; +using NSubstitute; +using Xunit.Abstractions; + +namespace Elsa.Workflows.Core.UnitTests.Middleware.Activities; + +public class NotificationPublishingMiddlewareTests : ActivityExecutionMiddlewareTestsBase +{ + public NotificationPublishingMiddlewareTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + PipelineFactory += b => b.UseMiddleware(); + } + + [Fact] + public async Task InvokeAsync_Raises_ActivityExecutingEvent() + { + // Act + await Pipeline.ExecuteAsync(ExecutionContext); + + // Assert + await _notificationSender.Received().SendAsync(Arg.Any()); + } + + [Fact] + public async Task InvokeAsync_Raises_ActivityExecutedEvent() + { + // Act + await Pipeline.ExecuteAsync(ExecutionContext); + + // Assert + await _notificationSender.Received().SendAsync(Arg.Any()); + } +}