diff --git a/doc/wiki/bpmn-workflows.md b/doc/wiki/bpmn-workflows.md index eb1283eea..d1c326631 100644 --- a/doc/wiki/bpmn-workflows.md +++ b/doc/wiki/bpmn-workflows.md @@ -28,7 +28,7 @@ elsa.AddBpmnInterchange(); - **`Activities`** — the Elsa activities bound to this scope (one per `BpmnWorkBinding`). - **`IsRootScope`** — left `false` on every scope the binder produces; the caller sets it to `true` to mark the outermost scope as a workflow entry point. -`BpmnProcess` completes with the interpreter's outcome name (e.g. `BpmnInterpreter.DoneOutcomeName`). It does **not** complete with `Outcomes.Default`, so connections from it must target explicit outcome ports. +`BpmnProcess` completes with the interpreter's outcome name — `BpmnInterpreter.DoneOutcomeName` ("Done") normally, or `BpmnInterpreter.CancelledOutcomeName` ("Cancelled") when a cancel end event cancelled a transaction. It does **not** complete with `Outcomes.Default`. Both outcomes are declared flow ports (`[FlowNode(BpmnInterpreter.DoneOutcomeName, BpmnInterpreter.CancelledOutcomeName)]`), so connections from it target one of them explicitly. ## Work Ledger diff --git a/src/modules/Elsa.Bpmn/Activities/BpmnProcess.cs b/src/modules/Elsa.Bpmn/Activities/BpmnProcess.cs index 6db6601b8..c2a03ed33 100644 --- a/src/modules/Elsa.Bpmn/Activities/BpmnProcess.cs +++ b/src/modules/Elsa.Bpmn/Activities/BpmnProcess.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Text.Json.Serialization; using System.Xml; using Bpmn.Model; +using Bpmn.Semantics; using Elsa.Bpmn.Hosting; using Elsa.Bpmn.Signals; using Elsa.Extensions; @@ -9,6 +10,7 @@ using Elsa.Scheduling; using Elsa.Scheduling.Bookmarks; using Elsa.Workflows; using Elsa.Workflows.Activities; +using Elsa.Workflows.Activities.Flowchart.Attributes; using Elsa.Workflows.Attributes; using Elsa.Workflows.Models; using Elsa.Workflows.Runtime; @@ -33,13 +35,15 @@ namespace Elsa.Bpmn.Activities; /// continuation, and its outcome is what a conditional sequence flow in the enclosing scope selects on. /// /// -/// Composing this activity into a Flowchart: it completes with only the interpreter's outcome name (e.g. -/// BpmnInterpreter.DoneOutcomeName, and CancelledOutcomeName where relevant) — not with -/// Outcomes.Default, which an ordinary activity's null result also produces and which additionally matches a -/// null-port connection. A Connection built with the default/null-port shorthand will therefore never fire -/// from this activity; always target an explicit outcome port. +/// Composing this activity into a Flowchart: it completes with only the interpreter's outcome name — +/// normally, or +/// when a cancel end event cancelled a transaction — never with Outcomes.Default, which an ordinary +/// activity's null result also produces and which additionally matches a null-port connection. Both outcomes are +/// declared flow ports, so a Connection targets one of them explicitly; the default/null-port shorthand will +/// never fire from this activity. /// /// +[FlowNode(BpmnInterpreter.DoneOutcomeName, BpmnInterpreter.CancelledOutcomeName)] [Activity("Elsa", "BPMN", "Executes a BPMN process scope.")] [System.ComponentModel.Browsable(false)] public class BpmnProcess : Container, ITrigger diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/Composition/BpmnCompositionTests.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/Composition/BpmnCompositionTests.cs index 7ed0628ba..ec8f50a20 100644 --- a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/Composition/BpmnCompositionTests.cs +++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/Composition/BpmnCompositionTests.cs @@ -50,6 +50,38 @@ public class BpmnCompositionTests(ITestOutputHelper testOutputHelper) Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); } + [Fact(DisplayName = "A BPMN process composed into a flowchart and connected on both outcomes routes down the one it completes with")] + public async Task BpmnProcessInsideAFlowchart_RoutesDownTheCancelledPortWhenTheProcessCancels() + { + // The gap this closes: the activity declares both outcomes as flow ports, so a flowchart can connect the + // Cancelled port explicitly rather than only ever seeing Studio's synthesized default port. + + // Arrange + var process = BpmnTestProcesses.CancelledTransaction(_host.Log); + var onCancelled = Work("on-cancelled"); + var onDone = Work("on-done"); + + var flowchart = new Flowchart + { + Start = process, + Activities = { process, onCancelled, onDone }, + Connections = + { + new Connection(new Endpoint(process, BpmnInterpreter.CancelledOutcomeName), new Endpoint(onCancelled)), + new Connection(new Endpoint(process, BpmnInterpreter.DoneOutcomeName), new Endpoint(onDone)) + } + }; + + // Act + var result = await _host.RunAsync(flowchart); + + // Assert: only the Cancelled branch ran. + Assert.Contains("executed:work", _host.Log.Entries); + Assert.Contains("executed:on-cancelled", _host.Log.Entries); + Assert.DoesNotContain("executed:on-done", _host.Log.Entries); + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); + } + [Fact(DisplayName = "A nested scope runs with the trigger opt-out off, which is its default")] public async Task NestedScope_RunsWithTheTriggerOptOutOff() { diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs index fa0983d98..e551fdbe9 100644 --- a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs +++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs @@ -191,6 +191,23 @@ internal static partial class BpmnTestProcesses return Scope("scope", definition, nested, Immediate("after", log)); } + /// + /// A root scope that is itself a transaction, cancelled from within by its own cancel end event — nothing nests + /// it, so the scope's own completion outcome is Cancelled rather than Done. + /// + public static BpmnProcess CancelledTransaction(BpmnTestLog log) + { + var definition = new BpmnProcessBuilder("cancelled-transaction") + .Transaction() + .StartEvent("start") + .Task("work", bindingRef: BindingRef("work")) + .EndEvent("cancelled", null, Cancel()) + .ConnectSequence("start", "work", "cancelled") + .Build(); + + return Scope("scope", definition, Immediate("work", log)); + } + /// /// A transaction subprocess that starts a compensation replay on one branch and cancels itself on the other while /// that replay is still running, so the replay's claimed-but-unrun log entries are torn down mid-run. diff --git a/test/unit/Elsa.Bpmn.UnitTests/BpmnProcessDescriptorTests.cs b/test/unit/Elsa.Bpmn.UnitTests/BpmnProcessDescriptorTests.cs new file mode 100644 index 000000000..62b9202f1 --- /dev/null +++ b/test/unit/Elsa.Bpmn.UnitTests/BpmnProcessDescriptorTests.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using Bpmn.Semantics; +using Elsa.Bpmn.Activities; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using NSubstitute; +using Xunit; + +namespace Elsa.Bpmn.UnitTests; + +public class BpmnProcessDescriptorTests +{ + [Fact(DisplayName = "BpmnProcess's descriptor declares Done and Cancelled as its only flow ports")] + public async Task DescribeActivityAsync_DeclaresDoneAndCancelledAsOnlyFlowPorts() + { + var defaultValueResolver = Substitute.For(); + var propertyUIHandlerResolver = Substitute.For(); + + defaultValueResolver.GetDefaultValue(Arg.Any()).Returns((object?)null); + propertyUIHandlerResolver + .GetUIPropertiesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => new ValueTask>(new Dictionary())); + + var describer = new ActivityDescriber(defaultValueResolver, propertyUIHandlerResolver); + + var descriptor = await describer.DescribeActivityAsync(typeof(BpmnProcess)); + + var flowPorts = descriptor.Ports.Where(port => port.Type == PortType.Flow).ToList(); + + Assert.Equal(2, flowPorts.Count); + Assert.Contains(flowPorts, port => port.Name == BpmnInterpreter.DoneOutcomeName); + Assert.Contains(flowPorts, port => port.Name == BpmnInterpreter.CancelledOutcomeName); + } +}