Adds ExecuteWorkflow activity tests (#7003)

* Add unit tests for `ExecuteWorkflow` activity covering various scenarios and refactor `FlowJoin` activity to remove `[Obsolete]` attribute.

* Add integration tests for `ExecuteWorkflow` activity

- Introduce parent and child workflow definitions to test various scenarios, including input handling, output capture, correlation ID setting, and execution order.
- Add corresponding JSON workflow files for integration tests.
- Update project files to include new workflows.

* Refactor `ExecuteWorkflowTests` to consolidate repetitive code by introducing utility methods for workflow execution and instance retrieval.

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2025-10-31 18:57:45 +01:00 committed by GitHub
parent 1423728769
commit a5b760ec09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1045 additions and 0 deletions

View file

@ -0,0 +1,112 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.State;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Activities.ExecuteWorkflow;
public class ExecuteWorkflowTests
{
private readonly IServiceProvider _services;
private readonly CapturingTextWriter _capturingTextWriter = new();
public ExecuteWorkflowTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper)
.WithCapturingTextWriter(_capturingTextWriter)
.WithWorkflowsFromDirectory("Activities", "ExecuteWorkflow", "Workflows")
.Build();
}
[Fact(DisplayName = "ExecuteWorkflow without WaitForCompletion completes immediately")]
public async Task ExecuteWorkflowWithoutWaitForCompletion()
{
var workflowState = await RunWorkflowAsync("parent-no-wait");
Assert.Equal(WorkflowStatus.Finished, workflowState.Status);
Assert.Equal(WorkflowSubStatus.Finished, workflowState.SubStatus);
var lines = _capturingTextWriter.Lines.ToList();
Assert.Contains("Parent: Before child", lines);
Assert.Contains("Child: Executing", lines);
Assert.Contains("Parent: After child", lines);
}
[Fact(DisplayName = "ExecuteWorkflow with WaitForCompletion waits for child to finish")]
public async Task ExecuteWorkflowWithWaitForCompletion()
{
var workflowState = await RunWorkflowAsync("parent-with-wait");
Assert.Equal(WorkflowStatus.Finished, workflowState.Status);
Assert.Equal(WorkflowSubStatus.Finished, workflowState.SubStatus);
var lines = _capturingTextWriter.Lines.ToList();
Assert.Equal(3, lines.Count);
Assert.Equal("Parent: Before child", lines[0]);
Assert.Equal("Child: Executing", lines[1]);
Assert.Equal("Parent: After child", lines[2]);
}
[Fact(DisplayName = "ExecuteWorkflow passes input to child workflow")]
public async Task ExecuteWorkflowPassesInput()
{
var workflowState = await RunWorkflowAsync("parent-with-input");
Assert.Equal(WorkflowStatus.Finished, workflowState.Status);
var lines = _capturingTextWriter.Lines.ToList();
Assert.Contains("Child received: Hello from parent", lines);
}
[Fact(DisplayName = "ExecuteWorkflow captures child workflow output")]
public async Task ExecuteWorkflowCapturesOutput()
{
var workflowState = await RunWorkflowAsync("parent-capture-output");
Assert.Equal(WorkflowStatus.Finished, workflowState.Status);
var lines = _capturingTextWriter.Lines.ToList();
Assert.Contains("Child output: 42", lines);
Assert.Contains("Parent received: 42", lines);
}
[Fact(DisplayName = "ExecuteWorkflow sets correlation ID on child workflow")]
public async Task ExecuteWorkflowSetsCorrelationId()
{
var workflowState = await RunWorkflowAsync("parent-with-correlation");
Assert.Equal(WorkflowStatus.Finished, workflowState.Status);
var childInstance = await GetWorkflowInstanceAsync("child-workflow");
Assert.Equal("test-correlation-123", childInstance.CorrelationId);
}
[Fact(DisplayName = "ExecuteWorkflow includes ParentInstanceId in child properties")]
public async Task ExecuteWorkflowSetsParentInstanceId()
{
await RunWorkflowAsync("parent-no-wait");
var parentInstance = await GetWorkflowInstanceAsync("parent-no-wait");
var childInstance = await GetWorkflowInstanceAsync("child-workflow");
Assert.True(childInstance.WorkflowState.Properties.ContainsKey("ParentInstanceId"));
Assert.Equal(parentInstance.Id, childInstance.WorkflowState.Properties["ParentInstanceId"]);
}
private async Task<WorkflowState> RunWorkflowAsync(string workflowDefinitionId)
{
await _services.PopulateRegistriesAsync();
return await _services.RunWorkflowUntilEndAsync(workflowDefinitionId);
}
private async Task<WorkflowInstance> GetWorkflowInstanceAsync(string definitionId)
{
var workflowInstanceStore = _services.GetRequiredService<IWorkflowInstanceStore>();
return (await workflowInstanceStore.FindAsync(new()
{
DefinitionId = definitionId
}))!;
}
}

View file

@ -0,0 +1,62 @@
{
"id": "child-with-input-v1",
"definitionId": "child-with-input",
"name": "Child With Input",
"createdAt": "2025-01-15T00:00:00.000000+00:00",
"version": 1,
"variables": [],
"inputs": [
{
"uiHint": "singleline",
"storageDriverType": "Elsa.Workflows.Services.WorkflowStorageDriver, Elsa.Workflows.Core",
"type": "String",
"name": "Message",
"displayName": "Message",
"description": "",
"category": "",
"isArray": false
}
],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isLatest": true,
"isPublished": true,
"root": {
"type": "Elsa.Flowchart",
"version": 1,
"id": "Flowchart1",
"metadata": {},
"customProperties": {
"source": "FlowchartJsonConverter.cs:45",
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"start": "WriteLine1",
"activities": [
{
"text": {
"typeName": "String",
"expression": {
"type": "JavaScript",
"value": "`Child received: ${getMessage()}`"
},
"memoryReference": {
"id": "WriteLine1:input-1"
}
},
"id": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
}
],
"connections": []
}
}

View file

@ -0,0 +1,100 @@
{
"id": "child-with-output-v1",
"definitionId": "child-with-output",
"name": "Child With Output",
"createdAt": "2025-01-15T00:00:00.000000+00:00",
"version": 1,
"variables": [],
"inputs": [],
"outputs": [
{
"type": "Object",
"name": "Result",
"displayName": "Result",
"description": "",
"isArray": false
}
],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isLatest": true,
"isPublished": true,
"root": {
"type": "Elsa.Flowchart",
"version": 1,
"id": "Flowchart1",
"metadata": {},
"customProperties": {
"source": "FlowchartJsonConverter.cs:45",
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"start": "WriteLine1",
"activities": [
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Child output: 42"
},
"memoryReference": {
"id": "WriteLine1:input-1"
}
},
"id": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
},
{
"outputName": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Result"
},
"memoryReference": {
"id": "SetOutput1:outputName"
}
},
"outputValue": {
"typeName": "Object",
"expression": {
"type": "Literal",
"value": 42
},
"memoryReference": {
"id": "SetOutput1:outputValue"
}
},
"id": "SetOutput1",
"type": "Elsa.SetOutput",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
}
],
"connections": [
{
"source": {
"activity": "WriteLine1",
"port": "Done"
},
"target": {
"activity": "SetOutput1",
"port": "In"
}
}
]
}
}

View file

@ -0,0 +1,51 @@
{
"id": "child-workflow-v1",
"definitionId": "child-workflow",
"name": "Child Workflow",
"createdAt": "2025-01-15T00:00:00.000000+00:00",
"version": 1,
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isLatest": true,
"isPublished": true,
"root": {
"type": "Elsa.Flowchart",
"version": 1,
"id": "Flowchart1",
"metadata": {},
"customProperties": {
"source": "FlowchartJsonConverter.cs:45",
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"start": "WriteLine1",
"activities": [
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Child: Executing"
},
"memoryReference": {
"id": "WriteLine1:input-1"
}
},
"id": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
}
],
"connections": []
}
}

View file

@ -0,0 +1,92 @@
{
"id": "parent-capture-output-v1",
"definitionId": "parent-capture-output",
"name": "Parent Capture Output",
"createdAt": "2025-01-15T00:00:00.000000+00:00",
"version": 1,
"variables": [
{
"id": "ChildResult",
"name": "ChildResult",
"typeName": "Object",
"isArray": false,
"value": null,
"storageDriverType": "Elsa.Workflows.Services.WorkflowStorageDriver, Elsa.Workflows.Core"
}
],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isLatest": true,
"isPublished": true,
"root": {
"type": "Elsa.Sequence",
"version": 1,
"id": "Sequence1",
"metadata": {},
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"activities": [
{
"workflowDefinitionId": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "child-with-output"
},
"memoryReference": {
"id": "ExecuteWorkflow1:workflowDefinitionId"
}
},
"waitForCompletion": {
"typeName": "Boolean",
"expression": {
"type": "Literal",
"value": true
},
"memoryReference": {
"id": "ExecuteWorkflow1:waitForCompletion"
}
},
"result": {
"typeName": "ExecuteWorkflowResult",
"memoryReference": {
"id": "ChildResult"
}
},
"id": "ExecuteWorkflow1",
"type": "Elsa.ExecuteWorkflow",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "JavaScript",
"value": "`Parent received: ${getChildResult().output.Result}`"
},
"memoryReference": {
"id": "WriteLine1:input-1"
}
},
"id": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
}
]
}
}

View file

@ -0,0 +1,97 @@
{
"id": "parent-no-wait-v1",
"definitionId": "parent-no-wait",
"name": "Parent No Wait",
"createdAt": "2025-01-15T00:00:00.000000+00:00",
"version": 1,
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isLatest": true,
"isPublished": true,
"root": {
"type": "Elsa.Sequence",
"version": 1,
"id": "Sequence1",
"metadata": {},
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"activities": [
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Parent: Before child"
},
"memoryReference": {
"id": "WriteLine1:input-1"
}
},
"id": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
},
{
"workflowDefinitionId": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "child-workflow"
},
"memoryReference": {
"id": "ExecuteWorkflow1:workflowDefinitionId"
}
},
"waitForCompletion": {
"typeName": "Boolean",
"expression": {
"type": "Literal",
"value": false
},
"memoryReference": {
"id": "ExecuteWorkflow1:waitForCompletion"
}
},
"id": "ExecuteWorkflow1",
"type": "Elsa.ExecuteWorkflow",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Parent: After child"
},
"memoryReference": {
"id": "WriteLine2:input-1"
}
},
"id": "WriteLine2",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
}
]
}
}

View file

@ -0,0 +1,71 @@
{
"id": "parent-with-correlation-v1",
"definitionId": "parent-with-correlation",
"name": "Parent With Correlation",
"createdAt": "2025-01-15T00:00:00.000000+00:00",
"version": 1,
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isLatest": true,
"isPublished": true,
"root": {
"type": "Elsa.Flowchart",
"version": 1,
"id": "Flowchart1",
"metadata": {},
"customProperties": {
"source": "FlowchartJsonConverter.cs:45",
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"start": "ExecuteWorkflow1",
"activities": [
{
"workflowDefinitionId": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "child-workflow"
},
"memoryReference": {
"id": "ExecuteWorkflow1:workflowDefinitionId"
}
},
"correlationId": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "test-correlation-123"
},
"memoryReference": {
"id": "ExecuteWorkflow1:correlationId"
}
},
"waitForCompletion": {
"typeName": "Boolean",
"expression": {
"type": "Literal",
"value": true
},
"memoryReference": {
"id": "ExecuteWorkflow1:waitForCompletion"
}
},
"id": "ExecuteWorkflow1",
"type": "Elsa.ExecuteWorkflow",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
}
],
"connections": []
}
}

View file

@ -0,0 +1,71 @@
{
"id": "parent-with-input-v1",
"definitionId": "parent-with-input",
"name": "Parent With Input",
"createdAt": "2025-01-15T00:00:00.000000+00:00",
"version": 1,
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isLatest": true,
"isPublished": true,
"root": {
"type": "Elsa.Flowchart",
"version": 1,
"id": "Flowchart1",
"metadata": {},
"customProperties": {
"source": "FlowchartJsonConverter.cs:45",
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"start": "ExecuteWorkflow1",
"activities": [
{
"workflowDefinitionId": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "child-with-input"
},
"memoryReference": {
"id": "ExecuteWorkflow1:workflowDefinitionId"
}
},
"input": {
"typeName": "IDictionary<String, Object>",
"expression": {
"type": "JavaScript",
"value": "({ Message: 'Hello from parent' })"
},
"memoryReference": {
"id": "ExecuteWorkflow1:input"
}
},
"waitForCompletion": {
"typeName": "Boolean",
"expression": {
"type": "Literal",
"value": true
},
"memoryReference": {
"id": "ExecuteWorkflow1:waitForCompletion"
}
},
"id": "ExecuteWorkflow1",
"type": "Elsa.ExecuteWorkflow",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
}
],
"connections": []
}
}

View file

@ -0,0 +1,97 @@
{
"id": "parent-with-wait-v1",
"definitionId": "parent-with-wait",
"name": "Parent With Wait",
"createdAt": "2025-01-15T00:00:00.000000+00:00",
"version": 1,
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isLatest": true,
"isPublished": true,
"root": {
"type": "Elsa.Sequence",
"version": 1,
"id": "Sequence1",
"metadata": {},
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"activities": [
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Parent: Before child"
},
"memoryReference": {
"id": "WriteLine1:input-1"
}
},
"id": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
},
{
"workflowDefinitionId": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "child-workflow"
},
"memoryReference": {
"id": "ExecuteWorkflow1:workflowDefinitionId"
}
},
"waitForCompletion": {
"typeName": "Boolean",
"expression": {
"type": "Literal",
"value": true
},
"memoryReference": {
"id": "ExecuteWorkflow1:waitForCompletion"
}
},
"id": "ExecuteWorkflow1",
"type": "Elsa.ExecuteWorkflow",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Parent: After child"
},
"memoryReference": {
"id": "WriteLine2:input-1"
}
},
"id": "WriteLine2",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {}
}
]
}
}

View file

@ -62,5 +62,29 @@
<None Update="Scenarios\WorkflowDefinitionActivities\Workflows\child-published-version.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Activities\ExecuteWorkflow\Workflows\child-with-input.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Activities\ExecuteWorkflow\Workflows\child-with-output.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Activities\ExecuteWorkflow\Workflows\child-workflow.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Activities\ExecuteWorkflow\Workflows\parent-capture-output.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Activities\ExecuteWorkflow\Workflows\parent-no-wait.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Activities\ExecuteWorkflow\Workflows\parent-with-correlation.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Activities\ExecuteWorkflow\Workflows\parent-with-input.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Activities\ExecuteWorkflow\Workflows\parent-with-wait.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -0,0 +1,268 @@
using Elsa.Common.Models;
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Management;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Activities;
using Elsa.Workflows.State;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Activities.UnitTests.Composition;
public class ExecuteWorkflowTests
{
private const string DefaultWorkflowDefinitionId = "test-workflow-def";
private const string DefaultGeneratedId = "generated-child-id";
private const string DefaultCorrelationId = "test-correlation";
[Theory]
[InlineData(true, WorkflowStatus.Finished)]
[InlineData(true, WorkflowStatus.Running)]
[InlineData(false, WorkflowStatus.Finished)]
[InlineData(false, WorkflowStatus.Running)]
public async Task Should_Invoke_Workflow_With_Correct_Options(bool waitForCompletion, WorkflowStatus childWorkflowStatus)
{
// Arrange
var input = new Dictionary<string, object> { ["Key1"] = "Value1" };
var childOutput = new Dictionary<string, object> { ["ChildKey"] = "ChildValue" };
var executeWorkflow = new ExecuteWorkflow
{
WorkflowDefinitionId = new(DefaultWorkflowDefinitionId),
CorrelationId = new(DefaultCorrelationId),
Input = new(input),
WaitForCompletion = new(waitForCompletion)
};
// Act
var (context, workflowInvoker) = await ExecuteAsync(executeWorkflow, childWorkflowStatus, childOutput);
var parentInstanceId = context.WorkflowExecutionContext.Id;
// Assert
await workflowInvoker.Received(1).InvokeAsync(
Arg.Any<WorkflowGraph>(),
Arg.Is<RunWorkflowOptions>(opts =>
opts.ParentWorkflowInstanceId == parentInstanceId &&
opts.WorkflowInstanceId == DefaultGeneratedId &&
opts.CorrelationId == DefaultCorrelationId &&
opts.Input != null && opts.Input.ContainsKey("Key1") && (string)opts.Input["Key1"] == "Value1" &&
opts.Input.ContainsKey("ParentInstanceId") && (string)opts.Input["ParentInstanceId"] == parentInstanceId &&
opts.Properties.ContainsKey("ParentInstanceId") && (string)opts.Properties["ParentInstanceId"] == parentInstanceId &&
(waitForCompletion ? opts.Properties.ContainsKey("WaitForCompletion") && (bool)opts.Properties["WaitForCompletion"] : !opts.Properties.ContainsKey("WaitForCompletion"))
),
Arg.Any<CancellationToken>()
);
// Result is only set when either not waiting or child workflow finishes
if (!waitForCompletion || childWorkflowStatus == WorkflowStatus.Finished)
{
var result = (ExecuteWorkflowResult)context.GetActivityOutput(() => executeWorkflow.Result)!;
Assert.NotNull(result);
Assert.Equal(DefaultGeneratedId, result.WorkflowInstanceId);
Assert.Equal(childWorkflowStatus, result.Status);
Assert.Equal(childOutput, result.Output);
}
}
[Theory]
[InlineData(false, WorkflowStatus.Running)]
[InlineData(true, WorkflowStatus.Finished)]
public async Task Should_Complete_Activity_When_Expected(bool waitForCompletion, WorkflowStatus childWorkflowStatus)
{
// Arrange
var executeWorkflow = new ExecuteWorkflow
{
WorkflowDefinitionId = new(DefaultWorkflowDefinitionId),
WaitForCompletion = new(waitForCompletion)
};
// Act
var (context, _) = await ExecuteAsync(executeWorkflow, childWorkflowStatus);
// Assert - Activity should complete when not waiting or when child workflow finishes
Assert.True(context.IsCompleted);
}
[Fact]
public async Task Should_Create_Bookmark_When_WaitForCompletion_Is_True_And_Child_Is_Running()
{
// Arrange
var executeWorkflow = new ExecuteWorkflow
{
WorkflowDefinitionId = new(DefaultWorkflowDefinitionId),
WaitForCompletion = new(true)
};
// Act
var (context, _) = await ExecuteAsync(executeWorkflow, WorkflowStatus.Running);
// Assert - Activity should not complete when child workflow is still running and waiting
Assert.False(context.IsCompleted);
Assert.NotEmpty(context.Bookmarks);
}
[Fact]
public async Task Should_Throw_When_Workflow_Definition_Not_Found()
{
// Arrange
const string workflowDefinitionId = "non-existent-workflow";
var workflowDefinitionService = Substitute.For<IWorkflowDefinitionService>();
workflowDefinitionService
.FindWorkflowGraphAsync(workflowDefinitionId, Arg.Any<VersionOptions>(), Arg.Any<CancellationToken>())
.Returns((WorkflowGraph?)null);
var executeWorkflow = new ExecuteWorkflow
{
WorkflowDefinitionId = new(workflowDefinitionId)
};
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => ExecuteAsync(executeWorkflow, customWorkflowDefinitionService: workflowDefinitionService));
}
[Fact]
public async Task Should_Use_Empty_Input_When_Input_Not_Provided()
{
// Arrange
var executeWorkflow = new ExecuteWorkflow
{
WorkflowDefinitionId = new(DefaultWorkflowDefinitionId)
};
// Act
var (_, workflowInvoker) = await ExecuteAsync(executeWorkflow);
// Assert - Should create input with ParentInstanceId even when no input provided
await workflowInvoker.Received(1).InvokeAsync(
Arg.Any<WorkflowGraph>(),
Arg.Is<RunWorkflowOptions>(opts =>
opts.Input != null &&
opts.Input.ContainsKey("ParentInstanceId") &&
opts.Input.Count == 1
),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Should_Use_Null_CorrelationId_When_Not_Provided()
{
// Arrange
var executeWorkflow = new ExecuteWorkflow
{
WorkflowDefinitionId = new(DefaultWorkflowDefinitionId)
};
// Act
var (_, workflowInvoker) = await ExecuteAsync(executeWorkflow);
// Assert
await workflowInvoker.Received(1).InvokeAsync(
Arg.Any<WorkflowGraph>(),
Arg.Is<RunWorkflowOptions>(opts => opts.CorrelationId == null),
Arg.Any<CancellationToken>()
);
}
[Theory]
[InlineData(WorkflowSubStatus.Cancelled)]
[InlineData(WorkflowSubStatus.Faulted)]
[InlineData(WorkflowSubStatus.Suspended)]
public async Task Should_Handle_Different_Workflow_SubStatuses(WorkflowSubStatus subStatus)
{
// Arrange
var executeWorkflow = new ExecuteWorkflow
{
WorkflowDefinitionId = new(DefaultWorkflowDefinitionId),
WaitForCompletion = new(false)
};
// Act
var (context, _) = await ExecuteAsync(executeWorkflow, WorkflowStatus.Finished, null, subStatus);
// Assert
var result = (ExecuteWorkflowResult)context.GetActivityOutput(() => executeWorkflow.Result)!;
Assert.NotNull(result);
Assert.Equal(subStatus, result.SubStatus);
}
private static WorkflowGraph CreateMockWorkflowGraph()
{
var writeLine = new WriteLine("Test") { Id = "test-activity" };
var workflow = Workflow.FromActivity(writeLine);
workflow.Id = "test-workflow";
var rootNode = new ActivityNode(writeLine, "Done");
return new(workflow, rootNode, [rootNode]);
}
private static IWorkflowDefinitionService CreateWorkflowDefinitionService(string workflowDefinitionId, WorkflowGraph workflowGraph)
{
var service = Substitute.For<IWorkflowDefinitionService>();
service
.FindWorkflowGraphAsync(workflowDefinitionId, Arg.Any<VersionOptions>(), Arg.Any<CancellationToken>())
.Returns(workflowGraph);
return service;
}
private static IIdentityGenerator CreateIdentityGenerator(string generatedId)
{
var generator = Substitute.For<IIdentityGenerator>();
generator.GenerateId().Returns(generatedId);
return generator;
}
private static IWorkflowInvoker CreateWorkflowInvoker(
WorkflowStatus status,
IDictionary<string, object>? output,
WorkflowSubStatus subStatus = WorkflowSubStatus.Executing)
{
var invoker = Substitute.For<IWorkflowInvoker>();
var workflow = Workflow.FromActivity(new WriteLine("Test"));
var workflowState = new WorkflowState
{
Status = status,
SubStatus = subStatus,
Output = output
};
var workflowResult = new RunWorkflowResult(null!, workflowState, workflow, null, Journal.Empty);
invoker
.InvokeAsync(Arg.Any<WorkflowGraph>(), Arg.Any<RunWorkflowOptions>(), Arg.Any<CancellationToken>())
.Returns(workflowResult);
return invoker;
}
private static async Task<(ActivityExecutionContext Context, IWorkflowInvoker WorkflowInvoker)>
ExecuteAsync(
ExecuteWorkflow executeWorkflow,
WorkflowStatus childWorkflowStatus = WorkflowStatus.Finished,
IDictionary<string, object>? output = null,
WorkflowSubStatus subStatus = WorkflowSubStatus.Executing,
IWorkflowDefinitionService? customWorkflowDefinitionService = null,
IWorkflowInvoker? customWorkflowInvoker = null,
IIdentityGenerator? customIdentityGenerator = null)
{
var workflowGraph = CreateMockWorkflowGraph();
var workflowDefinitionService = customWorkflowDefinitionService ?? CreateWorkflowDefinitionService(DefaultWorkflowDefinitionId, workflowGraph);
var identityGenerator = customIdentityGenerator ?? CreateIdentityGenerator(DefaultGeneratedId);
var workflowInvoker = customWorkflowInvoker ?? CreateWorkflowInvoker(childWorkflowStatus, output, subStatus);
var context = await new ActivityTestFixture(executeWorkflow)
.ConfigureServices(services =>
{
services.AddSingleton(workflowDefinitionService);
services.AddSingleton(workflowInvoker);
services.AddSingleton(identityGenerator);
services.AddSingleton<IStimulusHasher>(_ => Substitute.For<IStimulusHasher>());
})
.ExecuteAsync();
return (context, workflowInvoker);
}
}