diff --git a/doc/qa/test-guidelines.md b/doc/qa/test-guidelines.md
index de0b29cc1..81feb0519 100644
--- a/doc/qa/test-guidelines.md
+++ b/doc/qa/test-guidelines.md
@@ -160,6 +160,42 @@ public async Task Should_Schedule_Child_Activity()
}
```
+**Example (checking activity outcomes):**
+```csharp
+[Fact]
+public async Task Should_Return_Multiple_Outcomes()
+{
+ // Arrange
+ var flowFork = new FlowFork
+ {
+ Branches = new(new[] { "Branch1", "Branch2", "Branch3" })
+ };
+
+ // Act
+ var context = await new ActivityTestFixture(flowFork).ExecuteAsync();
+
+ // Assert - Check all outcomes
+ var outcomes = context.GetOutcomes().ToList();
+ Assert.Equal(3, outcomes.Count);
+ Assert.Contains("Branch1", outcomes);
+ Assert.Contains("Branch2", outcomes);
+ Assert.Contains("Branch3", outcomes);
+}
+
+[Fact]
+public async Task Should_Return_Default_Outcome()
+{
+ // Arrange
+ var flowSwitch = new FlowSwitch();
+
+ // Act
+ var context = await new ActivityTestFixture(flowSwitch).ExecuteAsync();
+
+ // Assert - Check single outcome
+ Assert.True(context.HasOutcome("Default"));
+}
+```
+
#### **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.
@@ -214,6 +250,8 @@ Assert.Equal(WorkflowStatus.Finished, resumed.WorkflowInstance.Status);
| `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 |
+| `context.GetOutcomes` | Get all outcomes from activity execution | Asserting multiple outcomes in unit tests |
+| `context.HasOutcome` | Check if activity has specific outcome | Asserting single outcome 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. Integration tests only |
diff --git a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs
index 25e8b3ce8..429ce6f99 100644
--- a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs
+++ b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs
@@ -35,11 +35,15 @@ public static class RunWorkflowExtensions
{
var workflowDefinitionService = services.GetRequiredService();
var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionId, versionOptions ?? VersionOptions.Published);
+
+ if (workflowGraph == null)
+ throw new InvalidOperationException($"Workflow definition with ID '{workflowDefinitionId}' not found.");
+
var workflowRuntime = services.GetRequiredService();
var workflowClient = await workflowRuntime.CreateClientAsync();
var response = await workflowClient.CreateAndRunInstanceAsync(new()
{
- WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowGraph!.Workflow.Identity.Id),
+ WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowGraph.Workflow.Identity.Id),
Input = input
});
diff --git a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs
index fc5f5bff3..3f9d03d9d 100644
--- a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs
+++ b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs
@@ -17,4 +17,25 @@ public static class ActivityExecutionContextExtensions
{
return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null;
}
+
+ ///
+ /// Retrieves the collection of outcomes recorded in the execution context's journal data.
+ ///
+ /// The activity execution context from which to retrieve the outcomes.
+ /// A collection of outcome strings, or an empty collection if no outcomes are present.
+ public static IEnumerable GetOutcomes(this ActivityExecutionContext activityExecutionContext)
+ {
+ return activityExecutionContext.JournalData.TryGetValue("Outcomes", out var outcomes) && outcomes is string[] arr ? arr : [];
+ }
+
+ ///
+ /// Determines whether the specified outcome exists in the current activity execution context.
+ ///
+ /// The activity execution context to check for the outcome.
+ /// The outcome to verify within the execution context.
+ /// True if the specified outcome exists; otherwise, false.
+ public static bool HasOutcome(this ActivityExecutionContext activityExecutionContext, string outcome)
+ {
+ return activityExecutionContext.GetOutcomes().Contains(outcome);
+ }
}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs
index 8812a63b2..44a525cb9 100644
--- a/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs
+++ b/src/common/Elsa.Testing.Shared/ActivityTestFixture.cs
@@ -83,6 +83,7 @@ public class ActivityTestFixture
// Set up variables and inputs, then execute the activity
await SetupExistingVariablesAsync(Activity, context);
await context.EvaluateInputPropertiesAsync();
+ context.TransitionTo(ActivityStatus.Running);
await Activity.ExecuteAsync(context);
return context;
@@ -167,4 +168,4 @@ public class ActivityTestFixture
services.AddSingleton();
services.AddSingleton();
}
-}
+}
\ No newline at end of file
diff --git a/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs b/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs
index 6a443629d..c1f38f9ae 100644
--- a/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs
+++ b/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs
@@ -75,6 +75,6 @@ public class ConfigureEngineWithVariablesAndInputOutputAccessors(IOptions)(() => context.GetOutput(activityOutput.ActivityId, outputName)));
+ engine.SetValue($"get{outputName}From{activityOutput.ActivityName.Pascalize()}", (Func)(() => context.GetOutput(activityOutput.ActivityId, outputName)));
}
}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj
index f3487fa1f..08c0420f9 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj
@@ -35,5 +35,32 @@
Always
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DependencyWorkflows/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DependencyWorkflows/Tests.cs
index 45b4a0dca..f90bd7c9f 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DependencyWorkflows/Tests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/DependencyWorkflows/Tests.cs
@@ -17,12 +17,13 @@ public class Tests
}
[Fact(DisplayName = "Workflows provided from JSON files that depend on other workflows are executed correctly.")]
- public async Task Test1()
+ public async Task Should_Execute_Multi_Level_Nested_Workflow_Definitions()
{
// Populate registries.
await _services.PopulateRegistriesAsync();
- // Run the "matter" workflow.
+ // Run the "matter" workflow which contains MoleculeActivity, which contains AtomActivity.
+ // Matter -> 3x Molecule -> each Molecule contains 3x Atom = 9 "Atom" outputs total
await _services.RunWorkflowUntilEndAsync("matter-workflow");
// Assert.
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Tests.cs
new file mode 100644
index 000000000..453054974
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Tests.cs
@@ -0,0 +1,41 @@
+using Elsa.Testing.Shared;
+using Xunit.Abstractions;
+
+namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionActivities;
+
+public class Tests
+{
+ private readonly CapturingTextWriter _capturingTextWriter = new();
+ private readonly IServiceProvider _services;
+
+ public Tests(ITestOutputHelper testOutputHelper)
+ {
+ _services = new TestApplicationBuilder(testOutputHelper)
+ .WithCapturingTextWriter(_capturingTextWriter)
+ .WithWorkflowsFromDirectory("Scenarios", "WorkflowDefinitionActivities", "Workflows")
+ .Build();
+ }
+
+ [Theory]
+ [MemberData(nameof(WorkflowDefinitionActivityTestCases))]
+ public async Task Should_Execute_WorkflowDefinitionActivity_Scenarios(string workflowDefinitionId, string expectedOutput)
+ {
+ // Populate registries.
+ await _services.PopulateRegistriesAsync();
+
+ // Run the workflow.
+ await _services.RunWorkflowUntilEndAsync(workflowDefinitionId);
+
+ // Assert - verify expected output appears.
+ var lines = _capturingTextWriter.Lines.ToList();
+ Assert.Contains(expectedOutput, lines);
+ }
+
+ public static IEnumerable WorkflowDefinitionActivityTestCases()
+ {
+ yield return ["parent-with-input", "Hello from parent!"];
+ yield return ["parent-with-output", "Received from child: Child output value"];
+ yield return ["parent-with-outcomes", "Success path taken"];
+ yield return ["parent-version-fallback", "Published version executed"];
+ }
+}
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-published-version.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-published-version.json
new file mode 100644
index 000000000..23cf02e78
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-published-version.json
@@ -0,0 +1,23 @@
+{
+ "id": "child-published-v1",
+ "definitionId": "child-published",
+ "name": "ChildPublished",
+ "version": 1,
+ "isLatest": true,
+ "isPublished": true,
+ "usableAsActivity": true,
+ "inputs": [],
+ "outputs": [],
+ "root": {
+ "type": "Elsa.WriteLine",
+ "version": 1,
+ "id": "WriteLine1",
+ "text": {
+ "typeName": "String",
+ "expression": {
+ "type": "Literal",
+ "value": "Published version executed"
+ }
+ }
+ }
+}
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-input.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-input.json
new file mode 100644
index 000000000..3b85bc4bf
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-input.json
@@ -0,0 +1,79 @@
+{
+ "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
+ "id": "e694d38a6d17f6ab",
+ "definitionId": "d0ae88e4c15eacc0",
+ "name": "ChildWithInput",
+ "createdAt": "2025-10-21T12:06:12.52497\u002B00:00",
+ "version": 1,
+ "toolVersion": "3.6.0.0",
+ "variables": [],
+ "inputs": [
+ {
+ "uiHint": "singleline",
+ "storageDriverType": "Elsa.Workflows.WorkflowInstanceStorageDriver, Elsa.Workflows.Core",
+ "type": "String",
+ "name": "Message",
+ "displayName": "Message",
+ "description": "The message to write",
+ "category": "Primitives"
+ }
+ ],
+ "outputs": [],
+ "outcomes": [],
+ "customProperties": {},
+ "isReadonly": false,
+ "isSystem": false,
+ "isLatest": true,
+ "isPublished": true,
+ "options": {
+ "usableAsActivity": true,
+ "autoUpdateConsumingWorkflows": true
+ },
+ "root": {
+ "id": "855226c76e9ad551",
+ "nodeId": "Workflow1:855226c76e9ad551",
+ "name": "Flowchart1",
+ "type": "Elsa.Flowchart",
+ "version": 1,
+ "customProperties": {
+ "notFoundConnections": [],
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {},
+ "activities": [
+ {
+ "text": {
+ "typeName": "String",
+ "expression": {
+ "type": "JavaScript",
+ "value": "getMessage()"
+ }
+ },
+ "id": "eff084a902159510",
+ "nodeId": "Workflow1:855226c76e9ad551:eff084a902159510",
+ "name": "WriteLine1",
+ "type": "Elsa.WriteLine",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {
+ "designer": {
+ "position": {
+ "x": -278.5,
+ "y": -178.5
+ },
+ "size": {
+ "width": 159.6171875,
+ "height": 67.9765625
+ }
+ }
+ }
+ }
+ ],
+ "variables": [],
+ "connections": []
+ }
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-outcomes.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-outcomes.json
new file mode 100644
index 000000000..6a4aeeba8
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-outcomes.json
@@ -0,0 +1,72 @@
+{
+ "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
+ "id": "85761829037332b7",
+ "definitionId": "7ee5c04453217e3f",
+ "name": "ChildWithOutcomes",
+ "createdAt": "2025-10-21T13:11:59.899105\u002B00:00",
+ "version": 2,
+ "toolVersion": "3.6.0.0",
+ "variables": [],
+ "inputs": [],
+ "outputs": [],
+ "outcomes": [
+ "Success",
+ "Failed"
+ ],
+ "customProperties": {},
+ "isReadonly": false,
+ "isSystem": false,
+ "isLatest": true,
+ "isPublished": true,
+ "options": {
+ "usableAsActivity": true,
+ "autoUpdateConsumingWorkflows": true
+ },
+ "root": {
+ "id": "3519519e32e50f38",
+ "nodeId": "Workflow1:3519519e32e50f38",
+ "name": "Flowchart1",
+ "type": "Elsa.Flowchart",
+ "version": 1,
+ "customProperties": {
+ "notFoundConnections": [],
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {},
+ "activities": [
+ {
+ "outcomes": {
+ "typeName": "Object",
+ "expression": {
+ "type": "Literal",
+ "value": "Success"
+ }
+ },
+ "id": "a093b294de68dc32",
+ "nodeId": "Workflow1:3519519e32e50f38:a093b294de68dc32",
+ "name": "Complete1",
+ "type": "Elsa.Complete",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {
+ "designer": {
+ "position": {
+ "x": -160.5,
+ "y": -143.5
+ },
+ "size": {
+ "width": 157.15625,
+ "height": 67.9765625
+ }
+ }
+ }
+ }
+ ],
+ "variables": [],
+ "connections": []
+ }
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-output.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-output.json
new file mode 100644
index 000000000..fab43afa4
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/child-with-output.json
@@ -0,0 +1,84 @@
+{
+ "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
+ "id": "4da8180d0c9f7e3f",
+ "definitionId": "5781ed56ea4b160",
+ "name": "ChildWithOutput",
+ "createdAt": "2025-10-21T12:15:08.404102\u002B00:00",
+ "version": 4,
+ "toolVersion": "3.6.0.0",
+ "variables": [],
+ "inputs": [],
+ "outputs": [
+ {
+ "type": "String",
+ "name": "Output",
+ "displayName": "Output",
+ "description": "The result value",
+ "category": "Primitives"
+ }
+ ],
+ "outcomes": [],
+ "customProperties": {},
+ "isReadonly": false,
+ "isSystem": false,
+ "isLatest": true,
+ "isPublished": true,
+ "options": {
+ "usableAsActivity": true,
+ "autoUpdateConsumingWorkflows": true
+ },
+ "root": {
+ "id": "14493561a73301af",
+ "nodeId": "Workflow1:14493561a73301af",
+ "name": "Flowchart1",
+ "type": "Elsa.Flowchart",
+ "version": 1,
+ "customProperties": {
+ "notFoundConnections": [],
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {},
+ "activities": [
+ {
+ "outputName": {
+ "typeName": "String",
+ "expression": {
+ "type": "Literal",
+ "value": "Output"
+ }
+ },
+ "outputValue": {
+ "typeName": "Object",
+ "expression": {
+ "type": "Literal",
+ "value": "Child output value"
+ }
+ },
+ "id": "461b5cebad79475c",
+ "nodeId": "Workflow1:14493561a73301af:461b5cebad79475c",
+ "name": "SetOutput1",
+ "type": "Elsa.SetOutput",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {
+ "designer": {
+ "position": {
+ "x": -353.5,
+ "y": -235.5
+ },
+ "size": {
+ "width": 132.3203125,
+ "height": 67.9765625
+ }
+ }
+ }
+ }
+ ],
+ "variables": [],
+ "connections": []
+ }
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-version-fallback.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-version-fallback.json
new file mode 100644
index 000000000..b750cb617
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-version-fallback.json
@@ -0,0 +1,23 @@
+{
+ "id": "parent-version-fallback-v1",
+ "definitionId": "parent-version-fallback",
+ "name": "ParentVersionFallback",
+ "version": 1,
+ "isLatest": true,
+ "isPublished": true,
+ "root": {
+ "type": "Elsa.Flowchart",
+ "version": 1,
+ "id": "Flowchart1",
+ "start": "ChildActivity1",
+ "activities": [
+ {
+ "type": "ChildPublished",
+ "version": 1,
+ "id": "ChildActivity1",
+ "workflowDefinitionId": "child-published",
+ "workflowDefinitionVersionId": "non-existent-version-id"
+ }
+ ]
+ }
+}
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-input.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-input.json
new file mode 100644
index 000000000..2293ba19f
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-input.json
@@ -0,0 +1,74 @@
+{
+ "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
+ "id": "70a093d85f846d05",
+ "definitionId": "parent-with-input",
+ "name": "ParentWithInput",
+ "createdAt": "2025-10-21T12:08:40.940992\u002B00:00",
+ "version": 4,
+ "toolVersion": "3.6.0.0",
+ "variables": [],
+ "inputs": [],
+ "outputs": [],
+ "outcomes": [],
+ "customProperties": {},
+ "isReadonly": false,
+ "isSystem": false,
+ "isLatest": true,
+ "isPublished": true,
+ "options": {
+ "autoUpdateConsumingWorkflows": false
+ },
+ "root": {
+ "id": "Flowchart1",
+ "nodeId": "Workflow2:Flowchart1",
+ "type": "Elsa.Flowchart",
+ "version": 1,
+ "customProperties": {
+ "notFoundConnections": [],
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {},
+ "activities": [
+ {
+ "workflowDefinitionId": "d0ae88e4c15eacc0",
+ "workflowDefinitionVersionId": "e694d38a6d17f6ab",
+ "latestAvailablePublishedVersion": 1,
+ "latestAvailablePublishedVersionId": "e694d38a6d17f6ab",
+ "id": "ChildActivity1",
+ "nodeId": "Workflow2:Flowchart1:ChildActivity1",
+ "name": null,
+ "type": "ChildWithInput",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {
+ "designer": {
+ "position": {
+ "x": -80,
+ "y": -40
+ },
+ "size": {
+ "width": 161.8515625,
+ "height": 67.9765625
+ }
+ }
+ },
+ "message": {
+ "typeName": "String",
+ "expression": {
+ "type": "Literal",
+ "value": "Hello from parent!"
+ },
+ "memoryReference": {
+ "id": "ChildActivity1:input-message"
+ }
+ }
+ }
+ ],
+ "variables": [],
+ "connections": []
+ }
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-io.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-io.json
new file mode 100644
index 000000000..526e7396b
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-io.json
@@ -0,0 +1,23 @@
+{
+ "id": "parent-with-io-v1",
+ "definitionId": "parent-with-io",
+ "name": "ParentWithIO",
+ "version": 1,
+ "toolVersion": "3.6.0.0",
+ "isLatest": true,
+ "isPublished": true,
+ "root": {
+ "type": "ChildWithInput",
+ "version": 1,
+ "id": "ChildActivity1",
+ "workflowDefinitionId": "child-with-input",
+ "workflowDefinitionVersionId": "child-with-input-v1",
+ "message": {
+ "typeName": "String",
+ "expression": {
+ "type": "Literal",
+ "value": "Test message"
+ }
+ }
+ }
+}
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-outcomes.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-outcomes.json
new file mode 100644
index 000000000..887f6f04e
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-outcomes.json
@@ -0,0 +1,147 @@
+{
+ "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
+ "id": "a09fb3bdfd316332",
+ "definitionId": "parent-with-outcomes",
+ "name": "ParentWithOutcomes",
+ "createdAt": "2025-10-21T13:11:28.283567\u002B00:00",
+ "version": 2,
+ "toolVersion": "3.6.0.0",
+ "variables": [],
+ "inputs": [],
+ "outputs": [],
+ "outcomes": [],
+ "customProperties": {},
+ "isReadonly": false,
+ "isSystem": false,
+ "isLatest": true,
+ "isPublished": true,
+ "options": {
+ "autoUpdateConsumingWorkflows": false
+ },
+ "root": {
+ "id": "Flowchart1",
+ "nodeId": "Workflow2:Flowchart1",
+ "type": "Elsa.Flowchart",
+ "version": 1,
+ "customProperties": {
+ "notFoundConnections": [],
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {},
+ "activities": [
+ {
+ "workflowDefinitionId": "7ee5c04453217e3f",
+ "workflowDefinitionVersionId": "85761829037332b7",
+ "latestAvailablePublishedVersion": 2,
+ "latestAvailablePublishedVersionId": "85761829037332b7",
+ "id": "ChildActivity1",
+ "nodeId": "Workflow2:Flowchart1:ChildActivity1",
+ "name": null,
+ "type": "ChildWithOutcomes",
+ "version": 2,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {
+ "designer": {
+ "position": {
+ "x": -140,
+ "y": 0
+ },
+ "size": {
+ "width": 198.7421875,
+ "height": 67.9765625
+ }
+ }
+ }
+ },
+ {
+ "text": {
+ "typeName": "String",
+ "expression": {
+ "type": "Literal",
+ "value": "Failed path taken"
+ }
+ },
+ "id": "WriteLineFailed",
+ "nodeId": "Workflow2:Flowchart1:WriteLineFailed",
+ "name": null,
+ "type": "Elsa.WriteLine",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {
+ "designer": {
+ "position": {
+ "x": 140,
+ "y": 80
+ },
+ "size": {
+ "width": 159.6171875,
+ "height": 67.9765625
+ }
+ }
+ }
+ },
+ {
+ "text": {
+ "typeName": "String",
+ "expression": {
+ "type": "Literal",
+ "value": "Success path taken"
+ }
+ },
+ "id": "WriteLineSuccess",
+ "nodeId": "Workflow2:Flowchart1:WriteLineSuccess",
+ "name": null,
+ "type": "Elsa.WriteLine",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {
+ "designer": {
+ "position": {
+ "x": 180,
+ "y": -220
+ },
+ "size": {
+ "width": 159.6171875,
+ "height": 67.9765625
+ }
+ }
+ }
+ }
+ ],
+ "variables": [],
+ "connections": [
+ {
+ "source": {
+ "activity": "ChildActivity1",
+ "port": "Success"
+ },
+ "target": {
+ "activity": "WriteLineSuccess",
+ "port": "In"
+ },
+ "vertices": []
+ },
+ {
+ "source": {
+ "activity": "ChildActivity1",
+ "port": "Failed"
+ },
+ "target": {
+ "activity": "WriteLineFailed",
+ "port": "In"
+ },
+ "vertices": []
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-output.json b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-output.json
new file mode 100644
index 000000000..e1c7dd555
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionActivities/Workflows/parent-with-output.json
@@ -0,0 +1,107 @@
+{
+ "$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
+ "id": "b1f4fc14b3c6b51",
+ "definitionId": "parent-with-output",
+ "name": "ParentWithOutput",
+ "createdAt": "2025-10-21T12:27:50.918094\u002B00:00",
+ "version": 1,
+ "toolVersion": "3.6.0.0",
+ "variables": [],
+ "inputs": [],
+ "outputs": [],
+ "outcomes": [],
+ "customProperties": {},
+ "isReadonly": false,
+ "isSystem": false,
+ "isLatest": true,
+ "isPublished": true,
+ "options": {
+ "autoUpdateConsumingWorkflows": false
+ },
+ "root": {
+ "id": "bcbe1e9e5a9c9b0e",
+ "nodeId": "Workflow2:bcbe1e9e5a9c9b0e",
+ "name": "Flowchart1",
+ "type": "Elsa.Flowchart",
+ "version": 1,
+ "customProperties": {
+ "notFoundConnections": [],
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {},
+ "activities": [
+ {
+ "workflowDefinitionId": "5781ed56ea4b160",
+ "workflowDefinitionVersionId": "4da8180d0c9f7e3f",
+ "latestAvailablePublishedVersion": 4,
+ "latestAvailablePublishedVersionId": "4da8180d0c9f7e3f",
+ "id": "c98383464dab2047",
+ "nodeId": "Workflow2:bcbe1e9e5a9c9b0e:c98383464dab2047",
+ "name": "ChildWithOutput1",
+ "type": "ChildWithOutput",
+ "version": 4,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {
+ "designer": {
+ "position": {
+ "x": -323.5,
+ "y": -85.5
+ },
+ "size": {
+ "width": 174.1640625,
+ "height": 67.9765625
+ }
+ }
+ }
+ },
+ {
+ "text": {
+ "typeName": "String",
+ "expression": {
+ "type": "JavaScript",
+ "value": "\u0060Received from child: ${getOutputFromChildWithOutput1()}\u0060"
+ }
+ },
+ "id": "b4f314dce11c7135",
+ "nodeId": "Workflow2:bcbe1e9e5a9c9b0e:b4f314dce11c7135",
+ "name": "WriteLine1",
+ "type": "Elsa.WriteLine",
+ "version": 1,
+ "customProperties": {
+ "canStartWorkflow": false,
+ "runAsynchronously": false
+ },
+ "metadata": {
+ "designer": {
+ "position": {
+ "x": -27.5,
+ "y": -85.5
+ },
+ "size": {
+ "width": 159.6171875,
+ "height": 67.9765625
+ }
+ }
+ }
+ }
+ ],
+ "variables": [],
+ "connections": [
+ {
+ "source": {
+ "activity": "c98383464dab2047",
+ "port": "Done"
+ },
+ "target": {
+ "activity": "b4f314dce11c7135",
+ "port": "In"
+ },
+ "vertices": []
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/test/unit/Elsa.Activities.UnitTests/Branching/FlowForkTests.cs b/test/unit/Elsa.Activities.UnitTests/Branching/FlowForkTests.cs
new file mode 100644
index 000000000..8d1f74580
--- /dev/null
+++ b/test/unit/Elsa.Activities.UnitTests/Branching/FlowForkTests.cs
@@ -0,0 +1,40 @@
+using Elsa.Testing.Shared;
+using Elsa.Workflows;
+using Elsa.Workflows.Activities.Flowchart.Activities;
+
+namespace Elsa.Activities.UnitTests.Branching;
+
+public class FlowForkTests
+{
+ [Theory]
+ [MemberData(nameof(BranchTestCases))]
+ public async Task Should_Complete_With_Specified_Branches(string[] branches, string[] expectedOutcomes)
+ {
+ // Arrange
+ var flowFork = new FlowFork();
+ if (branches.Length > 0)
+ flowFork.Branches = new(branches);
+
+ // Act
+ var context = await ExecuteAsync(flowFork);
+
+ // Assert - Activity should complete with all outcomes.
+ var outcomes = context.GetOutcomes().ToList();
+
+ Assert.Equal(expectedOutcomes.Length, outcomes.Count);
+ foreach (var expectedOutcome in expectedOutcomes)
+ Assert.Contains(expectedOutcome, outcomes);
+ }
+
+ public static IEnumerable BranchTestCases()
+ {
+ yield return [Array.Empty(), new[] { "Done" }];
+ yield return [new[] { "SingleBranch" }, new[] { "SingleBranch" }];
+ yield return [new[] { "Branch1", "Branch2", "Branch3" }, new[] { "Branch1", "Branch2", "Branch3" }];
+ }
+
+ private static Task ExecuteAsync(IActivity activity)
+ {
+ return new ActivityTestFixture(activity).ExecuteAsync();
+ }
+}
diff --git a/test/unit/Elsa.Activities.UnitTests/Branching/FlowSwitchTests.cs b/test/unit/Elsa.Activities.UnitTests/Branching/FlowSwitchTests.cs
new file mode 100644
index 000000000..bbaff3172
--- /dev/null
+++ b/test/unit/Elsa.Activities.UnitTests/Branching/FlowSwitchTests.cs
@@ -0,0 +1,99 @@
+using Elsa.Expressions.Models;
+using Elsa.Testing.Shared;
+using Elsa.Workflows;
+using Elsa.Workflows.Activities.Flowchart.Activities;
+using Elsa.Workflows.Activities.Flowchart.Models;
+
+namespace Elsa.Activities.UnitTests.Branching;
+
+public class FlowSwitchTests
+{
+ [Theory]
+ [MemberData(nameof(DefaultOutcomeTestCases))]
+ public async Task Should_Return_Default_When_No_Cases_Match(List cases)
+ {
+ // Arrange
+ var flowSwitch = new FlowSwitch { Cases = cases };
+
+ // Act
+ var context = await ExecuteAsync(flowSwitch);
+
+ // Assert - Activity should return Default outcome when no cases match.
+ Assert.True(context.HasOutcome("Default"));
+ }
+
+ [Theory]
+ [MemberData(nameof(SwitchModeTestCases))]
+ public async Task Should_Return_Outcomes_Based_On_Switch_Mode(
+ List cases,
+ SwitchMode mode,
+ string[] expectedOutcomes)
+ {
+ // Arrange
+ var flowSwitch = new FlowSwitch
+ {
+ Cases = cases,
+ Mode = new(mode)
+ };
+
+ // Act
+ var context = await ExecuteAsync(flowSwitch);
+
+ // Assert - Activity should return outcomes based on switch mode.
+ var outcomes = context.GetOutcomes().ToList();
+ Assert.Equal(expectedOutcomes.Length, outcomes.Count);
+ foreach (var expectedOutcome in expectedOutcomes)
+ Assert.Contains(expectedOutcome, outcomes);
+ }
+
+ [Fact]
+ public async Task Should_Evaluate_Cases_With_Literal_Expression()
+ {
+ // Arrange
+ var flowSwitch = new FlowSwitch
+ {
+ Cases = new List
+ {
+ new("TrueCase", Expression.LiteralExpression(true)),
+ new("FalseCase", Expression.LiteralExpression(false))
+ }
+ };
+
+ // Act
+ var context = await ExecuteAsync(flowSwitch);
+
+ // Assert - Activity should match the case with true literal.
+ Assert.True(context.HasOutcome("TrueCase"));
+ }
+
+ public static IEnumerable DefaultOutcomeTestCases()
+ {
+ yield return [new List()];
+ yield return
+ [
+ new List
+ {
+ new("Case1", () => false),
+ new("Case2", () => false)
+ }
+ ];
+ }
+
+ public static IEnumerable SwitchModeTestCases()
+ {
+ var cases = new List
+ {
+ new("Case1", () => false),
+ new("Case2", () => true),
+ new("Case3", () => true)
+ };
+
+ yield return [cases, SwitchMode.MatchFirst, new[] { "Case2" }];
+ yield return [cases, SwitchMode.MatchAny, new[] { "Case2", "Case3" }];
+ }
+
+ private static Task ExecuteAsync(IActivity activity)
+ {
+ return new ActivityTestFixture(activity).ExecuteAsync();
+ }
+}
diff --git a/test/unit/Elsa.Activities.UnitTests/Branching/SwitchTests.cs b/test/unit/Elsa.Activities.UnitTests/Branching/SwitchTests.cs
new file mode 100644
index 000000000..c0758bbe4
--- /dev/null
+++ b/test/unit/Elsa.Activities.UnitTests/Branching/SwitchTests.cs
@@ -0,0 +1,194 @@
+using Elsa.Expressions.Models;
+using Elsa.Testing.Shared;
+using Elsa.Workflows;
+using NSubstitute;
+
+namespace Elsa.Activities.UnitTests.Branching;
+
+public class SwitchTests
+{
+ [Theory]
+ [InlineData(SwitchMode.MatchFirst, true)]
+ [InlineData(SwitchMode.MatchAny, true)]
+ [InlineData(SwitchMode.MatchFirst, false)]
+ [InlineData(SwitchMode.MatchAny, false)]
+ public async Task Should_Handle_No_Matching_Cases_Correctly(SwitchMode mode, bool hasDefault)
+ {
+ // Arrange
+ var defaultActivity = hasDefault ? Substitute.For() : null;
+ var switchActivity = new Switch
+ {
+ Mode = new(mode),
+ Cases = new List
+ {
+ new("False Case", Expression.LiteralExpression(false), Substitute.For())
+ },
+ Default = defaultActivity
+ };
+
+ // Act
+ var context = await ExecuteAsync(switchActivity);
+
+ // Assert
+ var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
+ if (hasDefault)
+ {
+ Assert.Single(scheduledActivities);
+ Assert.Equal(defaultActivity, scheduledActivities.First().Activity);
+ }
+ else
+ {
+ Assert.Empty(scheduledActivities);
+ }
+ }
+
+ [Theory]
+ [InlineData(SwitchMode.MatchFirst, 1)]
+ [InlineData(SwitchMode.MatchAny, 2)]
+ public async Task Should_Handle_Multiple_Matching_Cases_According_To_Mode(SwitchMode mode, int expectedScheduledCount)
+ {
+ // Arrange
+ var firstTrueActivity = Substitute.For();
+ var secondTrueActivity = Substitute.For();
+ var switchActivity = new Switch
+ {
+ Mode = new(mode),
+ Cases = new List
+ {
+ new("False", Expression.LiteralExpression(false), Substitute.For()),
+ new("First True", Expression.LiteralExpression(true), firstTrueActivity),
+ new("Second True", Expression.LiteralExpression(true), secondTrueActivity)
+ }
+ };
+
+ // Act
+ var context = await ExecuteAsync(switchActivity);
+
+ // Assert
+ var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
+ Assert.Equal(expectedScheduledCount, scheduledActivities.Count);
+
+ if (mode == SwitchMode.MatchFirst)
+ {
+ Assert.Equal(firstTrueActivity, scheduledActivities.First().Activity);
+ }
+ else // MatchAny
+ {
+ Assert.Contains(scheduledActivities, s => s.Activity == firstTrueActivity);
+ Assert.Contains(scheduledActivities, s => s.Activity == secondTrueActivity);
+ }
+ }
+
+ [Fact]
+ public async Task Should_Use_MatchFirst_As_Default_Mode()
+ {
+ // Arrange
+ var firstTrueActivity = Substitute.For();
+ var secondTrueActivity = Substitute.For();
+ var switchActivity = new Switch
+ {
+ // No Mode explicitly set - should default to MatchFirst
+ Cases = new List
+ {
+ new("First True", Expression.LiteralExpression(true), firstTrueActivity),
+ new("Second True", Expression.LiteralExpression(true), secondTrueActivity)
+ }
+ };
+
+ // Act
+ var context = await ExecuteAsync(switchActivity);
+
+ // Assert
+ var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
+ Assert.Single(scheduledActivities);
+ Assert.Equal(firstTrueActivity, scheduledActivities.First().Activity);
+ }
+
+ [Theory]
+ [InlineData(SwitchMode.MatchFirst)]
+ [InlineData(SwitchMode.MatchAny)]
+ public async Task Should_Schedule_Default_When_Null_Case_Condition_Evaluates_False(SwitchMode mode)
+ {
+ // Arrange
+ var defaultActivity = Substitute.For();
+ var switchActivity = new Switch
+ {
+ Mode = new(mode),
+ Cases = new List
+ {
+ new("Null condition", Expression.LiteralExpression(null), Substitute.For())
+ },
+ Default = defaultActivity
+ };
+
+ // Act
+ var context = await ExecuteAsync(switchActivity);
+
+ // Assert
+ var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
+ Assert.Single(scheduledActivities);
+ Assert.Equal(defaultActivity, scheduledActivities.First().Activity);
+ }
+
+ [Theory]
+ [InlineData(SwitchMode.MatchFirst, true)]
+ [InlineData(SwitchMode.MatchAny, true)]
+ [InlineData(SwitchMode.MatchFirst, false)]
+ [InlineData(SwitchMode.MatchAny, false)]
+ public async Task Should_Handle_Empty_Cases_Correctly(SwitchMode mode, bool hasDefault)
+ {
+ // Arrange
+ var defaultActivity = hasDefault ? Substitute.For() : null;
+ var switchActivity = new Switch
+ {
+ Mode = new(mode),
+ Cases = new List(),
+ Default = defaultActivity
+ };
+
+ // Act
+ var context = await ExecuteAsync(switchActivity);
+
+ // Assert
+ var scheduledActivities = context.WorkflowExecutionContext.Scheduler.List().ToList();
+ if (hasDefault)
+ {
+ Assert.Single(scheduledActivities);
+ Assert.Equal(defaultActivity, scheduledActivities.First().Activity);
+ }
+ else
+ {
+ Assert.Empty(scheduledActivities);
+ }
+ }
+
+ [Fact]
+ public void Should_Initialize_Cases_Collection_By_Default()
+ {
+ // Arrange & Act
+ var switchActivity = new Switch();
+
+ // Assert
+ Assert.NotNull(switchActivity.Cases);
+ Assert.Empty(switchActivity.Cases);
+
+ switchActivity.Cases.Add(new("Test", Expression.LiteralExpression(true), Substitute.For()));
+ Assert.Single(switchActivity.Cases);
+ }
+
+ [Fact]
+ public void Should_Initialize_Mode_To_MatchFirst_By_Default()
+ {
+ // Arrange
+ var switchActivity = new Switch();
+
+ // Assert
+ Assert.NotNull(switchActivity.Mode);
+ // The actual default value verification is handled by the mode-specific behavior tests
+ }
+
+ private static Task ExecuteAsync(IActivity activity)
+ {
+ return new ActivityTestFixture(activity).ExecuteAsync();
+ }
+}
diff --git a/test/unit/Elsa.Activities.UnitTests/Http/FlowSendHttpRequestTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/FlowSendHttpRequestTests.cs
new file mode 100644
index 000000000..5dfa4a3f4
--- /dev/null
+++ b/test/unit/Elsa.Activities.UnitTests/Http/FlowSendHttpRequestTests.cs
@@ -0,0 +1,206 @@
+using System.Net;
+using Elsa.Activities.UnitTests.Http.Helpers;
+using Elsa.Extensions;
+using Elsa.Http;
+using Elsa.Testing.Shared;
+using Elsa.Workflows;
+
+namespace Elsa.Activities.UnitTests.Http;
+
+public class FlowSendHttpRequestTests
+{
+ [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_Set_Status_Code_Output(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 SendHttpRequestTestHelpers.RequestCapture();
+ var responseHandler = SendHttpRequestTestHelpers.CreateResponseHandler(expectedHttpStatusCode, jsonResponse, requestCapture);
+ var flowSendHttpRequest = CreateFlowSendHttpRequest(expectedUrl, method);
+
+ // Act
+ var context = await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
+
+ // Assert
+ Assert.NotNull(requestCapture.CapturedRequest);
+ Assert.Equal(expectedMethod, requestCapture.CapturedRequest.Method);
+ Assert.Equal(expectedUrl, requestCapture.CapturedRequest.RequestUri);
+
+ var statusCodeOutput = context.GetActivityOutput(() => flowSendHttpRequest.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 SendHttpRequestTestHelpers.RequestCapture();
+ var responseHandler = SendHttpRequestTestHelpers.CreateResponseHandler(HttpStatusCode.OK, null, requestCapture);
+ var flowSendHttpRequest = CreateFlowSendHttpRequest(expectedUrl, authorization: authorizationHeader);
+
+ // Act
+ await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
+
+ // Assert
+ Assert.NotNull(requestCapture.CapturedRequest);
+ Assert.NotNull(requestCapture.CapturedRequest.Headers.Authorization);
+ Assert.Equal(authorizationHeader, requestCapture.CapturedRequest.Headers.Authorization.ToString());
+ }
+
+ [Theory]
+ [MemberData(nameof(StatusCodeOutcomeTestCases))]
+ public async Task Should_Return_Outcome_Based_On_Status_Code(
+ int[] expectedStatusCodes,
+ HttpStatusCode actualStatusCode,
+ string[] expectedOutcomes)
+ {
+ // Arrange
+ var flowSendHttpRequest = CreateFlowSendHttpRequest(
+ new("https://api.example.com/test"),
+ expectedStatusCodes: expectedStatusCodes);
+
+ var responseHandler = SendHttpRequestTestHelpers.CreateResponseHandler(actualStatusCode);
+
+ // Act
+ var context = await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
+
+ // Assert - Activity should return outcomes based on status code match.
+ var outcomes = context.GetOutcomes().ToList();
+ Assert.Equal(expectedOutcomes.Length, outcomes.Count);
+ foreach (var expectedOutcome in expectedOutcomes)
+ Assert.Contains(expectedOutcome, outcomes);
+ }
+
+ public static IEnumerable StatusCodeOutcomeTestCases()
+ {
+ // Expected status codes: 200, 404
+ // Status code matches - returns status code + Done
+ yield return [new[] { 200, 404 }, HttpStatusCode.OK, new[] { "200", "Done" }];
+ yield return [new[] { 200, 404 }, HttpStatusCode.NotFound, new[] { "404", "Done" }];
+
+ // Status code doesn't match - returns "Unmatched status code" + Done
+ yield return [new[] { 200, 404 }, HttpStatusCode.InternalServerError, new[] { "Unmatched status code", "Done" }];
+
+ // No expected status codes - returns only Done
+ yield return [Array.Empty(), HttpStatusCode.OK, new[] { "Done" }];
+ }
+
+ [Fact]
+ public async Task Should_Return_FailedToConnect_Outcome_On_HttpRequestException()
+ {
+ // Arrange
+ var flowSendHttpRequest = CreateFlowSendHttpRequest(new("https://api.example.com/error"));
+ var responseHandler = SendHttpRequestTestHelpers.CreateExceptionHandler("Connection failed");
+
+ // Act
+ var context = await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
+
+ // Assert
+ Assert.True(context.HasOutcome("Failed to connect"));
+ }
+
+ [Fact]
+ public async Task Should_Return_Timeout_Outcome_On_TaskCanceledException()
+ {
+ // Arrange
+ var flowSendHttpRequest = CreateFlowSendHttpRequest(new("https://api.example.com/timeout"));
+ var responseHandler = SendHttpRequestTestHelpers.CreateExceptionHandler("Request timed out");
+
+ // Act
+ var context = await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
+
+ // Assert
+ Assert.True(context.HasOutcome("Timeout"));
+ }
+
+ [Fact]
+ public async Task Should_Set_Response_Headers_Output()
+ {
+ // Arrange
+ var expectedHeaders = new Dictionary
+ {
+ { "Custom-Header", "CustomValue" },
+ { "X-Rate-Limit", "100" }
+ };
+
+ var responseHandler = SendHttpRequestTestHelpers.CreateResponseHandler(HttpStatusCode.OK, additionalHeaders: expectedHeaders);
+ var flowSendHttpRequest = CreateFlowSendHttpRequest(new("https://api.example.com/headers"));
+
+ // Act
+ var context = await ExecuteActivityAsync(flowSendHttpRequest, responseHandler);
+
+ // Assert
+ var responseHeadersObj = context.GetActivityOutput(() => flowSendHttpRequest.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 FlowSendHttpRequest());
+ fixture.AssertActivityAttributes(
+ expectedNamespace: "Elsa",
+ expectedCategory: "HTTP",
+ expectedDisplayName: "HTTP Request (flow)",
+ expectedDescription: "Send an HTTP request.",
+ expectedKind: ActivityKind.Task
+ );
+ }
+
+ [Fact]
+ public void Should_Have_Default_Expected_Status_Codes()
+ {
+ // Arrange
+ var flowSendHttpRequest = new FlowSendHttpRequest();
+
+ // Act - Get the default value
+ var defaultValue = ((IActivityPropertyDefaultValueProvider)flowSendHttpRequest)
+ .GetDefaultValue(typeof(FlowSendHttpRequest).GetProperty(nameof(FlowSendHttpRequest.ExpectedStatusCodes))!);
+
+ // Assert
+ var defaultStatusCodes = defaultValue as List;
+ Assert.NotNull(defaultStatusCodes);
+ Assert.Single(defaultStatusCodes);
+ Assert.Equal(200, defaultStatusCodes.First());
+ }
+
+ // Private helper methods
+ private static FlowSendHttpRequest CreateFlowSendHttpRequest(
+ Uri url,
+ string method = "GET",
+ object? content = null,
+ string? contentType = null,
+ string? authorization = null,
+ int[]? expectedStatusCodes = null)
+ {
+ return new()
+ {
+ Url = new(url),
+ Method = new(method),
+ Content = content != null ? new Input(content) : null!,
+ ContentType = contentType != null ? new Input(contentType) : null!,
+ Authorization = authorization != null ? new Input(authorization) : null!,
+ ExpectedStatusCodes = expectedStatusCodes != null ? new Input>(expectedStatusCodes) : null!
+ };
+ }
+
+ private static Task ExecuteActivityAsync(
+ FlowSendHttpRequest flowSendHttpRequest,
+ Func> responseHandler)
+ {
+ return new ActivityTestFixture(flowSendHttpRequest).WithHttpServices(responseHandler).ExecuteAsync();
+ }
+}
diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/ActivityTestFixtureHttpExtensions.cs
similarity index 99%
rename from test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs
rename to test/unit/Elsa.Activities.UnitTests/Http/Helpers/ActivityTestFixtureHttpExtensions.cs
index ea6b8de4f..0cc07b367 100644
--- a/test/unit/Elsa.Activities.UnitTests/Helpers/ActivityTestFixtureHttpExtensions.cs
+++ b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/ActivityTestFixtureHttpExtensions.cs
@@ -9,7 +9,7 @@ using Elsa.Workflows;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
-namespace Elsa.Activities.UnitTests.Helpers;
+namespace Elsa.Activities.UnitTests.Http.Helpers;
///
/// Extension methods for configuring HTTP-related services in ActivityTestFixture.
diff --git a/test/unit/Elsa.Activities.UnitTests/Http/Helpers/SendHttpRequestTestHelpers.cs b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/SendHttpRequestTestHelpers.cs
new file mode 100644
index 000000000..f654201ac
--- /dev/null
+++ b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/SendHttpRequestTestHelpers.cs
@@ -0,0 +1,43 @@
+using System.Net;
+
+namespace Elsa.Activities.UnitTests.Http.Helpers;
+
+///
+/// Shared helper methods for testing SendHttpRequest and FlowSendHttpRequest activities.
+///
+public static class SendHttpRequestTestHelpers
+{
+ ///
+ /// Creates a response handler that returns a specific HTTP status code and optional content.
+ ///
+ public static Func> CreateResponseHandler(
+ HttpStatusCode statusCode,
+ string? content = null,
+ RequestCapture? requestCapture = null,
+ Dictionary? additionalHeaders = null)
+ {
+ return (request, _) =>
+ {
+ if (requestCapture != null)
+ requestCapture.CapturedRequest = request;
+ return Task.FromResult(ActivityTestFixtureHttpExtensions.CreateHttpResponse(statusCode, content, additionalHeaders));
+ };
+ }
+
+ ///
+ /// Creates an exception handler that throws a specific exception type with a message.
+ ///
+ public static Func> CreateExceptionHandler(string message)
+ where TException : Exception
+ {
+ return (_, _) => throw ((TException)Activator.CreateInstance(typeof(TException), message)!);
+ }
+
+ ///
+ /// Captures HTTP request details during test execution.
+ ///
+ public sealed class RequestCapture
+ {
+ public HttpRequestMessage? CapturedRequest { get; set; }
+ }
+}
diff --git a/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/TestHttpMessageHandler.cs
similarity index 93%
rename from test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs
rename to test/unit/Elsa.Activities.UnitTests/Http/Helpers/TestHttpMessageHandler.cs
index 0b9f1843d..2eb0fbfbb 100644
--- a/test/unit/Elsa.Activities.UnitTests/Helpers/TestHttpMessageHandler.cs
+++ b/test/unit/Elsa.Activities.UnitTests/Http/Helpers/TestHttpMessageHandler.cs
@@ -1,4 +1,4 @@
-namespace Elsa.Activities.UnitTests.Helpers;
+namespace Elsa.Activities.UnitTests.Http.Helpers;
///
/// Custom test HTTP message handler that allows full control over HTTP responses for testing.
diff --git a/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs
index d14199041..0b032f7a2 100644
--- a/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs
+++ b/test/unit/Elsa.Activities.UnitTests/Http/SendHttpRequestTests.cs
@@ -1,6 +1,5 @@
using System.Net;
-using System.Text;
-using Elsa.Activities.UnitTests.Helpers;
+using Elsa.Activities.UnitTests.Http.Helpers;
using Elsa.Extensions;
using Elsa.Http;
using Elsa.Testing.Shared;