diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.BoundaryEvents.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.BoundaryEvents.cs
new file mode 100644
index 000000000..936a823d0
--- /dev/null
+++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.BoundaryEvents.cs
@@ -0,0 +1,123 @@
+using Bpmn.Model;
+using Elsa.Bpmn.Activities;
+using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
+
+namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
+
+internal static partial class BpmnTestProcesses
+{
+ /// An interrupting timer boundary event on a long-running task.
+ public static BpmnProcess InterruptingTimerBoundary(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("interrupting-timer-boundary")
+ .StartEvent("start")
+ .Task("task", bindingRef: BindingRef("task"))
+ .EndEvent("end")
+ .BoundaryEvent("timeout", attachedTo: "task", eventDefinition: Timer(), interrupting: true, bindingRef: BindingRef("timeout"))
+ .Task("onTimeout", bindingRef: BindingRef("onTimeout"))
+ .EndEvent("timedOut")
+ .ConnectSequence("start", "task", "end")
+ .ConnectSequence("timeout", "onTimeout", "timedOut")
+ .Build();
+
+ return Scope("scope", definition, Blocking("task", log), Blocking("timeout", log), Immediate("onTimeout", log));
+ }
+
+ /// A task that fails, with an error boundary event that catches it.
+ public static BpmnProcess ErrorBoundaryCaught(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("error-boundary-caught")
+ .StartEvent("start")
+ .Task("risky", bindingRef: BindingRef("risky"))
+ .EndEvent("end")
+ .BoundaryEvent("oops", attachedTo: "risky", eventDefinition: new BpmnEventDefinition(BpmnEventDefinitionTypes.Error))
+ .Task("recover", bindingRef: BindingRef("recover"))
+ .EndEvent("recovered")
+ .ConnectSequence("start", "risky", "end")
+ .ConnectSequence("oops", "recover", "recovered")
+ .Build();
+
+ return Scope("scope", definition, Faulting("risky", log), Immediate("recover", log));
+ }
+
+ ///
+ /// A task that fails inside an embedded subprocess with nothing there to catch it, and an error boundary event on
+ /// the subprocess in the enclosing scope that does.
+ ///
+ public static BpmnProcess ErrorPropagatedOutOfSubprocess(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("failing-subprocess-body")
+ .StartEvent("subStart")
+ .Task("subRisky", bindingRef: BindingRef("subRisky"))
+ .EndEvent("subEnd")
+ .ConnectSequence("subStart", "subRisky", "subEnd")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("error-propagated-out-of-subprocess")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .BoundaryEvent("subOops", attachedTo: "sub", eventDefinition: new BpmnEventDefinition(BpmnEventDefinitionTypes.Error))
+ .Task("subRecover", bindingRef: BindingRef("subRecover"))
+ .EndEvent("subRecovered")
+ .ConnectSequence("start", "sub", "after", "end")
+ .ConnectSequence("subOops", "subRecover", "subRecovered")
+ .Build();
+
+ var nested = Scope("sub", body, Faulting("subRisky", log));
+
+ return Scope("scope", definition, nested, Immediate("after", log), Immediate("subRecover", log));
+ }
+
+ /// A task that fails with nothing to catch it.
+ public static BpmnProcess UncaughtError(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("uncaught-error")
+ .StartEvent("start")
+ .Task("risky", bindingRef: BindingRef("risky"))
+ .EndEvent("end")
+ .ConnectSequence("start", "risky", "end")
+ .Build();
+
+ return Scope("scope", definition, Faulting("risky", log));
+ }
+
+ ///
+ /// An escalation thrown out of an embedded subprocess, caught by a non-interrupting escalation boundary event on
+ /// the subprocess. The nested scope keeps running, which is what "non-interrupting" means.
+ ///
+ public static BpmnProcess EscalationOutOfSubprocess(BpmnTestLog log)
+ {
+ // subFirst runs before anything the parent could be confused with, deliberately: it puts the nested scope's
+ // handle counter ahead of the parent's. A host that recognised its work by a shared, rewritable key rather than
+ // by the child activity execution would otherwise be rescued by two independent counters happening to agree.
+ var body = new BpmnProcessBuilder("subprocess-body")
+ .StartEvent("subStart")
+ .Task("subFirst", bindingRef: BindingRef("subFirst"))
+ .Task("subWork", bindingRef: BindingRef("subWork"))
+ .IntermediateThrowEvent("subEscalate", Escalation("REVIEW"))
+ .Task("subMore", bindingRef: BindingRef("subMore"))
+ .EndEvent("subEnd")
+ .ConnectSequence("subStart", "subFirst", "subWork", "subEscalate", "subMore", "subEnd")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("escalation-out-of-subprocess")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .BoundaryEvent("escalated", attachedTo: "sub", eventDefinition: Escalation("REVIEW"), interrupting: false)
+ .Task("notify", bindingRef: BindingRef("notify"))
+ .EndEvent("notified")
+ .ConnectSequence("start", "sub", "after", "end")
+ .ConnectSequence("escalated", "notify", "notified")
+ .Build();
+
+ // subMore blocks so the subprocess is still running when the escalation path executes, which is what makes
+ // "the escalating work is still live" observable rather than merely asserted.
+ var nested = Scope("sub", body, Immediate("subFirst", log), Blocking("subWork", log), Blocking("subMore", log));
+
+ return Scope("scope", definition, nested, Immediate("after", log), Immediate("notify", log));
+ }
+}
diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs
new file mode 100644
index 000000000..fa0983d98
--- /dev/null
+++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Compensation.cs
@@ -0,0 +1,256 @@
+using Bpmn.Model;
+using Elsa.Bpmn.Activities;
+using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
+
+namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
+
+internal static partial class BpmnTestProcesses
+{
+ ///
+ /// Three bookings, each carrying a compensation boundary event, and a compensate end event that replays the lot.
+ ///
+ ///
+ /// Three rather than two: with two, a handler order that merely happened to be reversed is indistinguishable from
+ /// one that swapped a pair, and a replay that walked the log forwards would still run every handler. The three
+ /// handlers bind work like anything else, and nothing in the graph flows into them — the only thing that can run
+ /// them is the replay.
+ ///
+ public static BpmnProcess CompensatedBookings(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("compensated-bookings")
+ .StartEvent("start")
+ .Task("bookFlight", bindingRef: BindingRef("bookFlight"))
+ .Task("bookHotel", bindingRef: BindingRef("bookHotel"))
+ .Task("bookCar", bindingRef: BindingRef("bookCar"))
+ .EndEvent("undoEverything", null, Compensation())
+ .Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight"))
+ .Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel"))
+ .Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar"))
+ .Element(CompensationHandler("undoFlight"))
+ .Element(CompensationHandler("undoHotel"))
+ .Element(CompensationHandler("undoCar"))
+ .ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoEverything")
+ .Build();
+
+ return Scope(
+ "scope",
+ definition,
+ Immediate("bookFlight", log),
+ Immediate("bookHotel", log),
+ Immediate("bookCar", log),
+ Immediate("undoFlight", log),
+ Immediate("undoHotel", log),
+ Immediate("undoCar", log));
+ }
+
+ ///
+ /// The same three bookings, compensated by an intermediate throw event naming one of them in its
+ /// activityRef, and a task after the throw that its outbound flow reaches once the replay is done.
+ ///
+ public static BpmnProcess TargetedCompensation(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("targeted-compensation")
+ .StartEvent("start")
+ .Task("bookFlight", bindingRef: BindingRef("bookFlight"))
+ .Task("bookHotel", bindingRef: BindingRef("bookHotel"))
+ .Task("bookCar", bindingRef: BindingRef("bookCar"))
+ .IntermediateThrowEvent("undoHotelOnly", Compensation(activityRef: "bookHotel"))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight"))
+ .Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel"))
+ .Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar"))
+ .Element(CompensationHandler("undoFlight"))
+ .Element(CompensationHandler("undoHotel"))
+ .Element(CompensationHandler("undoCar"))
+ .ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoHotelOnly", "after", "end")
+ .Build();
+
+ return Scope(
+ "scope",
+ definition,
+ Immediate("bookFlight", log),
+ Immediate("bookHotel", log),
+ Immediate("bookCar", log),
+ Immediate("undoFlight", log),
+ Immediate("undoHotel", log),
+ Immediate("undoCar", log),
+ Immediate("after", log));
+ }
+
+ ///
+ /// A compensation log inside an embedded subprocess, and a second one in the enclosing scope that compensates the
+ /// subprocess itself.
+ ///
+ ///
+ /// Two logs, one per scope, and neither can see the other: the body replays its own two handlers before it
+ /// completes, and the enclosing scope registers exactly one entry — the subprocess's own successful completion,
+ /// which its attached compensation boundary makes compensable — and replays that.
+ ///
+ public static BpmnProcess CompensationInsideSubprocess(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("compensating-subprocess-body")
+ .StartEvent("subStart")
+ .Task("subCharge", bindingRef: BindingRef("subCharge"))
+ .Task("subShip", bindingRef: BindingRef("subShip"))
+ .EndEvent("subUndo", null, Compensation())
+ .Element(CompensationBoundary("subChargeCompensated", attachedTo: "subCharge", handler: "subRefund"))
+ .Element(CompensationBoundary("subShipCompensated", attachedTo: "subShip", handler: "subRecall"))
+ .Element(CompensationHandler("subRefund"))
+ .Element(CompensationHandler("subRecall"))
+ .ConnectSequence("subStart", "subCharge", "subShip", "subUndo")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("compensation-inside-subprocess")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"))
+ .EndEvent("undoOuter", null, Compensation())
+ .Element(CompensationBoundary("subCompensated", attachedTo: "sub", handler: "undoSub"))
+ .Element(CompensationHandler("undoSub"))
+ .ConnectSequence("start", "sub", "undoOuter")
+ .Build();
+
+ var nested = Scope(
+ "sub",
+ body,
+ Immediate("subCharge", log),
+ Immediate("subShip", log),
+ Immediate("subRefund", log),
+ Immediate("subRecall", log));
+
+ return Scope("scope", definition, nested, Immediate("undoSub", log));
+ }
+
+ ///
+ /// A transaction subprocess that cancels itself from the inside, and a cancel boundary event on the transaction
+ /// that routes the cancellation.
+ ///
+ ///
+ /// The nested scope completes with the Cancelled outcome rather than Done, and the enclosing scope
+ /// only reaches the boundary path if that outcome survives the trip through the parent's completion callback.
+ /// Nothing else in the process distinguishes the two: with the outcome dropped the parent simply carries on down
+ /// the ordinary sequence flow, which is a completion that looks entirely successful.
+ ///
+ public static BpmnProcess CancelledTransactionSubprocess(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("transaction-body")
+ .Transaction()
+ .StartEvent("subStart")
+ .Task("subWork", bindingRef: BindingRef("subWork"))
+ .EndEvent("subCancelled", null, Cancel())
+ .ConnectSequence("subStart", "subWork", "subCancelled")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("cancelled-transaction-subprocess")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .BoundaryEvent("cancelled", attachedTo: "sub", eventDefinition: Cancel())
+ .Task("unwind", bindingRef: BindingRef("unwind"))
+ .EndEvent("unwound")
+ .ConnectSequence("start", "sub", "after", "end")
+ .ConnectSequence("cancelled", "unwind", "unwound")
+ .Build();
+
+ var nested = Scope("sub", body, Immediate("subWork", log));
+
+ return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
+ }
+
+ ///
+ /// A transaction subprocess that cancels itself from the inside, with nothing on the enclosing scope to route the
+ /// cancellation.
+ ///
+ ///
+ /// The same shape as minus the cancel boundary event. Graph
+ /// validation cannot see into the nested definition to know a cancel end event is in there, so an unroutable
+ /// cancellation is an execution-time rule: the enclosing scope faults rather than treating the transaction as an
+ /// ordinary completion and carrying on down the sequence flow.
+ ///
+ public static BpmnProcess CancelledTransactionWithoutCancelBoundary(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("unroutable-cancel-body")
+ .Transaction()
+ .StartEvent("subStart")
+ .Task("subWork", bindingRef: BindingRef("subWork"))
+ .EndEvent("subCancelled", null, Cancel())
+ .ConnectSequence("subStart", "subWork", "subCancelled")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("unroutable-cancel")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .ConnectSequence("start", "sub", "after", "end")
+ .Build();
+
+ var nested = Scope("sub", body, Immediate("subWork", log));
+
+ return Scope("scope", definition, nested, Immediate("after", 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.
+ ///
+ ///
+ ///
+ /// The shape is what makes the release observable. chargeCard and reserveSeat both complete and
+ /// register, in that order, so the replay the intermediate throw opens claims both and runs them in reverse:
+ /// releaseSeat first, which blocks, leaving refundCard claimed and never started. Cancelling the
+ /// transaction from the other branch stops the replay's coordinating token, which is the only thing in scope that
+ /// tears a run down mid-flight.
+ ///
+ ///
+ /// fraudCheck blocks so a test decides when the cancellation happens, rather than racing the replay.
+ ///
+ ///
+ public static BpmnProcess CompensationRunCancelledMidReplay(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("mid-replay-cancel-body")
+ .Transaction()
+ .StartEvent("subStart")
+ .ParallelGateway("subSplit")
+ .Task("chargeCard", bindingRef: BindingRef("chargeCard"))
+ .Task("reserveSeat", bindingRef: BindingRef("reserveSeat"))
+ .IntermediateThrowEvent("rollBack", Compensation())
+ .EndEvent("rolledBack")
+ .Task("fraudCheck", bindingRef: BindingRef("fraudCheck"))
+ .EndEvent("subCancelled", null, Cancel())
+ .Element(CompensationBoundary("cardCompensated", attachedTo: "chargeCard", handler: "refundCard"))
+ .Element(CompensationBoundary("seatCompensated", attachedTo: "reserveSeat", handler: "releaseSeat"))
+ .Element(CompensationHandler("refundCard"))
+ .Element(CompensationHandler("releaseSeat"))
+ .ConnectSequence("subStart", "subSplit")
+ .Connect("subSplit", "chargeCard")
+ .ConnectSequence("chargeCard", "reserveSeat", "rollBack", "rolledBack")
+ .Connect("subSplit", "fraudCheck")
+ .ConnectSequence("fraudCheck", "subCancelled")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("mid-replay-cancel")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .BoundaryEvent("cancelled", attachedTo: "sub", eventDefinition: Cancel())
+ .Task("unwind", bindingRef: BindingRef("unwind"))
+ .EndEvent("unwound")
+ .ConnectSequence("start", "sub", "after", "end")
+ .ConnectSequence("cancelled", "unwind", "unwound")
+ .Build();
+
+ var nested = Scope(
+ "sub",
+ body,
+ Immediate("chargeCard", log),
+ Immediate("reserveSeat", log),
+ Blocking("fraudCheck", log),
+ Immediate("refundCard", log),
+ Blocking("releaseSeat", log));
+
+ return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
+ }
+}
diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.EventSubprocesses.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.EventSubprocesses.cs
new file mode 100644
index 000000000..fb3fd95fe
--- /dev/null
+++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.EventSubprocesses.cs
@@ -0,0 +1,297 @@
+using Bpmn.Model;
+using Elsa.Bpmn.Activities;
+using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
+using Elsa.Workflows;
+
+namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
+
+internal static partial class BpmnTestProcesses
+{
+ ///
+ /// A task that fails, with a dormant error-triggered event subprocess in the same scope to catch it.
+ ///
+ ///
+ /// An error event subprocess arms nothing: it rides the same FaultSignal seam an error boundary event does,
+ /// and the only thing that distinguishes it here is where the recovery work runs — inside a nested scope of its
+ /// own, seeded at the body's error start event, rather than on an outbound flow of the enclosing graph.
+ ///
+ public static BpmnProcess ErrorEventSubprocess(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("error-event-subprocess-body")
+ .Element(EventSubprocessStart("errStart", Error()))
+ .Task("handleError", bindingRef: BindingRef("handleError"))
+ .EndEvent("errEnd")
+ .ConnectSequence("errStart", "handleError", "errEnd")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("error-event-subprocess")
+ .StartEvent("start")
+ .Task("risky", bindingRef: BindingRef("risky"))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .Element(EventSubprocess("evtSub"))
+ .ConnectSequence("start", "risky", "after", "end")
+ .Build();
+
+ return Scope("scope", definition, Faulting("risky", log), Immediate("after", log), Scope("evtSub", body, Immediate("handleError", log)));
+ }
+
+ ///
+ /// An escalation thrown out of an embedded subprocess, caught by a non-interrupting escalation-triggered event
+ /// subprocess on the enclosing scope rather than by a boundary event on the subprocess.
+ ///
+ ///
+ /// Non-interrupting, so the escalating subprocess keeps running and nothing in the scope is torn down. That is
+ /// also what makes "the scope-level catcher fired" distinguishable from "the subprocess was stopped": with an
+ /// interrupting catcher the two are the same observation.
+ ///
+ public static BpmnProcess EscalationEventSubprocessOutOfSubprocess(BpmnTestLog log)
+ {
+ var subBody = new BpmnProcessBuilder("escalating-subprocess-body")
+ .StartEvent("subStart")
+ .Task("subWork", bindingRef: BindingRef("subWork"))
+ .IntermediateThrowEvent("subEscalate", Escalation("REVIEW"))
+ .Task("subMore", bindingRef: BindingRef("subMore"))
+ .EndEvent("subEnd")
+ .ConnectSequence("subStart", "subWork", "subEscalate", "subMore", "subEnd")
+ .Build();
+
+ var handlerBody = new BpmnProcessBuilder("escalation-event-subprocess-body")
+ .Element(EventSubprocessStart("escStart", Escalation("REVIEW"), interrupting: false))
+ .Task("handleEscalation", bindingRef: BindingRef("handleEscalation"))
+ .EndEvent("escEnd")
+ .ConnectSequence("escStart", "handleEscalation", "escEnd")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("escalation-event-subprocess")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .Element(EventSubprocess("evtSub"))
+ .ConnectSequence("start", "sub", "after", "end")
+ .Build();
+
+ var nested = Scope("sub", subBody, Blocking("subWork", log), Blocking("subMore", log));
+
+ return Scope("scope", definition, nested, Immediate("after", log), Scope("evtSub", handlerBody, Immediate("handleEscalation", log)));
+ }
+
+ ///
+ /// A non-interrupting message-triggered event subprocess: a listener armed at scope start, and a body that runs
+ /// each time the listener fires while the scope's own long-running work is still going.
+ ///
+ ///
+ ///
+ /// The listener is the second binding channel — listenerBindingRef — and is bound in the same
+ /// WorkBindings map as everything else. It stands in for a real message wait: blocking work a test
+ /// finishes, which is exactly what "the trigger fired" means to the host.
+ ///
+ ///
+ /// work blocks so the scope stays open across the fires and so that when it finally completes, the armed
+ /// listener is a running activity rather than a scheduled-but-not-yet-invoked one — the second of which
+ /// this host cannot withdraw at all.
+ ///
+ ///
+ public static BpmnProcess MessageEventSubprocess(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("message-event-subprocess-body")
+ .Element(EventSubprocessStart("msgStart", Message("nudge"), interrupting: false))
+ .Task("handleNudge", bindingRef: BindingRef("handleNudge"))
+ .EndEvent("msgEnd")
+ .ConnectSequence("msgStart", "handleNudge", "msgEnd")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("message-event-subprocess")
+ .StartEvent("start")
+ .Task("work", bindingRef: BindingRef("work"))
+ .EndEvent("end")
+ .Element(EventSubprocess("evtSub", listenerBindingRef: BindingRef("nudgeListener")))
+ .ConnectSequence("start", "work", "end")
+ .Build();
+
+ return Scope(
+ "scope",
+ definition,
+ Blocking("work", log),
+ Blocking("nudgeListener", log),
+ Scope("evtSub", body, Immediate("handleNudge", log)));
+ }
+
+ ///
+ /// The same message-triggered event subprocess, but inside an embedded subprocess that completes while the
+ /// enclosing scope carries on — so a listener that outlived the scope that armed it is distinguishable from one
+ /// that merely outlived the workflow.
+ ///
+ ///
+ /// At the root, "the armed work does not survive the scope" and "does not survive the workflow" are the same
+ /// observation, and Elsa tears a finished workflow's children down regardless. Here the workflow keeps running
+ /// after the scope that armed the listener has completed, which is the only shape in which a listener left behind
+ /// is a listener something could still resume into.
+ ///
+ public static BpmnProcess NestedMessageEventSubprocess(BpmnTestLog log)
+ {
+ var handlerBody = new BpmnProcessBuilder("nested-message-event-subprocess-body")
+ .Element(EventSubprocessStart("msgStart", Message("nudge"), interrupting: false))
+ .Task("handleNudge", bindingRef: BindingRef("handleNudge"))
+ .EndEvent("msgEnd")
+ .ConnectSequence("msgStart", "handleNudge", "msgEnd")
+ .Build();
+
+ var subBody = new BpmnProcessBuilder("listening-subprocess-body")
+ .StartEvent("subStart")
+ .Task("subWork", bindingRef: BindingRef("subWork"))
+ .EndEvent("subEnd")
+ .Element(EventSubprocess("evtSub", listenerBindingRef: BindingRef("nudgeListener")))
+ .ConnectSequence("subStart", "subWork", "subEnd")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("nested-message-event-subprocess")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .ConnectSequence("start", "sub", "after", "end")
+ .Build();
+
+ var nested = Scope(
+ "sub",
+ subBody,
+ Blocking("subWork", log),
+ Blocking("nudgeListener", log),
+ Scope("evtSub", handlerBody, Immediate("handleNudge", log)));
+
+ return Scope("scope", definition, nested, Immediate("after", log));
+ }
+
+ ///
+ /// An error-triggered event subprocess whose body runs an ordinary embedded subprocess of its own, so the
+ /// start-element hint has both a place to arrive and a place it must not reach.
+ ///
+ ///
+ ///
+ /// The body's only start event is event-defined, which is what makes the hint's arrival observable rather than
+ /// merely asserted: seeded from the hint the body runs, and seeded as an ordinary direct invocation it faults
+ /// deterministically with bpmn.start.none-available, because there is no none start event to begin at.
+ ///
+ ///
+ /// The nested inner subprocess is the other direction. Its own invocation carries an ordinary scheduling
+ /// cause, so the hint must not be inherited: were it, the inner process would be seeded at an element it does not
+ /// declare and fault with bpmn.start.unresolved-hint instead of starting at its own none start event.
+ ///
+ ///
+ public static BpmnProcess EventSubprocessBodyWithNestedSubprocess(BpmnTestLog log)
+ {
+ var innerBody = new BpmnProcessBuilder("event-subprocess-inner-body")
+ .StartEvent("innerStart")
+ .Task("innerOnly", bindingRef: BindingRef("innerOnly"))
+ .EndEvent("innerEnd")
+ .ConnectSequence("innerStart", "innerOnly", "innerEnd")
+ .Build();
+
+ var body = new BpmnProcessBuilder("hinted-event-subprocess-body")
+ .Element(EventSubprocessStart("errStart", Error()))
+ .Task("handleError", bindingRef: BindingRef("handleError"))
+ .SubProcess("inner", bindingRef: BindingRef("inner"))
+ .EndEvent("errEnd")
+ .ConnectSequence("errStart", "handleError", "inner", "errEnd")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("event-subprocess-start-hint")
+ .StartEvent("start")
+ .Task("risky", bindingRef: BindingRef("risky"))
+ .EndEvent("end")
+ .Element(EventSubprocess("evtSub"))
+ .ConnectSequence("start", "risky", "end")
+ .Build();
+
+ var handler = Scope("evtSub", body, Immediate("handleError", log), Scope("inner", innerBody, Immediate("innerOnly", log)));
+
+ return Scope("scope", definition, Faulting("risky", log), handler);
+ }
+
+ /// An event subprocess whose body declares two start events, which the library refuses.
+ public static BpmnProcess EventSubprocessBodyWithTwoStartEvents(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("two-start-event-subprocess-body")
+ .Element(EventSubprocessStart("errStart", Error()))
+ .StartEvent("alsoStart")
+ .Task("handleError", bindingRef: BindingRef("handleError"))
+ .EndEvent("errEnd")
+ .ConnectSequence("errStart", "handleError", "errEnd")
+ .Connect("alsoStart", "handleError")
+ .Build();
+
+ return RefusedEventSubprocessScope("two-start-events", log, ("evtSub", body, "handleError"));
+ }
+
+ /// Two error-triggered event subprocesses in one scope, which the library refuses.
+ public static BpmnProcess TwoErrorEventSubprocesses(BpmnTestLog log)
+ {
+ BpmnProcessDefinition Body(string prefix) => new BpmnProcessBuilder($"{prefix}-error-event-subprocess-body")
+ .Element(EventSubprocessStart($"{prefix}Start", Error()))
+ .Task($"{prefix}Handle", bindingRef: BindingRef($"{prefix}Handle"))
+ .EndEvent($"{prefix}End")
+ .ConnectSequence($"{prefix}Start", $"{prefix}Handle", $"{prefix}End")
+ .Build();
+
+ return RefusedEventSubprocessScope(
+ "two-error-event-subprocesses",
+ log,
+ ("evtSubA", Body("first"), "firstHandle"),
+ ("evtSubB", Body("second"), "secondHandle"));
+ }
+
+ /// Two code-less catch-all escalation-triggered event subprocesses in one scope, which the library refuses.
+ public static BpmnProcess TwoCatchAllEscalationEventSubprocesses(BpmnTestLog log)
+ {
+ BpmnProcessDefinition Body(string prefix) => new BpmnProcessBuilder($"{prefix}-escalation-event-subprocess-body")
+ .Element(EventSubprocessStart($"{prefix}Start", Escalation(), interrupting: false))
+ .Task($"{prefix}Handle", bindingRef: BindingRef($"{prefix}Handle"))
+ .EndEvent($"{prefix}End")
+ .ConnectSequence($"{prefix}Start", $"{prefix}Handle", $"{prefix}End")
+ .Build();
+
+ return RefusedEventSubprocessScope(
+ "two-catch-all-escalation-event-subprocesses",
+ log,
+ ("evtSubA", Body("first"), "firstHandle"),
+ ("evtSubB", Body("second"), "secondHandle"));
+ }
+
+ /// A non-interrupting error-triggered event subprocess, which is not legal BPMN and which the library refuses.
+ public static BpmnProcess NonInterruptingErrorEventSubprocess(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("non-interrupting-error-event-subprocess-body")
+ .Element(EventSubprocessStart("errStart", Error(), interrupting: false))
+ .Task("handleError", bindingRef: BindingRef("handleError"))
+ .EndEvent("errEnd")
+ .ConnectSequence("errStart", "handleError", "errEnd")
+ .Build();
+
+ return RefusedEventSubprocessScope("non-interrupting-error-event-subprocess", log, ("evtSub", body, "handleError"));
+ }
+
+ ///
+ /// The start/only/end graph the refusal processes share, carrying the event subprocesses whose declaration
+ /// the library refuses. Nothing in it ever runs: the refusal is raised when the scope builds its graph, which is
+ /// before any work is started.
+ ///
+ private static BpmnProcess RefusedEventSubprocessScope(string processId, BpmnTestLog log, params (string ElementId, BpmnProcessDefinition Body, string HandlerId)[] eventSubprocesses)
+ {
+ var builder = new BpmnProcessBuilder(processId)
+ .StartEvent("start")
+ .Task("only", bindingRef: BindingRef("only"))
+ .EndEvent("end")
+ .ConnectSequence("start", "only", "end");
+
+ foreach (var eventSubprocess in eventSubprocesses)
+ builder = builder.Element(EventSubprocess(eventSubprocess.ElementId));
+
+ var work = new List { Immediate("only", log) };
+
+ work.AddRange(eventSubprocesses.Select(eventSubprocess => Scope(eventSubprocess.ElementId, eventSubprocess.Body, Immediate(eventSubprocess.HandlerId, log))));
+
+ return Scope("scope", builder.Build(), work.ToArray());
+ }
+}
diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Flow.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Flow.cs
new file mode 100644
index 000000000..97da7abcc
--- /dev/null
+++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.Flow.cs
@@ -0,0 +1,116 @@
+using Bpmn.Model;
+using Elsa.Bpmn.Activities;
+using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
+using Elsa.Workflows;
+
+namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
+
+internal static partial class BpmnTestProcesses
+{
+ /// A linear process: one task between a start and an end event.
+ public static BpmnProcess LinearTask(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("linear-task")
+ .StartEvent("start")
+ .Task("only", bindingRef: BindingRef("only"))
+ .EndEvent("end")
+ .ConnectSequence("start", "only", "end")
+ .Build();
+
+ return Scope("scope", definition, Immediate("only", log));
+ }
+
+ /// An embedded subprocess with one task in it, and one task after it in the enclosing scope.
+ public static BpmnProcess NestedSubprocess(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("nested-subprocess-body")
+ .StartEvent("subStart")
+ .Task("subOnly", bindingRef: BindingRef("subOnly"))
+ .EndEvent("subEnd")
+ .ConnectSequence("subStart", "subOnly", "subEnd")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("nested-subprocess")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .ConnectSequence("start", "sub", "after", "end")
+ .Build();
+
+ return Scope("scope", definition, Scope("sub", body, Immediate("subOnly", log)), Immediate("after", log));
+ }
+
+ /// A parallel gateway split and join.
+ public static BpmnProcess ParallelSplitAndJoin(BpmnTestLog log) =>
+ ParallelSplitAndJoinTopology("parallel-split-and-join", Immediate("left", log), Immediate("right", log), log);
+
+ ///
+ /// A parallel split into two branches that both block, and a join. Used to prove a scope suspends with two live
+ /// units of work outstanding and, once resumed, matches each completion back to its own binding through the
+ /// rehydrated ledger.
+ ///
+ public static BpmnProcess ParallelSplitAndJoinBlocking(BpmnTestLog log) =>
+ ParallelSplitAndJoinTopology("parallel-split-and-join-blocking", Blocking("left", log), Blocking("right", log), log);
+
+ ///
+ /// The start/split/left/right/join/after/end graph shared by and
+ /// , parameterised by the work the two branches run.
+ ///
+ private static BpmnProcess ParallelSplitAndJoinTopology(string processId, IActivity leftWork, IActivity rightWork, BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder(processId)
+ .StartEvent("start")
+ .ParallelGateway("split")
+ .Task("left", bindingRef: BindingRef("left"))
+ .Task("right", bindingRef: BindingRef("right"))
+ .ParallelGateway("join")
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .ConnectSequence("start", "split")
+ .Connect("split", "left")
+ .Connect("split", "right")
+ .Connect("left", "join")
+ .Connect("right", "join")
+ .ConnectSequence("join", "after", "end")
+ .Build();
+
+ return Scope("scope", definition, leftWork, rightWork, Immediate("after", log));
+ }
+
+ ///
+ /// A parallel split and join, blocking on both branches, nested inside an embedded subprocess. Used to prove
+ /// a nested scope's own ledger -- not just a root scope's -- matches each completion back to its
+ /// binding after a round trip through Elsa's own serializer.
+ ///
+ public static BpmnProcess NestedParallelSplitAndJoinBlocking(BpmnTestLog log)
+ {
+ var body = new BpmnProcessBuilder("nested-parallel-split-and-join-body")
+ .StartEvent("subStart")
+ .ParallelGateway("subSplit")
+ .Task("subLeft", bindingRef: BindingRef("subLeft"))
+ .Task("subRight", bindingRef: BindingRef("subRight"))
+ .ParallelGateway("subJoin")
+ .Task("subAfter", bindingRef: BindingRef("subAfter"))
+ .EndEvent("subEnd")
+ .ConnectSequence("subStart", "subSplit")
+ .Connect("subSplit", "subLeft")
+ .Connect("subSplit", "subRight")
+ .Connect("subLeft", "subJoin")
+ .Connect("subRight", "subJoin")
+ .ConnectSequence("subJoin", "subAfter", "subEnd")
+ .Build();
+
+ var definition = new BpmnProcessBuilder("nested-parallel-split-and-join-blocking")
+ .StartEvent("start")
+ .SubProcess("sub", bindingRef: BindingRef("sub"))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .ConnectSequence("start", "sub", "after", "end")
+ .Build();
+
+ var nested = Scope("sub", body, Blocking("subLeft", log), Blocking("subRight", log), Immediate("subAfter", log));
+
+ return Scope("scope", definition, nested, Immediate("after", log));
+ }
+}
diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.MultiInstance.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.MultiInstance.cs
new file mode 100644
index 000000000..be035fb68
--- /dev/null
+++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.MultiInstance.cs
@@ -0,0 +1,69 @@
+using Bpmn.Model;
+using Elsa.Bpmn.Activities;
+using Elsa.Bpmn.IntegrationTests.Scenarios.HostPort.Activities;
+using Elsa.Workflows.Memory;
+
+namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
+
+internal static partial class BpmnTestProcesses
+{
+ /// The name of the variable loops over.
+ public const string CollectionVariableName = "items";
+
+ ///
+ /// A parallel multi-instance task: two concurrent instances of one binding, told apart only by their iteration id.
+ ///
+ public static BpmnProcess ParallelMultiInstanceTask(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("parallel-multi-instance-task")
+ .StartEvent("start")
+ .Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, cardinality: 2))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .ConnectSequence("start", "each", "after", "end")
+ .Build();
+
+ return Scope("scope", definition, Blocking("each", log), Immediate("after", log));
+ }
+
+ ///
+ /// A sequential multi-instance task: one instance at a time, each blocking until a test finishes it. Used to
+ /// drive many evaluations of one scope so a persisted blob that is not pruned is observable as unbounded growth.
+ ///
+ public static BpmnProcess SequentialMultiInstanceTask(BpmnTestLog log, int cardinality)
+ {
+ var definition = new BpmnProcessBuilder("sequential-multi-instance-task")
+ .StartEvent("start")
+ .Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: true, cardinality: cardinality))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .ConnectSequence("start", "each", "after", "end")
+ .Build();
+
+ return Scope("scope", definition, Blocking("each", log), Immediate("after", log));
+ }
+
+ ///
+ /// A collection-mode multi-instance task: one instance per item of a container-scoped variable, which the
+ /// interpreter reads back through IBpmnVariableReader while it evaluates.
+ ///
+ ///
+ /// Three items rather than two, so the instance count cannot be confused with a declared cardinality. The
+ /// collection variable is declared on both sides — on the definition, because BpmnGraph.Build refuses a
+ /// loop naming a variable the process does not declare, and on the activity, because that is where the value
+ /// actually lives.
+ ///
+ public static BpmnProcess CollectionMultiInstanceTask(BpmnTestLog log)
+ {
+ var definition = new BpmnProcessBuilder("collection-multi-instance-task")
+ .Variable(CollectionVariableName)
+ .StartEvent("start")
+ .Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, collectionVariable: CollectionVariableName))
+ .Task("after", bindingRef: BindingRef("after"))
+ .EndEvent("end")
+ .ConnectSequence("start", "each", "after", "end")
+ .Build();
+
+ return Scope("scope", definition, [new Variable(CollectionVariableName, ["alpha", "beta", "gamma"])], Immediate("each", log), Immediate("after", log));
+ }
+}
diff --git a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs
index 024c32629..2d0b0fdba 100644
--- a/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs
+++ b/test/integration/Elsa.Bpmn.IntegrationTests/Scenarios/HostPort/BpmnTestProcesses.cs
@@ -11,831 +11,14 @@ namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
///
///
/// Every activity's id is the BPMN element id it runs, and its binding ref is that id prefixed, so a process reads
-/// the same way in the model and in the assertions.
+/// the same way in the model and in the assertions. The processes themselves live in sibling partials, one per BPMN
+/// construct family; this file holds the element factories and scope builders they all share.
///
-internal static class BpmnTestProcesses
+internal static partial class BpmnTestProcesses
{
- /// An interrupting timer boundary event on a long-running task.
- public static BpmnProcess InterruptingTimerBoundary(BpmnTestLog log)
- {
- var definition = new BpmnProcessBuilder("interrupting-timer-boundary")
- .StartEvent("start")
- .Task("task", bindingRef: BindingRef("task"))
- .EndEvent("end")
- .BoundaryEvent("timeout", attachedTo: "task", eventDefinition: Timer(), interrupting: true, bindingRef: BindingRef("timeout"))
- .Task("onTimeout", bindingRef: BindingRef("onTimeout"))
- .EndEvent("timedOut")
- .ConnectSequence("start", "task", "end")
- .ConnectSequence("timeout", "onTimeout", "timedOut")
- .Build();
-
- return Scope("scope", definition, Blocking("task", log), Blocking("timeout", log), Immediate("onTimeout", log));
- }
-
- ///
- /// An escalation thrown out of an embedded subprocess, caught by a non-interrupting escalation boundary event on
- /// the subprocess. The nested scope keeps running, which is what "non-interrupting" means.
- ///
- public static BpmnProcess EscalationOutOfSubprocess(BpmnTestLog log)
- {
- // subFirst runs before anything the parent could be confused with, deliberately: it puts the nested scope's
- // handle counter ahead of the parent's. A host that recognised its work by a shared, rewritable key rather than
- // by the child activity execution would otherwise be rescued by two independent counters happening to agree.
- var body = new BpmnProcessBuilder("subprocess-body")
- .StartEvent("subStart")
- .Task("subFirst", bindingRef: BindingRef("subFirst"))
- .Task("subWork", bindingRef: BindingRef("subWork"))
- .IntermediateThrowEvent("subEscalate", Escalation("REVIEW"))
- .Task("subMore", bindingRef: BindingRef("subMore"))
- .EndEvent("subEnd")
- .ConnectSequence("subStart", "subFirst", "subWork", "subEscalate", "subMore", "subEnd")
- .Build();
-
- var definition = new BpmnProcessBuilder("escalation-out-of-subprocess")
- .StartEvent("start")
- .SubProcess("sub", bindingRef: BindingRef("sub"))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .BoundaryEvent("escalated", attachedTo: "sub", eventDefinition: Escalation("REVIEW"), interrupting: false)
- .Task("notify", bindingRef: BindingRef("notify"))
- .EndEvent("notified")
- .ConnectSequence("start", "sub", "after", "end")
- .ConnectSequence("escalated", "notify", "notified")
- .Build();
-
- // subMore blocks so the subprocess is still running when the escalation path executes, which is what makes
- // "the escalating work is still live" observable rather than merely asserted.
- var nested = Scope("sub", body, Immediate("subFirst", log), Blocking("subWork", log), Blocking("subMore", log));
-
- return Scope("scope", definition, nested, Immediate("after", log), Immediate("notify", log));
- }
-
- /// A parallel gateway split and join.
- public static BpmnProcess ParallelSplitAndJoin(BpmnTestLog log) =>
- ParallelSplitAndJoinTopology("parallel-split-and-join", Immediate("left", log), Immediate("right", log), log);
-
- /// A task that fails, with an error boundary event that catches it.
- public static BpmnProcess ErrorBoundaryCaught(BpmnTestLog log)
- {
- var definition = new BpmnProcessBuilder("error-boundary-caught")
- .StartEvent("start")
- .Task("risky", bindingRef: BindingRef("risky"))
- .EndEvent("end")
- .BoundaryEvent("oops", attachedTo: "risky", eventDefinition: new BpmnEventDefinition(BpmnEventDefinitionTypes.Error))
- .Task("recover", bindingRef: BindingRef("recover"))
- .EndEvent("recovered")
- .ConnectSequence("start", "risky", "end")
- .ConnectSequence("oops", "recover", "recovered")
- .Build();
-
- return Scope("scope", definition, Faulting("risky", log), Immediate("recover", log));
- }
-
- ///
- /// A task that fails inside an embedded subprocess with nothing there to catch it, and an error boundary event on
- /// the subprocess in the enclosing scope that does.
- ///
- public static BpmnProcess ErrorPropagatedOutOfSubprocess(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("failing-subprocess-body")
- .StartEvent("subStart")
- .Task("subRisky", bindingRef: BindingRef("subRisky"))
- .EndEvent("subEnd")
- .ConnectSequence("subStart", "subRisky", "subEnd")
- .Build();
-
- var definition = new BpmnProcessBuilder("error-propagated-out-of-subprocess")
- .StartEvent("start")
- .SubProcess("sub", bindingRef: BindingRef("sub"))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .BoundaryEvent("subOops", attachedTo: "sub", eventDefinition: new BpmnEventDefinition(BpmnEventDefinitionTypes.Error))
- .Task("subRecover", bindingRef: BindingRef("subRecover"))
- .EndEvent("subRecovered")
- .ConnectSequence("start", "sub", "after", "end")
- .ConnectSequence("subOops", "subRecover", "subRecovered")
- .Build();
-
- var nested = Scope("sub", body, Faulting("subRisky", log));
-
- return Scope("scope", definition, nested, Immediate("after", log), Immediate("subRecover", log));
- }
-
- /// A task that fails with nothing to catch it.
- public static BpmnProcess UncaughtError(BpmnTestLog log)
- {
- var definition = new BpmnProcessBuilder("uncaught-error")
- .StartEvent("start")
- .Task("risky", bindingRef: BindingRef("risky"))
- .EndEvent("end")
- .ConnectSequence("start", "risky", "end")
- .Build();
-
- return Scope("scope", definition, Faulting("risky", log));
- }
-
- ///
- /// A parallel multi-instance task: two concurrent instances of one binding, told apart only by their iteration id.
- ///
- public static BpmnProcess ParallelMultiInstanceTask(BpmnTestLog log)
- {
- var definition = new BpmnProcessBuilder("parallel-multi-instance-task")
- .StartEvent("start")
- .Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, cardinality: 2))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .ConnectSequence("start", "each", "after", "end")
- .Build();
-
- return Scope("scope", definition, Blocking("each", log), Immediate("after", log));
- }
-
- ///
- /// A sequential multi-instance task: one instance at a time, each blocking until a test finishes it. Used to
- /// drive many evaluations of one scope so a persisted blob that is not pruned is observable as unbounded growth.
- ///
- public static BpmnProcess SequentialMultiInstanceTask(BpmnTestLog log, int cardinality)
- {
- var definition = new BpmnProcessBuilder("sequential-multi-instance-task")
- .StartEvent("start")
- .Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: true, cardinality: cardinality))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .ConnectSequence("start", "each", "after", "end")
- .Build();
-
- return Scope("scope", definition, Blocking("each", log), Immediate("after", log));
- }
-
- ///
- /// A parallel split into two branches that both block, and a join. Used to prove a scope suspends with two live
- /// units of work outstanding and, once resumed, matches each completion back to its own binding through the
- /// rehydrated ledger.
- ///
- public static BpmnProcess ParallelSplitAndJoinBlocking(BpmnTestLog log) =>
- ParallelSplitAndJoinTopology("parallel-split-and-join-blocking", Blocking("left", log), Blocking("right", log), log);
-
- ///
- /// The start/split/left/right/join/after/end graph shared by and
- /// , parameterised by the work the two branches run.
- ///
- private static BpmnProcess ParallelSplitAndJoinTopology(string processId, IActivity leftWork, IActivity rightWork, BpmnTestLog log)
- {
- var definition = new BpmnProcessBuilder(processId)
- .StartEvent("start")
- .ParallelGateway("split")
- .Task("left", bindingRef: BindingRef("left"))
- .Task("right", bindingRef: BindingRef("right"))
- .ParallelGateway("join")
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .ConnectSequence("start", "split")
- .Connect("split", "left")
- .Connect("split", "right")
- .Connect("left", "join")
- .Connect("right", "join")
- .ConnectSequence("join", "after", "end")
- .Build();
-
- return Scope("scope", definition, leftWork, rightWork, Immediate("after", log));
- }
-
- ///
- /// A collection-mode multi-instance task: one instance per item of a container-scoped variable, which the
- /// interpreter reads back through IBpmnVariableReader while it evaluates.
- ///
- ///
- /// Three items rather than two, so the instance count cannot be confused with a declared cardinality. The
- /// collection variable is declared on both sides — on the definition, because BpmnGraph.Build refuses a
- /// loop naming a variable the process does not declare, and on the activity, because that is where the value
- /// actually lives.
- ///
- public static BpmnProcess CollectionMultiInstanceTask(BpmnTestLog log)
- {
- var definition = new BpmnProcessBuilder("collection-multi-instance-task")
- .Variable(CollectionVariableName)
- .StartEvent("start")
- .Task(BpmnElementTypes.Task, "each", bindingRef: BindingRef("each"), loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, collectionVariable: CollectionVariableName))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .ConnectSequence("start", "each", "after", "end")
- .Build();
-
- return Scope("scope", definition, [new Variable(CollectionVariableName, ["alpha", "beta", "gamma"])], Immediate("each", log), Immediate("after", log));
- }
-
- ///
- /// A transaction subprocess that cancels itself from the inside, and a cancel boundary event on the transaction
- /// that routes the cancellation.
- ///
- ///
- /// The nested scope completes with the Cancelled outcome rather than Done, and the enclosing scope
- /// only reaches the boundary path if that outcome survives the trip through the parent's completion callback.
- /// Nothing else in the process distinguishes the two: with the outcome dropped the parent simply carries on down
- /// the ordinary sequence flow, which is a completion that looks entirely successful.
- ///
- public static BpmnProcess CancelledTransactionSubprocess(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("transaction-body")
- .Transaction()
- .StartEvent("subStart")
- .Task("subWork", bindingRef: BindingRef("subWork"))
- .EndEvent("subCancelled", null, Cancel())
- .ConnectSequence("subStart", "subWork", "subCancelled")
- .Build();
-
- var definition = new BpmnProcessBuilder("cancelled-transaction-subprocess")
- .StartEvent("start")
- .SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .BoundaryEvent("cancelled", attachedTo: "sub", eventDefinition: Cancel())
- .Task("unwind", bindingRef: BindingRef("unwind"))
- .EndEvent("unwound")
- .ConnectSequence("start", "sub", "after", "end")
- .ConnectSequence("cancelled", "unwind", "unwound")
- .Build();
-
- var nested = Scope("sub", body, Immediate("subWork", log));
-
- return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
- }
-
- ///
- /// Three bookings, each carrying a compensation boundary event, and a compensate end event that replays the lot.
- ///
- ///
- /// Three rather than two: with two, a handler order that merely happened to be reversed is indistinguishable from
- /// one that swapped a pair, and a replay that walked the log forwards would still run every handler. The three
- /// handlers bind work like anything else, and nothing in the graph flows into them — the only thing that can run
- /// them is the replay.
- ///
- public static BpmnProcess CompensatedBookings(BpmnTestLog log)
- {
- var definition = new BpmnProcessBuilder("compensated-bookings")
- .StartEvent("start")
- .Task("bookFlight", bindingRef: BindingRef("bookFlight"))
- .Task("bookHotel", bindingRef: BindingRef("bookHotel"))
- .Task("bookCar", bindingRef: BindingRef("bookCar"))
- .EndEvent("undoEverything", null, Compensation())
- .Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight"))
- .Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel"))
- .Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar"))
- .Element(CompensationHandler("undoFlight"))
- .Element(CompensationHandler("undoHotel"))
- .Element(CompensationHandler("undoCar"))
- .ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoEverything")
- .Build();
-
- return Scope(
- "scope",
- definition,
- Immediate("bookFlight", log),
- Immediate("bookHotel", log),
- Immediate("bookCar", log),
- Immediate("undoFlight", log),
- Immediate("undoHotel", log),
- Immediate("undoCar", log));
- }
-
- ///
- /// The same three bookings, compensated by an intermediate throw event naming one of them in its
- /// activityRef, and a task after the throw that its outbound flow reaches once the replay is done.
- ///
- public static BpmnProcess TargetedCompensation(BpmnTestLog log)
- {
- var definition = new BpmnProcessBuilder("targeted-compensation")
- .StartEvent("start")
- .Task("bookFlight", bindingRef: BindingRef("bookFlight"))
- .Task("bookHotel", bindingRef: BindingRef("bookHotel"))
- .Task("bookCar", bindingRef: BindingRef("bookCar"))
- .IntermediateThrowEvent("undoHotelOnly", Compensation(activityRef: "bookHotel"))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .Element(CompensationBoundary("flightCompensated", attachedTo: "bookFlight", handler: "undoFlight"))
- .Element(CompensationBoundary("hotelCompensated", attachedTo: "bookHotel", handler: "undoHotel"))
- .Element(CompensationBoundary("carCompensated", attachedTo: "bookCar", handler: "undoCar"))
- .Element(CompensationHandler("undoFlight"))
- .Element(CompensationHandler("undoHotel"))
- .Element(CompensationHandler("undoCar"))
- .ConnectSequence("start", "bookFlight", "bookHotel", "bookCar", "undoHotelOnly", "after", "end")
- .Build();
-
- return Scope(
- "scope",
- definition,
- Immediate("bookFlight", log),
- Immediate("bookHotel", log),
- Immediate("bookCar", log),
- Immediate("undoFlight", log),
- Immediate("undoHotel", log),
- Immediate("undoCar", log),
- Immediate("after", 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.
- ///
- ///
- ///
- /// The shape is what makes the release observable. chargeCard and reserveSeat both complete and
- /// register, in that order, so the replay the intermediate throw opens claims both and runs them in reverse:
- /// releaseSeat first, which blocks, leaving refundCard claimed and never started. Cancelling the
- /// transaction from the other branch stops the replay's coordinating token, which is the only thing in scope that
- /// tears a run down mid-flight.
- ///
- ///
- /// fraudCheck blocks so a test decides when the cancellation happens, rather than racing the replay.
- ///
- ///
- public static BpmnProcess CompensationRunCancelledMidReplay(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("mid-replay-cancel-body")
- .Transaction()
- .StartEvent("subStart")
- .ParallelGateway("subSplit")
- .Task("chargeCard", bindingRef: BindingRef("chargeCard"))
- .Task("reserveSeat", bindingRef: BindingRef("reserveSeat"))
- .IntermediateThrowEvent("rollBack", Compensation())
- .EndEvent("rolledBack")
- .Task("fraudCheck", bindingRef: BindingRef("fraudCheck"))
- .EndEvent("subCancelled", null, Cancel())
- .Element(CompensationBoundary("cardCompensated", attachedTo: "chargeCard", handler: "refundCard"))
- .Element(CompensationBoundary("seatCompensated", attachedTo: "reserveSeat", handler: "releaseSeat"))
- .Element(CompensationHandler("refundCard"))
- .Element(CompensationHandler("releaseSeat"))
- .ConnectSequence("subStart", "subSplit")
- .Connect("subSplit", "chargeCard")
- .ConnectSequence("chargeCard", "reserveSeat", "rollBack", "rolledBack")
- .Connect("subSplit", "fraudCheck")
- .ConnectSequence("fraudCheck", "subCancelled")
- .Build();
-
- var definition = new BpmnProcessBuilder("mid-replay-cancel")
- .StartEvent("start")
- .SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .BoundaryEvent("cancelled", attachedTo: "sub", eventDefinition: Cancel())
- .Task("unwind", bindingRef: BindingRef("unwind"))
- .EndEvent("unwound")
- .ConnectSequence("start", "sub", "after", "end")
- .ConnectSequence("cancelled", "unwind", "unwound")
- .Build();
-
- var nested = Scope(
- "sub",
- body,
- Immediate("chargeCard", log),
- Immediate("reserveSeat", log),
- Blocking("fraudCheck", log),
- Immediate("refundCard", log),
- Blocking("releaseSeat", log));
-
- return Scope("scope", definition, nested, Immediate("after", log), Immediate("unwind", log));
- }
-
- ///
- /// A transaction subprocess that cancels itself from the inside, with nothing on the enclosing scope to route the
- /// cancellation.
- ///
- ///
- /// The same shape as minus the cancel boundary event. Graph
- /// validation cannot see into the nested definition to know a cancel end event is in there, so an unroutable
- /// cancellation is an execution-time rule: the enclosing scope faults rather than treating the transaction as an
- /// ordinary completion and carrying on down the sequence flow.
- ///
- public static BpmnProcess CancelledTransactionWithoutCancelBoundary(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("unroutable-cancel-body")
- .Transaction()
- .StartEvent("subStart")
- .Task("subWork", bindingRef: BindingRef("subWork"))
- .EndEvent("subCancelled", null, Cancel())
- .ConnectSequence("subStart", "subWork", "subCancelled")
- .Build();
-
- var definition = new BpmnProcessBuilder("unroutable-cancel")
- .StartEvent("start")
- .SubProcess("sub", bindingRef: BindingRef("sub"), isTransaction: true)
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .ConnectSequence("start", "sub", "after", "end")
- .Build();
-
- var nested = Scope("sub", body, Immediate("subWork", log));
-
- return Scope("scope", definition, nested, Immediate("after", log));
- }
-
- ///
- /// A compensation log inside an embedded subprocess, and a second one in the enclosing scope that compensates the
- /// subprocess itself.
- ///
- ///
- /// Two logs, one per scope, and neither can see the other: the body replays its own two handlers before it
- /// completes, and the enclosing scope registers exactly one entry — the subprocess's own successful completion,
- /// which its attached compensation boundary makes compensable — and replays that.
- ///
- public static BpmnProcess CompensationInsideSubprocess(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("compensating-subprocess-body")
- .StartEvent("subStart")
- .Task("subCharge", bindingRef: BindingRef("subCharge"))
- .Task("subShip", bindingRef: BindingRef("subShip"))
- .EndEvent("subUndo", null, Compensation())
- .Element(CompensationBoundary("subChargeCompensated", attachedTo: "subCharge", handler: "subRefund"))
- .Element(CompensationBoundary("subShipCompensated", attachedTo: "subShip", handler: "subRecall"))
- .Element(CompensationHandler("subRefund"))
- .Element(CompensationHandler("subRecall"))
- .ConnectSequence("subStart", "subCharge", "subShip", "subUndo")
- .Build();
-
- var definition = new BpmnProcessBuilder("compensation-inside-subprocess")
- .StartEvent("start")
- .SubProcess("sub", bindingRef: BindingRef("sub"))
- .EndEvent("undoOuter", null, Compensation())
- .Element(CompensationBoundary("subCompensated", attachedTo: "sub", handler: "undoSub"))
- .Element(CompensationHandler("undoSub"))
- .ConnectSequence("start", "sub", "undoOuter")
- .Build();
-
- var nested = Scope(
- "sub",
- body,
- Immediate("subCharge", log),
- Immediate("subShip", log),
- Immediate("subRefund", log),
- Immediate("subRecall", log));
-
- return Scope("scope", definition, nested, Immediate("undoSub", log));
- }
-
- ///
- /// A task that fails, with a dormant error-triggered event subprocess in the same scope to catch it.
- ///
- ///
- /// An error event subprocess arms nothing: it rides the same FaultSignal seam an error boundary event does,
- /// and the only thing that distinguishes it here is where the recovery work runs — inside a nested scope of its
- /// own, seeded at the body's error start event, rather than on an outbound flow of the enclosing graph.
- ///
- public static BpmnProcess ErrorEventSubprocess(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("error-event-subprocess-body")
- .Element(EventSubprocessStart("errStart", Error()))
- .Task("handleError", bindingRef: BindingRef("handleError"))
- .EndEvent("errEnd")
- .ConnectSequence("errStart", "handleError", "errEnd")
- .Build();
-
- var definition = new BpmnProcessBuilder("error-event-subprocess")
- .StartEvent("start")
- .Task("risky", bindingRef: BindingRef("risky"))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .Element(EventSubprocess("evtSub"))
- .ConnectSequence("start", "risky", "after", "end")
- .Build();
-
- return Scope("scope", definition, Faulting("risky", log), Immediate("after", log), Scope("evtSub", body, Immediate("handleError", log)));
- }
-
- ///
- /// An escalation thrown out of an embedded subprocess, caught by a non-interrupting escalation-triggered event
- /// subprocess on the enclosing scope rather than by a boundary event on the subprocess.
- ///
- ///
- /// Non-interrupting, so the escalating subprocess keeps running and nothing in the scope is torn down. That is
- /// also what makes "the scope-level catcher fired" distinguishable from "the subprocess was stopped": with an
- /// interrupting catcher the two are the same observation.
- ///
- public static BpmnProcess EscalationEventSubprocessOutOfSubprocess(BpmnTestLog log)
- {
- var subBody = new BpmnProcessBuilder("escalating-subprocess-body")
- .StartEvent("subStart")
- .Task("subWork", bindingRef: BindingRef("subWork"))
- .IntermediateThrowEvent("subEscalate", Escalation("REVIEW"))
- .Task("subMore", bindingRef: BindingRef("subMore"))
- .EndEvent("subEnd")
- .ConnectSequence("subStart", "subWork", "subEscalate", "subMore", "subEnd")
- .Build();
-
- var handlerBody = new BpmnProcessBuilder("escalation-event-subprocess-body")
- .Element(EventSubprocessStart("escStart", Escalation("REVIEW"), interrupting: false))
- .Task("handleEscalation", bindingRef: BindingRef("handleEscalation"))
- .EndEvent("escEnd")
- .ConnectSequence("escStart", "handleEscalation", "escEnd")
- .Build();
-
- var definition = new BpmnProcessBuilder("escalation-event-subprocess")
- .StartEvent("start")
- .SubProcess("sub", bindingRef: BindingRef("sub"))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .Element(EventSubprocess("evtSub"))
- .ConnectSequence("start", "sub", "after", "end")
- .Build();
-
- var nested = Scope("sub", subBody, Blocking("subWork", log), Blocking("subMore", log));
-
- return Scope("scope", definition, nested, Immediate("after", log), Scope("evtSub", handlerBody, Immediate("handleEscalation", log)));
- }
-
- ///
- /// A non-interrupting message-triggered event subprocess: a listener armed at scope start, and a body that runs
- /// each time the listener fires while the scope's own long-running work is still going.
- ///
- ///
- ///
- /// The listener is the second binding channel — listenerBindingRef — and is bound in the same
- /// WorkBindings map as everything else. It stands in for a real message wait: blocking work a test
- /// finishes, which is exactly what "the trigger fired" means to the host.
- ///
- ///
- /// work blocks so the scope stays open across the fires and so that when it finally completes, the armed
- /// listener is a running activity rather than a scheduled-but-not-yet-invoked one — the second of which
- /// this host cannot withdraw at all.
- ///
- ///
- public static BpmnProcess MessageEventSubprocess(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("message-event-subprocess-body")
- .Element(EventSubprocessStart("msgStart", Message("nudge"), interrupting: false))
- .Task("handleNudge", bindingRef: BindingRef("handleNudge"))
- .EndEvent("msgEnd")
- .ConnectSequence("msgStart", "handleNudge", "msgEnd")
- .Build();
-
- var definition = new BpmnProcessBuilder("message-event-subprocess")
- .StartEvent("start")
- .Task("work", bindingRef: BindingRef("work"))
- .EndEvent("end")
- .Element(EventSubprocess("evtSub", listenerBindingRef: BindingRef("nudgeListener")))
- .ConnectSequence("start", "work", "end")
- .Build();
-
- return Scope(
- "scope",
- definition,
- Blocking("work", log),
- Blocking("nudgeListener", log),
- Scope("evtSub", body, Immediate("handleNudge", log)));
- }
-
- ///
- /// The same message-triggered event subprocess, but inside an embedded subprocess that completes while the
- /// enclosing scope carries on — so a listener that outlived the scope that armed it is distinguishable from one
- /// that merely outlived the workflow.
- ///
- ///
- /// At the root, "the armed work does not survive the scope" and "does not survive the workflow" are the same
- /// observation, and Elsa tears a finished workflow's children down regardless. Here the workflow keeps running
- /// after the scope that armed the listener has completed, which is the only shape in which a listener left behind
- /// is a listener something could still resume into.
- ///
- public static BpmnProcess NestedMessageEventSubprocess(BpmnTestLog log)
- {
- var handlerBody = new BpmnProcessBuilder("nested-message-event-subprocess-body")
- .Element(EventSubprocessStart("msgStart", Message("nudge"), interrupting: false))
- .Task("handleNudge", bindingRef: BindingRef("handleNudge"))
- .EndEvent("msgEnd")
- .ConnectSequence("msgStart", "handleNudge", "msgEnd")
- .Build();
-
- var subBody = new BpmnProcessBuilder("listening-subprocess-body")
- .StartEvent("subStart")
- .Task("subWork", bindingRef: BindingRef("subWork"))
- .EndEvent("subEnd")
- .Element(EventSubprocess("evtSub", listenerBindingRef: BindingRef("nudgeListener")))
- .ConnectSequence("subStart", "subWork", "subEnd")
- .Build();
-
- var definition = new BpmnProcessBuilder("nested-message-event-subprocess")
- .StartEvent("start")
- .SubProcess("sub", bindingRef: BindingRef("sub"))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .ConnectSequence("start", "sub", "after", "end")
- .Build();
-
- var nested = Scope(
- "sub",
- subBody,
- Blocking("subWork", log),
- Blocking("nudgeListener", log),
- Scope("evtSub", handlerBody, Immediate("handleNudge", log)));
-
- return Scope("scope", definition, nested, Immediate("after", log));
- }
-
- ///
- /// An error-triggered event subprocess whose body runs an ordinary embedded subprocess of its own, so the
- /// start-element hint has both a place to arrive and a place it must not reach.
- ///
- ///
- ///
- /// The body's only start event is event-defined, which is what makes the hint's arrival observable rather than
- /// merely asserted: seeded from the hint the body runs, and seeded as an ordinary direct invocation it faults
- /// deterministically with bpmn.start.none-available, because there is no none start event to begin at.
- ///
- ///
- /// The nested inner subprocess is the other direction. Its own invocation carries an ordinary scheduling
- /// cause, so the hint must not be inherited: were it, the inner process would be seeded at an element it does not
- /// declare and fault with bpmn.start.unresolved-hint instead of starting at its own none start event.
- ///
- ///
- public static BpmnProcess EventSubprocessBodyWithNestedSubprocess(BpmnTestLog log)
- {
- var innerBody = new BpmnProcessBuilder("event-subprocess-inner-body")
- .StartEvent("innerStart")
- .Task("innerOnly", bindingRef: BindingRef("innerOnly"))
- .EndEvent("innerEnd")
- .ConnectSequence("innerStart", "innerOnly", "innerEnd")
- .Build();
-
- var body = new BpmnProcessBuilder("hinted-event-subprocess-body")
- .Element(EventSubprocessStart("errStart", Error()))
- .Task("handleError", bindingRef: BindingRef("handleError"))
- .SubProcess("inner", bindingRef: BindingRef("inner"))
- .EndEvent("errEnd")
- .ConnectSequence("errStart", "handleError", "inner", "errEnd")
- .Build();
-
- var definition = new BpmnProcessBuilder("event-subprocess-start-hint")
- .StartEvent("start")
- .Task("risky", bindingRef: BindingRef("risky"))
- .EndEvent("end")
- .Element(EventSubprocess("evtSub"))
- .ConnectSequence("start", "risky", "end")
- .Build();
-
- var handler = Scope("evtSub", body, Immediate("handleError", log), Scope("inner", innerBody, Immediate("innerOnly", log)));
-
- return Scope("scope", definition, Faulting("risky", log), handler);
- }
-
- /// An event subprocess whose body declares two start events, which the library refuses.
- public static BpmnProcess EventSubprocessBodyWithTwoStartEvents(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("two-start-event-subprocess-body")
- .Element(EventSubprocessStart("errStart", Error()))
- .StartEvent("alsoStart")
- .Task("handleError", bindingRef: BindingRef("handleError"))
- .EndEvent("errEnd")
- .ConnectSequence("errStart", "handleError", "errEnd")
- .Connect("alsoStart", "handleError")
- .Build();
-
- return RefusedEventSubprocessScope("two-start-events", log, ("evtSub", body, "handleError"));
- }
-
- /// Two error-triggered event subprocesses in one scope, which the library refuses.
- public static BpmnProcess TwoErrorEventSubprocesses(BpmnTestLog log)
- {
- BpmnProcessDefinition Body(string prefix) => new BpmnProcessBuilder($"{prefix}-error-event-subprocess-body")
- .Element(EventSubprocessStart($"{prefix}Start", Error()))
- .Task($"{prefix}Handle", bindingRef: BindingRef($"{prefix}Handle"))
- .EndEvent($"{prefix}End")
- .ConnectSequence($"{prefix}Start", $"{prefix}Handle", $"{prefix}End")
- .Build();
-
- return RefusedEventSubprocessScope(
- "two-error-event-subprocesses",
- log,
- ("evtSubA", Body("first"), "firstHandle"),
- ("evtSubB", Body("second"), "secondHandle"));
- }
-
- /// Two code-less catch-all escalation-triggered event subprocesses in one scope, which the library refuses.
- public static BpmnProcess TwoCatchAllEscalationEventSubprocesses(BpmnTestLog log)
- {
- BpmnProcessDefinition Body(string prefix) => new BpmnProcessBuilder($"{prefix}-escalation-event-subprocess-body")
- .Element(EventSubprocessStart($"{prefix}Start", Escalation(), interrupting: false))
- .Task($"{prefix}Handle", bindingRef: BindingRef($"{prefix}Handle"))
- .EndEvent($"{prefix}End")
- .ConnectSequence($"{prefix}Start", $"{prefix}Handle", $"{prefix}End")
- .Build();
-
- return RefusedEventSubprocessScope(
- "two-catch-all-escalation-event-subprocesses",
- log,
- ("evtSubA", Body("first"), "firstHandle"),
- ("evtSubB", Body("second"), "secondHandle"));
- }
-
- /// A non-interrupting error-triggered event subprocess, which is not legal BPMN and which the library refuses.
- public static BpmnProcess NonInterruptingErrorEventSubprocess(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("non-interrupting-error-event-subprocess-body")
- .Element(EventSubprocessStart("errStart", Error(), interrupting: false))
- .Task("handleError", bindingRef: BindingRef("handleError"))
- .EndEvent("errEnd")
- .ConnectSequence("errStart", "handleError", "errEnd")
- .Build();
-
- return RefusedEventSubprocessScope("non-interrupting-error-event-subprocess", log, ("evtSub", body, "handleError"));
- }
-
- ///
- /// The start/only/end graph the refusal processes share, carrying the event subprocesses whose declaration
- /// the library refuses. Nothing in it ever runs: the refusal is raised when the scope builds its graph, which is
- /// before any work is started.
- ///
- private static BpmnProcess RefusedEventSubprocessScope(string processId, BpmnTestLog log, params (string ElementId, BpmnProcessDefinition Body, string HandlerId)[] eventSubprocesses)
- {
- var builder = new BpmnProcessBuilder(processId)
- .StartEvent("start")
- .Task("only", bindingRef: BindingRef("only"))
- .EndEvent("end")
- .ConnectSequence("start", "only", "end");
-
- foreach (var eventSubprocess in eventSubprocesses)
- builder = builder.Element(EventSubprocess(eventSubprocess.ElementId));
-
- var work = new List { Immediate("only", log) };
-
- work.AddRange(eventSubprocesses.Select(eventSubprocess => Scope(eventSubprocess.ElementId, eventSubprocess.Body, Immediate(eventSubprocess.HandlerId, log))));
-
- return Scope("scope", builder.Build(), work.ToArray());
- }
-
- /// An embedded subprocess with one task in it, and one task after it in the enclosing scope.
- public static BpmnProcess NestedSubprocess(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("nested-subprocess-body")
- .StartEvent("subStart")
- .Task("subOnly", bindingRef: BindingRef("subOnly"))
- .EndEvent("subEnd")
- .ConnectSequence("subStart", "subOnly", "subEnd")
- .Build();
-
- var definition = new BpmnProcessBuilder("nested-subprocess")
- .StartEvent("start")
- .SubProcess("sub", bindingRef: BindingRef("sub"))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .ConnectSequence("start", "sub", "after", "end")
- .Build();
-
- return Scope("scope", definition, Scope("sub", body, Immediate("subOnly", log)), Immediate("after", log));
- }
-
- ///
- /// A parallel split and join, blocking on both branches, nested inside an embedded subprocess. Used to prove
- /// a nested scope's own ledger -- not just a root scope's -- matches each completion back to its
- /// binding after a round trip through Elsa's own serializer.
- ///
- public static BpmnProcess NestedParallelSplitAndJoinBlocking(BpmnTestLog log)
- {
- var body = new BpmnProcessBuilder("nested-parallel-split-and-join-body")
- .StartEvent("subStart")
- .ParallelGateway("subSplit")
- .Task("subLeft", bindingRef: BindingRef("subLeft"))
- .Task("subRight", bindingRef: BindingRef("subRight"))
- .ParallelGateway("subJoin")
- .Task("subAfter", bindingRef: BindingRef("subAfter"))
- .EndEvent("subEnd")
- .ConnectSequence("subStart", "subSplit")
- .Connect("subSplit", "subLeft")
- .Connect("subSplit", "subRight")
- .Connect("subLeft", "subJoin")
- .Connect("subRight", "subJoin")
- .ConnectSequence("subJoin", "subAfter", "subEnd")
- .Build();
-
- var definition = new BpmnProcessBuilder("nested-parallel-split-and-join-blocking")
- .StartEvent("start")
- .SubProcess("sub", bindingRef: BindingRef("sub"))
- .Task("after", bindingRef: BindingRef("after"))
- .EndEvent("end")
- .ConnectSequence("start", "sub", "after", "end")
- .Build();
-
- var nested = Scope("sub", body, Blocking("subLeft", log), Blocking("subRight", log), Immediate("subAfter", log));
-
- return Scope("scope", definition, nested, Immediate("after", log));
- }
-
- /// A linear process: one task between a start and an end event.
- public static BpmnProcess LinearTask(BpmnTestLog log)
- {
- var definition = new BpmnProcessBuilder("linear-task")
- .StartEvent("start")
- .Task("only", bindingRef: BindingRef("only"))
- .EndEvent("end")
- .ConnectSequence("start", "only", "end")
- .Build();
-
- return Scope("scope", definition, Immediate("only", log));
- }
-
/// The binding ref the given element's work is declared under.
public static string BindingRef(string elementId) => $"node-{elementId}";
- /// The name of the variable loops over.
- public const string CollectionVariableName = "items";
-
private static BpmnEventDefinition Timer() => new(BpmnEventDefinitionTypes.Timer);
private static BpmnEventDefinition Cancel() => new(BpmnEventDefinitionTypes.Cancel);