diff --git a/doc/qa/test-guidelines.md b/doc/qa/test-guidelines.md index 1fdf07a69..622e4240e 100644 --- a/doc/qa/test-guidelines.md +++ b/doc/qa/test-guidelines.md @@ -204,7 +204,7 @@ public async Task Should_Return_Default_Outcome() Pattern note: [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) returns a [`RunWorkflowResult`](../../src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs) (or equivalent) containing the [`WorkflowInstance`](../../src/modules/Elsa.Workflows.Management/Entities/WorkflowInstance.cs) and output variables when run to completion. Use returned state for deterministic assertions where possible. -**Example (using WorkflowTestFixture):** +**Example (using WorkflowTestFixture - basic):** ```csharp public class RunJavaScriptTests { @@ -215,18 +215,50 @@ public class RunJavaScriptTests _fixture = new WorkflowTestFixture(testOutputHelper); } - [Fact(DisplayName = "RunJavaScript should execute simple arithmetic script")] - public async Task Should_Execute_Simple_Script() + [Fact(DisplayName = "RunJavaScript should execute and return output")] + public async Task Should_Execute_And_Return_Output() { // Arrange var script = "return 1 + 1;"; var activity = new RunJavaScript { Script = new(script), Result = new() }; // Act - await _fixture.RunActivityAsync(activity); + var result = await _fixture.RunActivityAsync(activity); - // Assert - workflow completes successfully - Assert.Empty(_fixture.CapturingTextWriter.Lines); + // Assert - activity produced expected output + var output = result.GetActivityOutput(activity); + Assert.Equal(2, output); + } + + [Fact(DisplayName = "RunJavaScript should set outcomes")] + public async Task Should_Set_Outcomes() + { + // Arrange + var script = "setOutcomes(['Branch1', 'Branch2']);"; + var activity = new RunJavaScript { Script = new(script) }; + + // Act + var result = await _fixture.RunActivityAsync(activity); + + // Assert - activity produced expected outcomes + var outcomes = _fixture.GetOutcomes(result, activity); + Assert.Contains("Branch1", outcomes); + Assert.Contains("Branch2", outcomes); + } + + [Fact(DisplayName = "RunJavaScript should fault on invalid syntax")] + public async Task Should_Fault_On_Invalid_Syntax() + { + // Arrange + var script = "this is not valid javascript"; + var activity = new RunJavaScript { Script = new(script) }; + + // Act + var result = await _fixture.RunActivityAsync(activity); + + // Assert - activity should be in faulted state + var status = _fixture.GetActivityStatus(result, activity); + Assert.Equal(ActivityStatus.Faulted, status); } } ``` @@ -301,6 +333,9 @@ Assert.Equal(WorkflowStatus.Finished, resumed.WorkflowInstance.Status); | `WorkflowTestFixture.RunActivityAsync` | Run single activity as workflow | Integration tests for single activities | | `WorkflowTestFixture.RunWorkflowAsync` | Run workflow or workflow by definition ID | Integration tests for workflows | | `WorkflowTestFixture.CapturingTextWriter` | Capture WriteLine output | Asserting text output in integration tests | +| `WorkflowTestFixture.GetOutcomes` | Get all outcomes from specific activity | Asserting activity outcomes in integration tests | +| `WorkflowTestFixture.HasOutcome` | Check if activity produced specific outcome | Asserting single outcome in integration tests | +| `WorkflowTestFixture.GetActivityStatus` | Get execution status of specific activity | Asserting activity status (Faulted, Completed, etc.) | | `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 | @@ -356,7 +391,58 @@ When in doubt, add the minimal unit tests plus one integration test that reprodu --- +## WorkflowTestFixture Helper Methods + +When using [`WorkflowTestFixture`](../../src/common/Elsa.Testing.Shared.Integration/WorkflowTestFixture.cs) for integration tests, use these helper methods to assert on activity behavior: + +### Asserting Activity Outputs + +Use `result.GetActivityOutput(activity)` to retrieve the output value from an activity: + +```csharp +var result = await _fixture.RunActivityAsync(activity); +var output = result.GetActivityOutput(activity); +Assert.Equal(expectedValue, output); +``` + +### Asserting Activity Outcomes + +Use `_fixture.GetOutcomes(result, activity)` to retrieve all outcomes produced by an activity: + +```csharp +var result = await _fixture.RunActivityAsync(activity); +var outcomes = _fixture.GetOutcomes(result, activity); +Assert.Contains("ExpectedOutcome", outcomes); +``` + +Or use `_fixture.HasOutcome(result, activity, outcome)` to check for a specific outcome: + +```csharp +var result = await _fixture.RunActivityAsync(activity); +Assert.True(_fixture.HasOutcome(result, activity, "Success")); +``` + +### Asserting Activity Status + +Use `_fixture.GetActivityStatus(result, activity)` to check the execution status of an activity: + +```csharp +var result = await _fixture.RunActivityAsync(activity); +var status = _fixture.GetActivityStatus(result, activity); +Assert.Equal(ActivityStatus.Faulted, status); +``` + +**Available activity statuses:** +- `ActivityStatus.Pending` - Activity is pending execution +- `ActivityStatus.Running` - Activity is currently running +- `ActivityStatus.Completed` - Activity completed successfully +- `ActivityStatus.Canceled` - Activity was canceled +- `ActivityStatus.Faulted` - Activity encountered an error + +--- + ## Failure testing (faults & incidents) +- **Integration test faulted activities**: Use `_fixture.GetActivityStatus(result, activity)` to assert that a specific activity faulted, rather than checking workflow-level status. - **Integration test faulted workflows**: build a workflow that throws and run via [`RunAsync`](../../src/modules/Elsa.Workflows.Core/Contracts/IWorkflowRunner.cs) — assert [`WorkflowInstance.Status`](../../src/modules/Elsa.Workflows.Management/Entities/WorkflowInstance.cs) == [`Faulted`](../../src/modules/Elsa.Workflows.Core/Enums/WorkflowStatus.cs) on the returned state or via [`IWorkflowInstanceStore`](../../src/modules/Elsa.Workflows.Management/Contracts/IWorkflowInstanceStore.cs). - **Component tests for recovery/resume**: persist a faulted instance (or cause a host restart scenario), run your recovery logic, and assert the final state. diff --git a/src/common/Elsa.Testing.Shared.Integration/WorkflowTestFixture.cs b/src/common/Elsa.Testing.Shared.Integration/WorkflowTestFixture.cs index 8bf03ab4c..63f3cede3 100644 --- a/src/common/Elsa.Testing.Shared.Integration/WorkflowTestFixture.cs +++ b/src/common/Elsa.Testing.Shared.Integration/WorkflowTestFixture.cs @@ -152,4 +152,44 @@ public class WorkflowTestFixture return await Services.RunWorkflowUntilEndAsync(definitionId, input); } + + /// + /// Gets the outcomes produced by a specific activity from the workflow result. + /// + /// The workflow run result + /// The activity to get outcomes for + /// Collection of outcome names + public IEnumerable GetOutcomes(RunWorkflowResult result, IActivity activity) + { + var activityContext = result.Journal.ActivityExecutionContexts + .FirstOrDefault(c => c.Activity.Id == activity.Id); + + return activityContext?.GetOutcomes() ?? []; + } + + /// + /// Checks if a specific activity produced a specific outcome. + /// + /// The workflow run result + /// The activity to check + /// The outcome name to check for + /// True if the activity produced the specified outcome + public bool HasOutcome(RunWorkflowResult result, IActivity activity, string outcome) + { + return GetOutcomes(result, activity).Contains(outcome); + } + + /// + /// Gets the execution status of a specific activity from the workflow result. + /// + /// The workflow run result + /// The activity to get status for + /// The activity status, or null if the activity wasn't found in the journal + public ActivityStatus? GetActivityStatus(RunWorkflowResult result, IActivity activity) + { + var activityContext = result.Journal.ActivityExecutionContexts + .FirstOrDefault(c => c.Activity.Id == activity.Id); + + return activityContext?.Status; + } } diff --git a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionResultExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionResultExtensions.cs index 3103ed1a6..f738d49da 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionResultExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionResultExtensions.cs @@ -7,6 +7,36 @@ public static class WorkflowExecutionResultExtensions { public static T GetActivityOutput(this RunWorkflowResult result, IActivity activity, string? outputName = null) { - return (T)result.WorkflowExecutionContext.GetOutputByActivityId(activity.Id, outputName)!; + var value = result.WorkflowExecutionContext.GetOutputByActivityId(activity.Id, outputName); + + // If the value is already of the requested type, return it directly. + if (value is T tValue) + return tValue; + + // Handle nulls for reference/nullable types. + if (value is null) + { + // If T is a reference type or nullable, default(T) is fine. + // Otherwise, this is a runtime error. + return default(T) is null ? default! : throw new InvalidCastException($"Cannot convert null to non-nullable type {typeof(T).FullName}."); + } + + // Try to convert using System.Convert when possible (handles numeric casts like Double -> Int32). + try + { + var targetType = typeof(T); + + // Unwrap nullable to its underlying type for conversion. + var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + var converted = Convert.ChangeType(value, underlyingType, System.Globalization.CultureInfo.InvariantCulture); + + // If T is nullable and we converted to the underlying type, just cast. + return (T)converted!; + } + catch (Exception ex) + { + throw new InvalidCastException($"Unable to convert value of type '{value.GetType().FullName}' to type '{typeof(T).FullName}'.", ex); + } } } \ No newline at end of file diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/RunJavaScriptTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/RunJavaScriptTests.cs index 0e3ca8281..c3d605430 100644 --- a/test/integration/Elsa.JavaScript.IntegrationTests/RunJavaScriptTests.cs +++ b/test/integration/Elsa.JavaScript.IntegrationTests/RunJavaScriptTests.cs @@ -1,5 +1,6 @@ using Elsa.Expressions.JavaScript.Activities; using Elsa.Testing.Shared; +using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Memory; using Xunit.Abstractions; @@ -11,49 +12,56 @@ public class RunJavaScriptTests(ITestOutputHelper testOutputHelper) private readonly WorkflowTestFixture _fixture = new(testOutputHelper); [Theory(DisplayName = "RunJavaScript should execute valid scripts successfully")] - [InlineData("return 1 + 1;")] - [InlineData("return 'Hello World';")] - [InlineData("return 42;")] - public async Task Should_Execute_Valid_Scripts(string script) + [InlineData("return 1 + 1;", 2d)] + [InlineData("return 'Hello World';", "Hello World")] + [InlineData("return 42;", 42d)] + public async Task Should_Execute_Valid_Scripts(string script, object expectedOutput) { // Arrange var activity = new RunJavaScript { Script = new(script), Result = new() }; // Act - await _fixture.RunActivityAsync(activity); + var result = await _fixture.RunActivityAsync(activity); - // Assert - Assert.Empty(_fixture.CapturingTextWriter.Lines); + // Assert - script returns expected value + var output = result.GetActivityOutput(activity); + Assert.Equal(expectedOutput, output); } [Theory(DisplayName = "RunJavaScript should set outcomes correctly")] - [InlineData("setOutcome('Success');")] - [InlineData("setOutcomes(['Branch1', 'Branch2', 'Branch3']);")] - public async Task Should_Set_Outcomes(string script) + [InlineData("setOutcome('Success');", new[] { "Success" })] + [InlineData("setOutcomes(['Branch1', 'Branch2', 'Branch3']);", new[] { "Branch1", "Branch2", "Branch3" })] + public async Task Should_Set_Outcomes(string script, string[] expectedOutcomes) { // Arrange var activity = new RunJavaScript { Script = new(script) }; // Act - await _fixture.RunActivityAsync(activity); + var result = await _fixture.RunActivityAsync(activity); - // Assert - Assert.Null(_fixture.CapturingTextWriter.Lines.FirstOrDefault()); + // Assert - activity produced expected outcomes + var outcomes = _fixture.GetOutcomes(result, activity).ToArray(); + Assert.Equal(expectedOutcomes.Length, outcomes.Length); + foreach (var expectedOutcome in expectedOutcomes) + { + Assert.Contains(expectedOutcome, outcomes); + } } - [Theory(DisplayName = "RunJavaScript should not execute invalid scripts")] + [Theory(DisplayName = "RunJavaScript should produce null output for empty or whitespace scripts")] [InlineData("")] [InlineData(" ")] - public async Task Should_Not_Execute_Invalid_Scripts(string script) + public async Task Should_Produce_Null_Output_For_Empty_Scripts(string script) { // Arrange var activity = new RunJavaScript { Script = new(script), Result = new() }; // Act - await _fixture.RunActivityAsync(activity); + var result = await _fixture.RunActivityAsync(activity); - // Assert - Assert.Empty(_fixture.CapturingTextWriter.Lines); + // Assert - empty/whitespace scripts produce no output + var output = result.GetActivityOutput(activity); + Assert.Null(output); } [Fact(DisplayName = "RunJavaScript should access workflow variables")] @@ -62,17 +70,19 @@ public class RunJavaScriptTests(ITestOutputHelper testOutputHelper) // Arrange var myVar = new Variable("MyVar", 100); var script = "return getMyVar();"; + var runJavaScript = new RunJavaScript { Script = new(script), Result = new() }; var workflow = new Workflow { - Root = new RunJavaScript { Script = new(script), Result = new() }, + Root = runJavaScript, Variables = { myVar } }; // Act - await _fixture.RunActivityAsync(workflow); + var result = await _fixture.RunActivityAsync(workflow); - // Assert - workflow completes successfully - Assert.Empty(_fixture.CapturingTextWriter.Lines); + // Assert - variable was accessed and returned + var output = result.GetActivityOutput(runJavaScript); + Assert.Equal(100, output); } [Fact(DisplayName = "RunJavaScript should execute complex script with multiple statements and outcomes")] @@ -93,9 +103,27 @@ public class RunJavaScriptTests(ITestOutputHelper testOutputHelper) var activity = new RunJavaScript { Script = new(script), Result = new() }; // Act - await _fixture.RunActivityAsync(activity); + var result = await _fixture.RunActivityAsync(activity); - // Assert - workflow completes successfully - Assert.Empty(_fixture.CapturingTextWriter.Lines); + // Assert - script returns calculated sum + var output = result.GetActivityOutput(activity); + Assert.Equal(30, output); + } + + [Theory(DisplayName = "RunJavaScript should fault on invalid JavaScript syntax")] + [InlineData("this is not valid javascript")] + [InlineData("return unclosedBracket(;")] + [InlineData("undefined.property.access")] + public async Task Should_Fault_On_Invalid_JavaScript(string script) + { + // Arrange + var activity = new RunJavaScript { Script = new(script), Result = new() }; + + // Act + var result = await _fixture.RunActivityAsync(activity); + + // Assert - activity should be in faulted state + var activityStatus = _fixture.GetActivityStatus(result, activity); + Assert.Equal(ActivityStatus.Faulted, activityStatus); } }