From e8cd3cded5d4274b09fc17865c60787ae77c2d24 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 23 Jun 2025 10:03:10 +0200 Subject: [PATCH] Implement Activity Testing API (#6719) * Adds activity test-run endpoint Adds an API endpoint to facilitate testing of individual activities within a workflow. The endpoint allows developers to execute a specific activity within a given workflow definition. Also ensures latest workflow definition is marked when publishing. * Enables activity test run to return results Updates the activity test run endpoint to return activity execution results such as outputs, outcomes, exceptions, and status. This allows users to inspect the results of test runs for activities, providing valuable insights into their behavior. * Add activity testing API integration and activity test UI support Introduced `ITestsApi` and supporting models for activity testing in workflows. Updated the UI to support triggering and displaying activity test results, including handling outcomes, outputs, exceptions, and status. Additionally, updated dependency injection for the new API and adjusted the project references. * Refactor workflow test endpoints and update method parameters Updated the namespace and route for activity test endpoints from `TestRun` to `Tests/Activities`. Adjusted default parameter values in `WorkflowDefinitionHandle` method for improved consistency. * Introduce `ActivityTestRunner` to streamline activity testing Adds a new `IActivityTestRunner` interface and its implementation, simplifying the execution of individual workflow activities for testing purposes. Updates dependencies and refactors activity test endpoints to use the new service. * Update `ActivityTestRunner` to support variable test values Enhanced the `ActivityTestRunner` to include functionality for injecting test-specific variable values when executing workflow activities. Added a helper method `GetVariableTestValues` to retrieve and deserialize test variable data from custom properties. * Update `Tests/Activities` endpoint to include `ActivityState` Refactored the response model to replace `Outcomes` with `ActivityState` and `Payload`, ensuring more detailed activity execution results. * Add draft saving and saved notifications for workflow definitions * Add draft saving notification handling in cache evictions Integrates handling for `WorkflowDefinitionDraftSaving` and `WorkflowDefinitionDraftSaved` events within the workflow cache eviction service, ensuring definitions are properly evicted. * Refactor `ConfigureLiquidEngine` to simplify variable enumeration logic * Remove redundant `` tag from `ByDefinitionVersionId` method XML documentation * Refactor `Tests/Activities` endpoint documentation and constructor Updated XML documentation to clarify endpoint responsibilities and adjusted constructor by removing an unused dependency (`IIdentityGenerator`). --- .../DependencyInjectionExtensions.cs | 2 + .../Resources/Tests/ITestsApi.cs | 18 ++++ .../Resources/Tests/TestActivityRequest.cs | 9 ++ .../Resources/Tests/TestActivityResponse.cs | 12 +++ .../Models/ActivityHandle.cs | 16 ++++ .../Models/WorkflowDefinitionHandle.cs | 47 +++++++++++ .../Handlers/ConfigureLiquidEngine.cs | 20 +---- .../Endpoints/Tests/Activities/Endpoint.cs | 83 +++++++++++++++++++ .../Contexts/ActivityExecutionContext.cs | 2 +- .../Contexts/WorkflowExecutionContext.cs | 22 +++++ .../Contracts/IActivityInvoker.cs | 2 +- .../Contracts/IActivityTestRunner.cs | 8 ++ .../Contracts/IWorkflowRunner.cs | 18 ++-- .../Features/WorkflowsFeature.cs | 1 + .../DefaultActivityInvokerMiddleware.cs | 8 -- .../Models/WorkflowDefinitionHandle.cs | 2 +- .../Models/WorkflowGraph.cs | 48 +++++++++++ .../Services/ActivityInvoker.cs | 4 +- .../Services/ActivityTestRunner.cs | 46 ++++++++++ .../EvictWorkflowDefinitionServiceCache.cs | 20 +++-- .../WorkflowDefinitionDraftSaved.cs | 12 +++ .../WorkflowDefinitionDraftSaving.cs | 12 +++ .../Services/WorkflowDefinitionPublisher.cs | 3 + 23 files changed, 368 insertions(+), 47 deletions(-) create mode 100644 src/clients/Elsa.Api.Client/Resources/Tests/ITestsApi.cs create mode 100644 src/clients/Elsa.Api.Client/Resources/Tests/TestActivityRequest.cs create mode 100644 src/clients/Elsa.Api.Client/Resources/Tests/TestActivityResponse.cs create mode 100644 src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/ActivityHandle.cs create mode 100644 src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowDefinitionHandle.cs create mode 100644 src/modules/Elsa.Workflows.Api/Endpoints/Tests/Activities/Endpoint.cs create mode 100644 src/modules/Elsa.Workflows.Core/Contracts/IActivityTestRunner.cs create mode 100644 src/modules/Elsa.Workflows.Core/Services/ActivityTestRunner.cs create mode 100644 src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionDraftSaved.cs create mode 100644 src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionDraftSaving.cs diff --git a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs index 4bc70d80c..f846e499e 100644 --- a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs @@ -12,6 +12,7 @@ using Elsa.Api.Client.Resources.Resilience.Contracts; using Elsa.Api.Client.Resources.Scripting.Contracts; using Elsa.Api.Client.Resources.StorageDrivers.Contracts; using Elsa.Api.Client.Resources.Tasks.Contracts; +using Elsa.Api.Client.Resources.Tests; using Elsa.Api.Client.Resources.VariableTypes.Contracts; using Elsa.Api.Client.Resources.WorkflowActivationStrategies.Contracts; using Elsa.Api.Client.Resources.WorkflowDefinitions.Contracts; @@ -86,6 +87,7 @@ public static class DependencyInjectionExtensions services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); + services.AddApi(builderOptions); }); } diff --git a/src/clients/Elsa.Api.Client/Resources/Tests/ITestsApi.cs b/src/clients/Elsa.Api.Client/Resources/Tests/ITestsApi.cs new file mode 100644 index 000000000..09748d7e1 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/Tests/ITestsApi.cs @@ -0,0 +1,18 @@ +using Refit; + +namespace Elsa.Api.Client.Resources.Tests; + +/// +/// Represents a client for the testing API. +/// +public interface ITestsApi +{ + /// + /// Sends the specified request to the login API. + /// + /// The request. + /// The cancellation token. + /// The response. + [Post("/tests/activities")] + Task TestActivityAsync([Body] TestActivityRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Tests/TestActivityRequest.cs b/src/clients/Elsa.Api.Client/Resources/Tests/TestActivityRequest.cs new file mode 100644 index 000000000..49dfe56d0 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/Tests/TestActivityRequest.cs @@ -0,0 +1,9 @@ +using Elsa.Api.Client.Resources.WorkflowDefinitions.Models; + +namespace Elsa.Api.Client.Resources.Tests; + +public class TestActivityRequest +{ + public WorkflowDefinitionHandle WorkflowDefinitionHandle { get; set; } = null!; + public ActivityHandle ActivityHandle { get; set; } = null!; +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Tests/TestActivityResponse.cs b/src/clients/Elsa.Api.Client/Resources/Tests/TestActivityResponse.cs new file mode 100644 index 000000000..2830e7431 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/Tests/TestActivityResponse.cs @@ -0,0 +1,12 @@ +using Elsa.Api.Client.Resources.WorkflowInstances.Models; +using Elsa.Api.Client.Shared.Models; + +namespace Elsa.Api.Client.Resources.Tests; + +public class TestActivityResponse +{ + public IDictionary? Outputs { get; set; } + public ICollection? Outcomes { get; set; } + public ExceptionState? Exception { get; set; } + public ActivityStatus Status { get; set; } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/ActivityHandle.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/ActivityHandle.cs new file mode 100644 index 000000000..c6ee9fd16 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/ActivityHandle.cs @@ -0,0 +1,16 @@ +namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Models; + +/// +/// Represents a handle to an activity. +/// +public class ActivityHandle +{ + public static ActivityHandle FromActivityId(string activityId) => new() { ActivityId = activityId }; + public static ActivityHandle FromActivityNodeId(string activityNodeId) => new() { ActivityNodeId = activityNodeId }; + public static ActivityHandle FromActivityInstanceId(string activityInstanceId) => new() { ActivityInstanceId = activityInstanceId }; + public static ActivityHandle FromActivityHash(string activityHash) => new() { ActivityHash = activityHash }; + public string? ActivityId { get; init; } + public string? ActivityNodeId { get; init;} + public string? ActivityInstanceId { get; init;} + public string? ActivityHash { get; init;} +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowDefinitionHandle.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowDefinitionHandle.cs new file mode 100644 index 000000000..59f337784 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowDefinitions/Models/WorkflowDefinitionHandle.cs @@ -0,0 +1,47 @@ +using Elsa.Api.Client.Shared.Models; + +namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Models; + +/// +/// Represents a handle to a workflow definition. +/// +public class WorkflowDefinitionHandle +{ + /// + /// Gets or sets the definition ID. When set, the property is ignored. + /// + public string? DefinitionId { get; set; } + + /// + /// Gets or sets the version options. When set, the property is ignored. + /// + public VersionOptions? VersionOptions { get; set; } + + /// + /// Gets or sets the definition version ID. When set, the and properties are ignored. + /// + public string? DefinitionVersionId { get; set; } + + /// + /// Creates a new instance with the specified definition ID and version options. + /// + public static WorkflowDefinitionHandle ByDefinitionId(string definitionId, VersionOptions? versionOptions = null) => new() { DefinitionId = definitionId, VersionOptions = versionOptions }; + + /// + /// Creates a new instance with the specified definition version ID. + /// + /// + public static WorkflowDefinitionHandle ByDefinitionVersionId(string definitionVersionId) => new() { DefinitionVersionId = definitionVersionId }; + + /// + public override string ToString() + { + if (DefinitionId != null) + return $"DefinitionId: {DefinitionId}, VersionOptions: {VersionOptions}"; + + if (DefinitionVersionId != null) + return $"DefinitionVersionId: {DefinitionVersionId}"; + + return string.Empty; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Expressions.Liquid/Handlers/ConfigureLiquidEngine.cs b/src/modules/Elsa.Expressions.Liquid/Handlers/ConfigureLiquidEngine.cs index 8c2746ddc..34bff8f32 100644 --- a/src/modules/Elsa.Expressions.Liquid/Handlers/ConfigureLiquidEngine.cs +++ b/src/modules/Elsa.Expressions.Liquid/Handlers/ConfigureLiquidEngine.cs @@ -91,29 +91,11 @@ internal class ConfigureLiquidEngine : INotificationHandler EnumerateVariablesInScope(ExpressionExecutionContext context) - { - var currentScope = context; - - while (currentScope != null) - { - if (!currentScope.TryGetActivityExecutionContext(out var activityExecutionContext)) - break; - - var variables = activityExecutionContext.Variables; - - foreach (var variable in variables) - yield return variable; - - currentScope = currentScope.ParentContext; - } - } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/Tests/Activities/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/Tests/Activities/Endpoint.cs new file mode 100644 index 000000000..12311231f --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/Tests/Activities/Endpoint.cs @@ -0,0 +1,83 @@ +using Elsa.Abstractions; +using Elsa.Workflows.Management; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.State; + +namespace Elsa.Workflows.Api.Endpoints.Tests.Activities; + +/// +/// Represents an endpoint for testing activities in workflows. This endpoint is responsible for handling requests +/// that test the execution of a specific activity in a workflow and returning the results. +/// +/// +/// This endpoint is used to perform operations such as: +/// - Finding a workflow graph based on a provided workflow definition handle. +/// - Locating and executing a specific activity within the workflow graph. +/// - Capturing the execution results and returning them as a response. +/// +internal class Endpoint( + IWorkflowDefinitionService workflowDefinitionService, + IActivityTestRunner activityTestRunner, + IActivityExecutionMapper activityExecutionMapper) + : ElsaEndpoint +{ + /// + public override void Configure() + { + Post("/tests/activities"); + ConfigurePermissions("exec:tests"); + } + + /// + public override async Task HandleAsync(Request request, CancellationToken cancellationToken) + { + var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(request.WorkflowDefinitionHandle, cancellationToken); + + if (workflowGraph == null) + { + AddError("Workflow definition not found."); + await SendErrorsAsync(cancellation: cancellationToken); + return; + } + + var activity = workflowGraph.FindActivity(request.ActivityHandle); + + if (activity == null) + { + AddError("Activity not found."); + await SendErrorsAsync(cancellation: cancellationToken); + return; + } + + var activityExecutionContext = await activityTestRunner.RunAsync(workflowGraph, activity, cancellationToken); + var record = activityExecutionMapper.Map(activityExecutionContext); + var activityState = record.ActivityState ?? new Dictionary(); + + var response = new Response + { + ActivityState = activityState, + Outputs = record.Outputs, + Payload = record.Payload, + Exception = record.Exception, + Status = record.Status + }; + + await SendOkAsync(response, cancellationToken); + } +} + +public class Request +{ + public WorkflowDefinitionHandle WorkflowDefinitionHandle { get; set; } = null!; + public ActivityHandle ActivityHandle { get; set; } = null!; +} + +public class Response +{ + public IDictionary ActivityState { get; set; } = null!; + public IDictionary? Outputs { get; set; } + public IDictionary? Payload { get; set; } + public ExceptionState? Exception { get; set; } + public ActivityStatus Status { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs index b08a72c74..af08c5b40 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs @@ -232,7 +232,7 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable /// /// Returns the global node ID for the current activity within the graph. /// - /// As of tool version 3.0, all activity Ids are already unique, so there's no need to construct a hierarchical ID + /// As of tool version 3.0, all activity IDs are already unique, so there's no need to construct a hierarchical ID public string NodeId => ActivityNode.NodeId; public ISet Children { get; } = new HashSet(); diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs index 0254a95bb..262d327c1 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs @@ -85,6 +85,28 @@ public partial class WorkflowExecutionContext : IExecutionContext _cancellationTokenSources.Add(linkedCancellationTokenSource); _cancellationRegistrations.Add(linkedCancellationTokenSource.Token.Register(CancelWorkflow)); } + + /// + /// Creates a new for the specified workflow. + /// + public static async Task CreateAsync( + IServiceProvider serviceProvider, + WorkflowGraph workflowGraph, + string id, + CancellationToken cancellationToken = default) + { + var systemClock = serviceProvider.GetRequiredService(); + + return await CreateAsync( + serviceProvider, + workflowGraph, + id, + new List(), + new List(), + systemClock.UtcNow, + cancellationToken: cancellationToken + ); + } /// /// Creates a new for the specified workflow. diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityInvoker.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityInvoker.cs index a3cd9af2f..6b9267cc8 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IActivityInvoker.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityInvoker.cs @@ -13,7 +13,7 @@ public interface IActivityInvoker /// The workflow execution context. /// The activity to invoke. /// - Task InvokeAsync(WorkflowExecutionContext workflowExecutionContext, IActivity activity, ActivityInvocationOptions? options = default); + Task InvokeAsync(WorkflowExecutionContext workflowExecutionContext, IActivity activity, ActivityInvocationOptions? options = null); /// /// Invokes the specified activity execution context. diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IActivityTestRunner.cs b/src/modules/Elsa.Workflows.Core/Contracts/IActivityTestRunner.cs new file mode 100644 index 000000000..299ae2278 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IActivityTestRunner.cs @@ -0,0 +1,8 @@ +using Elsa.Workflows.Models; + +namespace Elsa.Workflows; + +public interface IActivityTestRunner +{ + Task RunAsync(WorkflowGraph workflowGraph, IActivity activity, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs index 5b3c7fd0e..a5bf9f801 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs @@ -10,14 +10,14 @@ namespace Elsa.Workflows; /// public interface IWorkflowRunner { - Task RunAsync(IActivity activity, RunWorkflowOptions? options = default, CancellationToken cancellationToken = default); - Task RunAsync(IWorkflow workflow, RunWorkflowOptions? options = default, CancellationToken cancellationToken = default); - Task> RunAsync(WorkflowBase workflow, RunWorkflowOptions? options = default, CancellationToken cancellationToken = default); - Task RunAsync(RunWorkflowOptions? options = default, CancellationToken cancellationToken = default) where T : IWorkflow, new(); - Task RunAsync(RunWorkflowOptions? options = default, CancellationToken cancellationToken = default) where T : WorkflowBase, new(); - Task RunAsync(Workflow workflow, RunWorkflowOptions? options = default, CancellationToken cancellationToken = default); - Task RunAsync(Workflow workflow, WorkflowState workflowState, RunWorkflowOptions? options = default, CancellationToken cancellationToken = default); - Task RunAsync(WorkflowGraph workflowGraph, RunWorkflowOptions? options = default, CancellationToken cancellationToken = default); - Task RunAsync(WorkflowGraph workflowGraph, WorkflowState workflowState, RunWorkflowOptions? options = default, CancellationToken cancellationToken = default); + Task RunAsync(IActivity activity, RunWorkflowOptions? options = null, CancellationToken cancellationToken = default); + Task RunAsync(IWorkflow workflow, RunWorkflowOptions? options = null, CancellationToken cancellationToken = default); + Task> RunAsync(WorkflowBase workflow, RunWorkflowOptions? options = null, CancellationToken cancellationToken = default); + Task RunAsync(RunWorkflowOptions? options = null, CancellationToken cancellationToken = default) where T : IWorkflow, new(); + Task RunAsync(RunWorkflowOptions? options = null, CancellationToken cancellationToken = default) where T : WorkflowBase, new(); + Task RunAsync(Workflow workflow, RunWorkflowOptions? options = null, CancellationToken cancellationToken = default); + Task RunAsync(Workflow workflow, WorkflowState workflowState, RunWorkflowOptions? options = null, CancellationToken cancellationToken = default); + Task RunAsync(WorkflowGraph workflowGraph, RunWorkflowOptions? options = null, CancellationToken cancellationToken = default); + Task RunAsync(WorkflowGraph workflowGraph, WorkflowState workflowState, RunWorkflowOptions? options = null, CancellationToken cancellationToken = default); Task RunAsync(WorkflowExecutionContext workflowExecutionContext); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index cc3f81aad..655bdaf5a 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -163,6 +163,7 @@ public class WorkflowsFeature : FeatureBase // Core. .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs index a4eb9ba8e..6731bb8a6 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs @@ -82,14 +82,6 @@ public class DefaultActivityInvokerMiddleware(ActivityMiddlewareDelegate next, I // Invoke next middleware. await next(context); - // // If the activity created any bookmarks, copy them into the workflow execution context. - // if (context.Bookmarks.Any()) - // { - // // Store bookmarks. - // workflowExecutionContext.Bookmarks.AddRange(context.Bookmarks); - // logger.LogDebug("Added {BookmarkCount} bookmarks to the workflow execution context", context.Bookmarks.Count); - // } - // Conditionally commit the workflow state. if (ShouldCommit(context, ActivityLifetimeEvent.ActivityExecuted)) await context.WorkflowExecutionContext.CommitAsync(); diff --git a/src/modules/Elsa.Workflows.Core/Models/WorkflowDefinitionHandle.cs b/src/modules/Elsa.Workflows.Core/Models/WorkflowDefinitionHandle.cs index 92957f25e..4844ec0f3 100644 --- a/src/modules/Elsa.Workflows.Core/Models/WorkflowDefinitionHandle.cs +++ b/src/modules/Elsa.Workflows.Core/Models/WorkflowDefinitionHandle.cs @@ -25,7 +25,7 @@ public class WorkflowDefinitionHandle /// /// Creates a new instance with the specified definition ID and version options. /// - public static WorkflowDefinitionHandle ByDefinitionId(string definitionId, VersionOptions? versionOptions = default) => new() { DefinitionId = definitionId, VersionOptions = versionOptions }; + public static WorkflowDefinitionHandle ByDefinitionId(string definitionId, VersionOptions? versionOptions = null) => new() { DefinitionId = definitionId, VersionOptions = versionOptions }; /// /// Creates a new instance with the specified definition version ID. diff --git a/src/modules/Elsa.Workflows.Core/Models/WorkflowGraph.cs b/src/modules/Elsa.Workflows.Core/Models/WorkflowGraph.cs index d8232a8bb..946be40ee 100644 --- a/src/modules/Elsa.Workflows.Core/Models/WorkflowGraph.cs +++ b/src/modules/Elsa.Workflows.Core/Models/WorkflowGraph.cs @@ -53,6 +53,54 @@ public record WorkflowGraph /// public IDictionary NodeIdLookup { get; } + /// + /// Finds the activity based on the provided . + /// + /// The handle containing the identification parameters for the activity. + /// The activity found based on the handle, or null if no activity is found. + public IActivity? FindActivity(ActivityHandle handle) + { + return handle.ActivityId != null + ? FindActivityById(handle.ActivityId) + : handle.ActivityNodeId != null + ? FindActivityByNodeId(handle.ActivityNodeId) + : handle.ActivityHash != null + ? FindActivityByHash(handle.ActivityHash) + : null; + } + + /// + /// Returns the with the specified activity ID from the workflow graph. + /// + public ActivityNode? FindNodeById(string nodeId) => NodeIdLookup.TryGetValue(nodeId, out var node) ? node : null; + + /// + /// Returns the with the specified hash of the activity node ID from the workflow graph. + /// + /// The hash of the activity node ID. + /// The with the specified hash of the activity node ID. + public ActivityNode? FindNodeByHash(string hash) => NodeHashLookup.TryGetValue(hash, out var node) ? node : null; + + /// Returns the containing the specified activity from the workflow graph. + public ActivityNode? FindNodeByActivity(IActivity activity) + { + return NodeActivityLookup.TryGetValue(activity, out var node) ? node : null; + } + + /// Returns the associated with the specified activity ID. + public ActivityNode? FindNodeByActivityId(string activityId) => Nodes.FirstOrDefault(x => x.Activity.Id == activityId); + + /// Returns the with the specified ID from the workflow graph. + public IActivity? FindActivityByNodeId(string nodeId) => FindNodeById(nodeId)?.Activity; + + /// Returns the with the specified ID from the workflow graph. + public IActivity? FindActivityById(string activityId) => FindNodeById(NodeIdLookup.SingleOrDefault(n => n.Key.EndsWith(activityId)).Value.NodeId)?.Activity; + + /// Returns the with the specified hash of the activity node ID from the workflow graph. + /// The hash of the activity node ID. + /// The with the specified hash of the activity node ID. + public IActivity? FindActivityByHash(string hash) => FindNodeByHash(hash)?.Activity; + private static string Hash(HashAlgorithm hashAlgorithm, string input) { var data = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(input)); diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityInvoker.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityInvoker.cs index a7d86aa5d..4560f6974 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityInvoker.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityInvoker.cs @@ -12,7 +12,7 @@ public class ActivityInvoker( { /// - public async Task InvokeAsync(WorkflowExecutionContext workflowExecutionContext, IActivity activity, ActivityInvocationOptions? options = null) + public async Task InvokeAsync(WorkflowExecutionContext workflowExecutionContext, IActivity activity, ActivityInvocationOptions? options = null) { // Setup an activity execution context, potentially reusing an existing one if requested. var existingActivityExecutionContext = options?.ExistingActivityExecutionContext; @@ -34,6 +34,8 @@ public class ActivityInvoker( // Execute the activity execution pipeline. await InvokeAsync(activityExecutionContext); + + return activityExecutionContext; } /// diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityTestRunner.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityTestRunner.cs new file mode 100644 index 000000000..f0d41edf8 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityTestRunner.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using Elsa.Extensions; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows; + +/// +public class ActivityTestRunner( + IServiceProvider serviceProvider, + IWorkflowExecutionPipeline pipeline, + IIdentityGenerator identityGenerator) + : IActivityTestRunner +{ + /// + public async Task RunAsync(WorkflowGraph workflowGraph, IActivity activity, CancellationToken cancellationToken = default) + { + var id = identityGenerator.GenerateId(); + var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync(serviceProvider, workflowGraph, id, cancellationToken); + var variableTestValues = GetVariableTestValues(workflowGraph); + + foreach (var variable in workflowGraph.Workflow.Variables) + { + var variableValue = variableTestValues.TryGetValue(variable.Id, out var value) ? value : variable.Value; + variable.Set(workflowExecutionContext.ExpressionExecutionContext!, variableValue); + } + + workflowExecutionContext.ScheduleActivity(activity); + workflowExecutionContext.TransitionTo(WorkflowSubStatus.Executing); + + await pipeline.ExecuteAsync(workflowExecutionContext); + var activityExecutionContext = workflowExecutionContext + .ActivityExecutionContexts + .First(x => x.Activity == activity); + return activityExecutionContext; + } + + private IDictionary GetVariableTestValues(WorkflowGraph workflowGraph) + { + var variableTestValues = workflowGraph.Workflow.CustomProperties.TryGetValue("VariableTestValues", out var variableTestValuesObj) ? variableTestValuesObj : null; + + if (variableTestValues is JsonElement jsonElement) + variableTestValues = JsonSerializer.Deserialize>(jsonElement.GetRawText()); + + return variableTestValues as IDictionary ?? new Dictionary(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Handlers/Notifications/EvictWorkflowDefinitionServiceCache.cs b/src/modules/Elsa.Workflows.Management/Handlers/Notifications/EvictWorkflowDefinitionServiceCache.cs index f3802e958..d465629d8 100644 --- a/src/modules/Elsa.Workflows.Management/Handlers/Notifications/EvictWorkflowDefinitionServiceCache.cs +++ b/src/modules/Elsa.Workflows.Management/Handlers/Notifications/EvictWorkflowDefinitionServiceCache.cs @@ -12,28 +12,34 @@ namespace Elsa.Workflows.Management.Handlers.Notifications; /// [UsedImplicitly] internal class EvictWorkflowDefinitionServiceCache(IWorkflowDefinitionCacheManager workflowDefinitionCacheManager) : + INotificationHandler, INotificationHandler, INotificationHandler, INotificationHandler, INotificationHandler, INotificationHandler { - /// - public async Task HandleAsync(WorkflowDefinitionPublishing notification, CancellationToken cancellationToken) + public Task HandleAsync(WorkflowDefinitionDraftSaving notification, CancellationToken cancellationToken) { - await workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(notification.WorkflowDefinition.DefinitionId, cancellationToken); + return workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(notification.WorkflowDefinition.DefinitionId, cancellationToken); + } + + /// + public Task HandleAsync(WorkflowDefinitionPublishing notification, CancellationToken cancellationToken) + { + return workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(notification.WorkflowDefinition.DefinitionId, cancellationToken); } /// - public async Task HandleAsync(WorkflowDefinitionRetracting notification, CancellationToken cancellationToken) + public Task HandleAsync(WorkflowDefinitionRetracting notification, CancellationToken cancellationToken) { - await workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(notification.WorkflowDefinition.DefinitionId, cancellationToken); + return workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(notification.WorkflowDefinition.DefinitionId, cancellationToken); } /// - public async Task HandleAsync(WorkflowDefinitionDeleting notification, CancellationToken cancellationToken) + public Task HandleAsync(WorkflowDefinitionDeleting notification, CancellationToken cancellationToken) { - await workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(notification.DefinitionId, cancellationToken); + return workflowDefinitionCacheManager.EvictWorkflowDefinitionAsync(notification.DefinitionId, cancellationToken); } /// diff --git a/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionDraftSaved.cs b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionDraftSaved.cs new file mode 100644 index 000000000..b0f6868cb --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionDraftSaved.cs @@ -0,0 +1,12 @@ +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Management.Entities; +using JetBrains.Annotations; + +namespace Elsa.Workflows.Management.Notifications; + +/// +/// A notification that is sent when a workflow definition has been updated. +/// +/// The workflow definition. +[PublicAPI] +public record WorkflowDefinitionDraftSaved(WorkflowDefinition WorkflowDefinition) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionDraftSaving.cs b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionDraftSaving.cs new file mode 100644 index 000000000..a3379dc3e --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionDraftSaving.cs @@ -0,0 +1,12 @@ +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Management.Entities; +using JetBrains.Annotations; + +namespace Elsa.Workflows.Management.Notifications; + +/// +/// A notification that is sent when a workflow definition is being updated. +/// +/// The workflow definition. +[PublicAPI] +public record WorkflowDefinitionDraftSaving(WorkflowDefinition WorkflowDefinition) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs index 88640c69d..0fc3c4d90 100644 --- a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs @@ -113,6 +113,7 @@ public class WorkflowDefinitionPublisher( // Save the newly published definition. definition.IsPublished = true; + definition.IsLatest = true; definition = Initialize(definition); await workflowDefinitionStore.SaveAsync(definition, cancellationToken); @@ -194,7 +195,9 @@ public class WorkflowDefinitionPublisher( draft.IsLatest = true; draft = Initialize(draft); + await mediator.SendAsync(new WorkflowDefinitionDraftSaving(draft), cancellationToken); await workflowDefinitionStore.SaveAsync(draft, cancellationToken); + await mediator.SendAsync(new WorkflowDefinitionDraftSaved(draft), cancellationToken); if (lastVersion is null) await mediator.SendAsync(new WorkflowDefinitionCreated(definition), cancellationToken);