diff --git a/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnActivityBindingFormat.cs b/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnActivityBindingFormat.cs new file mode 100644 index 000000000..f1c920230 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnActivityBindingFormat.cs @@ -0,0 +1,277 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bpmn.Model; +using Elsa.Bpmn.Interchange.Exceptions; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; + +namespace Elsa.Bpmn.Interchange.Binding; + +/// +/// The elsa: vendor extension that records which Elsa activity performs a BPMN task the document describes but +/// does not implement, and the one place the names making up that format are defined. +/// +/// +/// +/// Where it lives. Inside the BPMN document, as a vendor extension on the element it binds — not in a side +/// envelope. An exported .bpmn is therefore self-contained and re-importable by itself. Bpmn.Interchange +/// retains any extension element it does not own as typed foreign content and writes it back where it came from, which +/// is what makes this survive a read-modify-write cycle; the library never interprets it. +/// +/// +/// The format. Namespace URI https://elsaworkflows.io/schemas/bpmn/v1, conventional prefix elsa. +/// One <elsa:activityBinding> element inside the BPMN element's <bpmn:extensionElements>: +/// +/// +/// +/// activityType — attribute, required +/// +/// The Elsa activity type name as the activity registry keys it, e.g. Elsa.WriteLine. This is +/// , not a CLR type name. +/// +/// +/// +/// <elsa:input name="…"> — child element, zero or more, name unique within the binding +/// +/// One per configured activity input — every property reports +/// for the activity's CLR type, which is both every Input<T>-typed property and every plain-typed +/// property carrying [Input] (e.g. Switch.Cases); that is the same enumeration +/// ActivityDescriptor.Inputs is built from, so this format writes exactly the inputs Elsa itself considers +/// the activity to have. name is the input's property name as it appears in the activity's own JSON +/// (camelCase). The element's text is that single input value serialized by Elsa's configured activity +/// serializer — the same serializer a stored workflow definition is written and read through — so its shape +/// depends on how the activity declares the input: an Input<T>-typed property carries the +/// {"typeName":…,"expression":…} wrapper, which is what makes every expression type Elsa knows about +/// round-trip unchanged, while a plain-typed property carrying [Input] (e.g. Switch.Cases) carries +/// that value's own JSON — a collection such as Switch.Cases exports as a JSON array, not the wrapper. +/// No second encoding of activity inputs has to be kept in step with Elsa's own; an implementer must not assume +/// a single wrapper shape, only that the text is whatever Elsa's activity serializer produced for that input and +/// that reading it back through the same serializer reconstructs it. A second <elsa:input> naming an +/// input already declared is refused rather than silently taking the later one, and a name the activity +/// type does not declare an input for is refused rather than importing an activity quietly missing that configuration — Elsa's +/// deserializer ignores an unknown JSON member without complaint, so nothing else would ever say so. Both are +/// refused the same way an unregistered activity type or a call activity with no calledElement is refused +/// elsewhere in this binder. +/// +/// +/// +/// +/// Escaping. An input's text is JSON, carried as ordinary XML element text — not wrapped in +/// <![CDATA[…]]>. <, > and & inside the JSON (for example, inside a string +/// literal) are therefore XML-escaped as &lt;, &gt; and &amp; the way any XML text +/// node escapes them; Bpmn.Interchange reads and writes this content through System.Xml.Linq, whose +/// standard text-node escaping decodes it back to the original JSON automatically. Nothing else needs to escape or +/// unescape this text: hands the writer plain JSON, and reads +/// already decoded. +/// +/// Example, an Input<T>-typed input wrapped as an expression: +/// +/// <bpmn:serviceTask id="notify"> +/// <bpmn:extensionElements> +/// <elsa:activityBinding activityType="Elsa.WriteLine"> +/// <elsa:input name="text">{"typeName":"String","expression":{"type":"JavaScript","value":"getMessage()"}}</elsa:input> +/// </elsa:activityBinding> +/// </bpmn:extensionElements> +/// </bpmn:serviceTask> +/// +/// Example, an [Input]-attributed plain-typed input carrying its own JSON shape (here, an array): +/// +/// <bpmn:exclusiveGateway id="route"> +/// <bpmn:extensionElements> +/// <elsa:activityBinding activityType="Elsa.Switch"> +/// <elsa:input name="cases">[{"label":"case one","condition":{"type":"Literal","value":true}}]</elsa:input> +/// </elsa:activityBinding> +/// </bpmn:extensionElements> +/// </bpmn:exclusiveGateway> +/// +/// +/// Position is the key. The element it sits inside is the element it binds; nothing records a binding ref. +/// That is deliberate: a binding ref is derived by the reader from the element id and a configurable prefix +/// (BpmnImportOptions.BindingRefPrefix), so writing one into the document would make an exported file depend on +/// the import setting that happened to be in force when it was produced. +/// +/// +/// This is a compatibility surface. Changing , , +/// , or +/// breaks every previously exported .bpmn file: the old element stops being recognised and silently becomes +/// unrelated foreign content, which reads back as a task nobody bound. Anything that reads or writes this shape — +/// Elsa Studio included — has to agree with these constants, so a change here is a versioning decision, not a rename. +/// +/// +/// Disclosure. An exported .bpmn carries the binding configuration verbatim, which includes input +/// expressions: literal values, JavaScript, C#, Liquid, connection or endpoint names — whatever the author put on the +/// activity. Exporting a process is therefore a disclosure of its implementation detail, and a .bpmn from a +/// production tenant should be handled with the same care as the workflow definition it was built from. Nothing is +/// redacted, on purpose: a quietly redacted export produces a file that still looks executable and is not, and the +/// failure only shows up as wrong behaviour after someone re-imports it. +/// +/// +public sealed class BpmnActivityBindingFormat(IActivitySerializer activitySerializer, IActivityDescriber activityDescriber) +{ + /// The namespace URI of the elsa: BPMN vendor extension. See the remarks on this type before changing it. + public const string NamespaceUri = "https://elsaworkflows.io/schemas/bpmn/v1"; + + /// The conventional prefix for . Cosmetic in XML, but used verbatim in diagnostics. + public const string NamespacePrefix = "elsa"; + + /// The local name of the binding element. + public const string BindingElementName = "activityBinding"; + + /// The local name of the attribute naming the Elsa activity type. + public const string ActivityTypeAttributeName = "activityType"; + + /// The local name of a single-input child element. + public const string InputElementName = "input"; + + /// The local name of the attribute naming the input an element configures. + public const string InputNameAttributeName = "name"; + + private static readonly BpmnQName BindingQName = new(NamespaceUri, BindingElementName); + private static readonly BpmnQName InputQName = new(NamespaceUri, InputElementName); + + /// + /// The activity binding declared on the given retained content, or null when it declares none. + /// + public static BpmnExtensionElement? Find(BpmnExtensions? extensions) => + extensions?.ExtensionElements.FirstOrDefault(element => element.Name == BindingQName); + + /// + /// The given retained content with as its activity binding, replacing any it already + /// carried and leaving every other retained element in place. + /// + /// + /// Adding rather than replacing would leave two activityBinding elements on one BPMN element, and a reader + /// taking the first of them would silently apply the older one. + /// + public static BpmnExtensions Attach(BpmnExtensions? extensions, BpmnExtensionElement binding) + { + extensions ??= BpmnExtensions.Empty; + + return extensions with + { + ExtensionElements = extensions.ExtensionElements.Where(element => element.Name != BindingQName).Append(binding).ToList() + }; + } + + /// + /// The binding element declaring that performs the work, with each of its configured + /// inputs serialized. + /// + public BpmnExtensionElement Write(IActivity activity) + { + // The same set IActivityDescriber.DescribeActivityAsync builds an ActivityDescriptor.Inputs from — every + // property carrying an Input, and every plain-typed property carrying [Input] (e.g. Switch.Cases). Filtering + // on "derives from Input" alone, as this used to, silently drops the latter kind's configuration from the + // export. + // + // Ordered by name so that exporting the same activity twice produces the same bytes, which is what makes a + // .bpmn file diffable and a round-trip test meaningful. + var inputs = activityDescriber.GetInputProperties(activity.GetType()) + .Select(property => (Name: JsonNamingPolicy.CamelCase.ConvertName(property.Name), Value: property.GetValue(activity))) + .Where(input => input.Value is not null) + .OrderBy(input => input.Name, StringComparer.Ordinal) + .Select(input => new BpmnExtensionElement(InputQName, [Attribute(InputNameAttributeName, input.Name)], null, activitySerializer.Serialize(input.Value!))) + .ToList(); + + return new(BindingQName, [Attribute(ActivityTypeAttributeName, activity.Type)], inputs); + } + + /// + /// The activity a binding element declares, built through Elsa's own activity serializer so that it is + /// indistinguishable from the same activity loaded out of a stored workflow definition. + /// + /// + /// The element is malformed, names an activity type nothing registered, or names an input the activity type does + /// not declare. + /// + public IActivity Read(BpmnExtensionElement element) + { + var activityType = AttributeOf(element, ActivityTypeAttributeName) + ?? throw new BpmnBindingException($"An <{NamespacePrefix}:{BindingElementName}> element declares no '{ActivityTypeAttributeName}', so there is nothing to build."); + + var activityJson = new JsonObject + { + ["type"] = activityType + }; + + // Every name seen so far, in the order the document declares them, so a second with the same + // name is refused rather than silently overwriting activityJson[name] and leaving the earlier one's + // configuration invisible. + var seenInputNames = new List(); + var seenInputNameSet = new HashSet(StringComparer.Ordinal); + + foreach (var input in element.Children.Where(child => child.Name == InputQName)) + { + var name = AttributeOf(input, InputNameAttributeName) + ?? throw new BpmnBindingException($"An <{NamespacePrefix}:{InputElementName}> element of the '{activityType}' binding declares no '{InputNameAttributeName}'."); + + if (!seenInputNameSet.Add(name)) + throw new BpmnBindingException($"The '{activityType}' binding declares the input '{name}' more than once. Each <{NamespacePrefix}:{InputElementName}> must name a distinct input."); + + seenInputNames.Add(name); + activityJson[name] = Parse(input.Value, name, activityType); + } + + IActivity activity; + + try + { + activity = activitySerializer.Deserialize(activityJson.ToJsonString()); + } + catch (Exception exception) when (exception is JsonException or NotSupportedException) + { + throw new BpmnBindingException($"The binding to activity type '{activityType}' could not be deserialized: {exception.Message}"); + } + + // Elsa's activity serializer answers an unregistered type with a NotFoundActivity rather than throwing, and + // that placeholder only fails once it executes — by which time the workflow has already started and the + // process is mid-flight. Refusing at bind time turns "this .bpmn needs a module you have not installed" into a + // sentence naming the type, at the point where someone can still do something about it. + if (activity is NotFoundActivity) + throw new BpmnBindingException($"The binding names activity type '{activityType}', which is not registered in this application. Install or enable the module providing it before importing this document."); + + // Elsa's own JSON deserialization ignores a member the target type does not declare, so a mistyped or + // stale input name would otherwise import silently as an activity missing that configuration, with no + // diagnostic anywhere. IActivityDescriber.GetInputProperties is the same enumeration Write reads from and + // ActivityDescriptor.Inputs is built from, so a name is accepted here exactly when Write could have produced + // it. + if (seenInputNames.Count > 0) + { + var declaredInputNames = activityDescriber.GetInputProperties(activity.GetType()) + .Select(property => JsonNamingPolicy.CamelCase.ConvertName(property.Name)) + .ToHashSet(StringComparer.Ordinal); + + var undeclaredInputNames = seenInputNames.Where(name => !declaredInputNames.Contains(name)).ToList(); + + if (undeclaredInputNames.Count > 0) + { + var noun = undeclaredInputNames.Count == 1 ? "an input" : "inputs"; + var names = string.Join(", ", undeclaredInputNames.Select(name => $"'{name}'")); + + throw new BpmnBindingException($"The '{activityType}' binding declares {noun} {names}, which '{activityType}' does not have."); + } + } + + return activity; + } + + private static JsonNode? Parse(string? json, string inputName, string activityType) + { + try + { + return JsonNode.Parse(json ?? "null"); + } + catch (JsonException exception) + { + throw new BpmnBindingException($"Input '{inputName}' of the '{activityType}' binding does not hold valid JSON: {exception.Message}"); + } + } + + private static BpmnForeignAttribute Attribute(string name, string value) => new(new(null, name), value); + + // An unprefixed XML attribute belongs to no namespace, which is what the reader records for these; comparing on + // the local name alone would also match a same-named attribute some other vendor put in its own namespace. + private static string? AttributeOf(BpmnExtensionElement element, string name) => + element.Attributes.FirstOrDefault(attribute => string.IsNullOrEmpty(attribute.Name.Namespace) && attribute.Name.LocalName == name)?.Value; +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnWorkBinder.cs b/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnWorkBinder.cs new file mode 100644 index 000000000..00ab2de66 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnWorkBinder.cs @@ -0,0 +1,177 @@ +using System.Xml; +using Bpmn.Interchange; +using Bpmn.Model; +using Elsa.Bpmn.Activities; +using Elsa.Bpmn.Interchange.Exceptions; +using Elsa.Scheduling.Activities; +using Elsa.Workflows; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Runtime.Activities; + +namespace Elsa.Bpmn.Interchange.Binding; + +/// +/// Turns the reader's declarations into the Elsa activities a +/// scope runs. This is the seam where BPMN vocabulary becomes Elsa work. +/// +/// +/// +/// Six of the seven binding kinds bind to an activity this repository already has. Only +/// is an authoring decision, which is right: BPMN genuinely does not say +/// what a serviceTask does. Its answer is read from the elsa:activityBinding extension element inside +/// the document — see , including what exporting one discloses. +/// +/// +/// Every binding for a scope is bound, whatever its slot. A +/// binding — the listener an event subprocess arms while its enclosing scope runs — is an entry in the same +/// map as any other, under its own binding ref. It needs no special case here +/// because the interpreter arms it at scope start on its own; singling it out would be a second code path with +/// nothing different to do. +/// +/// +/// Distinct activity instances per scope. A binding ref is unique within a scope, not across scopes, so two +/// scopes can legitimately declare the same one. Every binding gets its own freshly constructed activity, nothing is +/// cached or reused between scopes, and each activity is given a scope-qualified id. That is not a tidiness point: +/// ActivityVisitor collects activities into a set and skips one it has already seen, so a single instance +/// appearing under two scopes becomes one node in Elsa's identity graph and the second scope's child is missing from +/// it — a graph that builds, publishes, and then never runs half the process. +/// +/// +/// This binder lives in Elsa.Bpmn.Interchange rather than Elsa.Bpmn because +/// is a Bpmn.Interchange type: binding it in Elsa.Bpmn would pull the interchange library into the +/// execution module's dependency closure, which is exactly the package split D12 draws. The direction it needs is +/// available — Elsa.Bpmn.Interchange already references Elsa.Bpmn, so and the +/// four activity targets are all in reach. +/// +/// +/// Nothing here decides root position: is left off on every scope this produces, +/// including the outermost one, so a bound process is safe to nest by default and whoever composes it into a workflow +/// says explicitly that it is an entry point. +/// +/// +public sealed class BpmnWorkBinder(BpmnActivityBindingFormat format) +{ + /// + /// Binds one process definition, and every process nested inside it, into a scope. + /// + /// The process to bind. + /// Every binding the read produced, across all processes in the document. + /// A binding cannot be turned into an activity. + public BpmnProcess Bind(BpmnProcessDefinition definition, IReadOnlyCollection bindings) + { + var scope = BindScope(definition, bindings); + scope.Id = definition.ProcessId; + return scope; + } + + private BpmnProcess BindScope(BpmnProcessDefinition definition, IReadOnlyCollection bindings) + { + var scope = new BpmnProcess + { + Process = definition + }; + + // A document-declared variable is invisible to the interpreter's IBpmnVariableReader port until it resolves + // through Elsa's own ExpressionExecutionContext.GetVariable, which walks Container.Variables — not + // BpmnProcessDefinition.Variables. Declaring one here for each is what makes a collection-mode multi-instance + // over a document-declared collection readable instead of Absent. The declared default, when there is one, + // travels as the JsonElement it already is: the reader serializes whatever the memory block holds, so nothing + // here needs to interpret BpmnVariableDeclaration.TypeHint. + foreach (var declaration in definition.Variables) + { + scope.Variables.Add(new Variable(declaration.Name, declaration.DefaultValue is { } defaultValue ? (object)defaultValue : null)); + } + + // Element ids whose elsa:activityBinding was actually used. A declaration nothing consumed is refused below. + var consumed = new HashSet(StringComparer.Ordinal); + + foreach (var binding in bindings.Where(binding => string.Equals(binding.ProcessId, definition.ProcessId, StringComparison.Ordinal))) + { + var activity = CreateActivity(definition, binding, bindings, consumed); + + // Scope-qualified and deterministic. The binding ref alone is unique only within its scope, and an id that + // repeats across scopes gives two logical positions one identity in bookmarks and persisted state. + activity.Id = $"{binding.ProcessId}:{binding.BindingRef}"; + + scope.Activities.Add(activity); + scope.WorkBindings[binding.BindingRef] = activity.Id; + } + + RefuseUnusedDeclarations(definition, consumed); + + return scope; + } + + private IActivity CreateActivity(BpmnProcessDefinition definition, BpmnWorkBinding binding, IReadOnlyCollection bindings, ISet consumed) => + binding switch + { + BpmnWorkBinding.TimerWait timer => new Delay(IsoDurationOf(timer)), + BpmnWorkBinding.MessageWait message => new Event(message.MessageName), + BpmnWorkBinding.SignalWait signal => new Event(signal.SignalName), + BpmnWorkBinding.MessagePublish publish => new PublishEvent + { + EventName = new(publish.MessageName) + }, + BpmnWorkBinding.CallProcess call => new DispatchWorkflow + { + WorkflowDefinitionId = new(CalledElementOf(call)), + WaitForCompletion = new(call.WaitForCompletion) + }, + BpmnWorkBinding.NestedProcess nested => BindScope(nested.Definition, bindings), + BpmnWorkBinding.UnboundTask unbound => ReadDeclaredActivity(definition, unbound, consumed), + // The binding hierarchy is closed, so this is reachable only from a library version that added a kind this + // binder has never heard of. Skipping it would produce a scope whose interpreter starts work nothing maps. + _ => throw new BpmnBindingException($"The BPMN work binding kind '{binding.GetType().Name}' declared by element '{binding.ElementId}' is not supported by this binder.") + }; + + private IActivity ReadDeclaredActivity(BpmnProcessDefinition definition, BpmnWorkBinding.UnboundTask unbound, ISet consumed) + { + var element = definition.Elements.FirstOrDefault(element => string.Equals(element.ElementId, unbound.ElementId, StringComparison.Ordinal)); + + if (BpmnActivityBindingFormat.Find(element?.Extensions) is not { } declaration) + { + throw new BpmnBindingException( + $"BPMN element '{unbound.ElementId}' of process '{unbound.ProcessId}' is a '{unbound.TaskType}': the document says what it is for, not how to perform it, and nothing binds it to an Elsa activity. " + + $"Declare one with an <{BpmnActivityBindingFormat.NamespacePrefix}:{BpmnActivityBindingFormat.BindingElementName}> element inside the element's ."); + } + + consumed.Add(unbound.ElementId); + + return format.Read(declaration); + } + + /// + /// Refuses an elsa:activityBinding on an element that has no unbound task to bind. + /// + /// + /// Six of the seven kinds bind on their own and never consult a declaration, so one written on a timer, a + /// subprocess or a gateway configures nothing. Ignoring it is the quiet answer: the author sees their expression in + /// the file, the process runs, and the activity they configured never executes. Refusing says so. + /// + private static void RefuseUnusedDeclarations(BpmnProcessDefinition definition, ISet consumed) + { + foreach (var element in definition.Elements.Where(element => BpmnActivityBindingFormat.Find(element.Extensions) is not null && !consumed.Contains(element.ElementId))) + { + throw new BpmnBindingException( + $"BPMN element '{element.ElementId}' ({element.ElementType}) of process '{definition.ProcessId}' carries an <{BpmnActivityBindingFormat.NamespacePrefix}:{BpmnActivityBindingFormat.BindingElementName}> element, but its work is not an unbound task, so nothing would ever run it. " + + "Only a task the document describes without implementing takes an authored activity binding."); + } + } + + private static TimeSpan IsoDurationOf(BpmnWorkBinding.TimerWait timer) + { + try + { + return XmlConvert.ToTimeSpan(timer.IsoDuration); + } + catch (Exception exception) when (exception is FormatException or OverflowException or ArgumentNullException) + { + throw new BpmnBindingException($"BPMN element '{timer.ElementId}' declares the timer duration '{timer.IsoDuration}', which is not an ISO-8601 duration Elsa can wait for."); + } + } + + private static string CalledElementOf(BpmnWorkBinding.CallProcess call) => + !string.IsNullOrWhiteSpace(call.CalledElement) + ? call.CalledElement + : throw new BpmnBindingException($"BPMN element '{call.ElementId}' is a call activity that names no calledElement, so there is no workflow definition to dispatch."); +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Exceptions/BpmnBindingException.cs b/src/modules/Elsa.Bpmn.Interchange/Exceptions/BpmnBindingException.cs new file mode 100644 index 000000000..3bea8f624 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Exceptions/BpmnBindingException.cs @@ -0,0 +1,12 @@ +namespace Elsa.Bpmn.Interchange.Exceptions; + +/// +/// Thrown when a BPMN document's work bindings cannot be turned into Elsa activities. +/// +/// +/// Every refusal this type carries is a design-time one: the document, or the elsa:activityBinding in it, does +/// not say enough to build a runnable graph. Refusing here rather than binding something plausible is deliberate — the +/// alternatives (an unbound task quietly dropped, a call activity dispatching nothing, a timer of zero) all produce a +/// workflow that runs to completion looking healthy while doing none of what the document says. +/// +public class BpmnBindingException(string message) : Exception(message); diff --git a/src/modules/Elsa.Bpmn.Interchange/Features/BpmnInterchangeFeature.cs b/src/modules/Elsa.Bpmn.Interchange/Features/BpmnInterchangeFeature.cs index 66a368b5c..f30e2de77 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Features/BpmnInterchangeFeature.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Features/BpmnInterchangeFeature.cs @@ -1,13 +1,21 @@ using Elsa.Bpmn.Features; +using Elsa.Bpmn.Interchange.Binding; using Elsa.Features.Abstractions; using Elsa.Features.Attributes; using Elsa.Features.Services; +using Microsoft.Extensions.DependencyInjection; namespace Elsa.Bpmn.Interchange.Features; /// /// Provides BPMN XML interchange support to the system. /// +/// +/// The task-to-activity binding this module reads lives inside the BPMN document as an elsa: vendor extension, +/// so an exported .bpmn carries binding configuration — including input expressions — verbatim. Exporting a +/// process discloses its implementation detail; see for the format and for why +/// nothing is redacted. +/// [DependsOn(typeof(BpmnFeature))] public class BpmnInterchangeFeature : FeatureBase { @@ -15,4 +23,11 @@ public class BpmnInterchangeFeature : FeatureBase public BpmnInterchangeFeature(IModule module) : base(module) { } + + /// + public override void Apply() + { + Services.AddSingleton(); + Services.AddSingleton(); + } } diff --git a/src/modules/Elsa.Bpmn.Interchange/ShellFeatures/BpmnInterchangeFeature.cs b/src/modules/Elsa.Bpmn.Interchange/ShellFeatures/BpmnInterchangeFeature.cs index 074179929..2d0297359 100644 --- a/src/modules/Elsa.Bpmn.Interchange/ShellFeatures/BpmnInterchangeFeature.cs +++ b/src/modules/Elsa.Bpmn.Interchange/ShellFeatures/BpmnInterchangeFeature.cs @@ -1,4 +1,5 @@ using CShells.Features; +using Elsa.Bpmn.Interchange.Binding; using Elsa.Bpmn.ShellFeatures; using Elsa.Common.ShellFeatures; using JetBrains.Annotations; @@ -9,6 +10,12 @@ namespace Elsa.Bpmn.Interchange.ShellFeatures; /// /// Provides BPMN XML interchange support to the system. /// +/// +/// The task-to-activity binding this module reads lives inside the BPMN document as an elsa: vendor extension, +/// so an exported .bpmn carries binding configuration — including input expressions — verbatim. Exporting a +/// process discloses its implementation detail; see for the format and for why +/// nothing is redacted. +/// [ShellFeature( DisplayName = "BPMN Interchange", Description = "Provides BPMN XML interchange (import/export) capabilities for workflows.", @@ -18,5 +25,7 @@ public class BpmnInterchangeFeature : IShellFeature { public void ConfigureServices(IServiceCollection services) { + services.AddSingleton(); + services.AddSingleton(); } } diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnActivityBindingFormatTests.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnActivityBindingFormatTests.cs new file mode 100644 index 000000000..7bf2dacb8 --- /dev/null +++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnActivityBindingFormatTests.cs @@ -0,0 +1,175 @@ +using Bpmn.Model; +using Elsa.Bpmn.Interchange.Binding; +using Elsa.Bpmn.Interchange.Exceptions; +using Elsa.Expressions.Models; +using Elsa.Workflows.Activities; +using Xunit.Abstractions; + +namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding; + +/// +/// The elsa:activityBinding extension element: what it holds, and that what goes in comes back out. +/// +/// +/// This element is a compatibility surface — Elsa Studio writes it and every exported .bpmn is bound to it — +/// so the round-trip is asserted on the element's own shape, not only on the activity that comes back. A change to a +/// name or to how an input is encoded shows up here as a failing assertion rather than as files that stop importing. +/// +public class BpmnActivityBindingFormatTests(ITestOutputHelper testOutputHelper) : BpmnBindingTestBase(testOutputHelper) +{ + [Fact(DisplayName = "A written binding names the activity type and carries one element per configured input")] + public void Write_ProducesTheDocumentedShape() + { + var element = Format.Write(new WriteLine("hello")); + + Assert.Equal(new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName), element.Name); + Assert.Equal("Elsa.WriteLine", AttributeOf(element, BpmnActivityBindingFormat.ActivityTypeAttributeName)); + + var input = Assert.Single(element.Children); + + Assert.Equal(new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.InputElementName), input.Name); + Assert.Equal("text", AttributeOf(input, BpmnActivityBindingFormat.InputNameAttributeName)); + Assert.Contains("\"typeName\"", input.Value); + Assert.Contains("hello", input.Value); + } + + [Fact(DisplayName = "A binding written then read back carries its literal inputs intact")] + public void WriteThenRead_PreservesALiteralInput() + { + var element = Format.Write(new WriteLine("hello")); + + var activity = Assert.IsType(Format.Read(element)); + + Assert.Equal("Elsa.WriteLine", activity.Type); + Assert.Equal("hello", ValueOf(activity.Text)); + } + + [Fact(DisplayName = "A binding written then read back carries an expression, not just a literal")] + public void WriteThenRead_PreservesAnExpression() + { + // The reason the disclosure note on BpmnActivityBindingFormat is not theoretical: an exported .bpmn carries + // expression source verbatim. It also proves the encoding is Elsa's own input JSON rather than a value dump — + // a value-only format would silently degrade this input to a literal, or to nothing. + var element = Format.Write(new WriteLine(new Expression("JavaScript", "getSecretMessage()"))); + + Assert.Contains("getSecretMessage()", Assert.Single(element.Children).Value); + + var activity = Assert.IsType(Format.Read(element)); + + Assert.Equal("JavaScript", activity.Text.Expression!.Type); + Assert.Equal("getSecretMessage()", ValueOf(activity.Text)); + } + + [Fact(DisplayName = "A binding written then read back carries an attribute-declared input intact, not only an Input-typed one")] + public void WriteThenRead_PreservesAnAttributeDeclaredInput() + { + // Switch.Cases is ICollection: [Input] on a plain-typed property, not one that derives from Input. + // Write used to enumerate only properties whose CLR type derives from Input, which silently dropped this kind + // of configuration from the export. + var activity = new Switch + { + Cases = { new SwitchCase { Label = "case one", Condition = new Expression("Literal", true) } } + }; + + var element = Format.Write(activity); + + Assert.Contains(element.Children, child => AttributeOf(child, BpmnActivityBindingFormat.InputNameAttributeName) == "cases"); + + var read = Assert.IsType(Format.Read(element)); + var readCase = Assert.Single(read.Cases); + + Assert.Equal("case one", readCase.Label); + Assert.Equal(true, readCase.Condition.Value); + } + + [Fact(DisplayName = "A binding naming an input the activity type does not declare is refused, not silently dropped")] + public void Read_RefusesAnUnknownInputName() + { + // Elsa's own deserializer ignores a JSON member the target type does not declare, so a mistyped or stale + // input name would otherwise import as an activity quietly missing that configuration, with no diagnostic + // anywhere. WriteLine declares "text", not "txt". + var inputName = new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.InputElementName); + var nameAttribute = Attribute(BpmnActivityBindingFormat.InputNameAttributeName, "txt"); + + var element = new BpmnExtensionElement( + new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName), + [Attribute(BpmnActivityBindingFormat.ActivityTypeAttributeName, "Elsa.WriteLine")], + [new BpmnExtensionElement(inputName, [nameAttribute], null, "{\"typeName\":\"String\",\"expression\":{\"type\":\"Literal\",\"value\":\"hello\"}}")]); + + var exception = Assert.Throws(() => Format.Read(element)); + + Assert.Contains("txt", exception.Message); + } + + [Fact(DisplayName = "A binding naming an activity type nothing registered is refused, not turned into a placeholder")] + public void Read_RefusesAnUnregisteredActivityType() + { + // Elsa answers an unknown activity type with a NotFoundActivity that only throws once it executes. Accepting + // it here would import cleanly, publish cleanly, and fail in the middle of a running process. + var element = new BpmnExtensionElement( + new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName), + [new(new(null, BpmnActivityBindingFormat.ActivityTypeAttributeName), "Contoso.NoSuchActivity")]); + + var exception = Assert.Throws(() => Format.Read(element)); + + Assert.Contains("Contoso.NoSuchActivity", exception.Message); + } + + [Fact(DisplayName = "A binding declaring no activity type is refused")] + public void Read_RefusesAnElementWithNoActivityType() + { + var element = new BpmnExtensionElement(new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName)); + + Assert.Throws(() => Format.Read(element)); + } + + [Fact(DisplayName = "A binding declaring the same input name twice is refused, not resolved last-wins")] + public void Read_RefusesADuplicateInputName() + { + // Two children for the same activity type. The second would otherwise silently win, + // leaving the author's first configuration in the file but never applied — exactly the quiet wrong answer + // this binder refuses everywhere else (an unbound task, a dead declaration, an unregistered activity type, a + // malformed timer duration, a call activity with no calledElement). + var inputName = new BpmnQName(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.InputElementName); + var nameAttribute = Attribute(BpmnActivityBindingFormat.InputNameAttributeName, "text"); + + var element = new BpmnExtensionElement( + new(BpmnActivityBindingFormat.NamespaceUri, BpmnActivityBindingFormat.BindingElementName), + [Attribute(BpmnActivityBindingFormat.ActivityTypeAttributeName, "Elsa.WriteLine")], + [ + new BpmnExtensionElement(inputName, [nameAttribute], null, "{\"typeName\":\"String\",\"expression\":{\"type\":\"Literal\",\"value\":\"first\"}}"), + new BpmnExtensionElement(inputName, [nameAttribute], null, "{\"typeName\":\"String\",\"expression\":{\"type\":\"Literal\",\"value\":\"second\"}}") + ]); + + var exception = Assert.Throws(() => Format.Read(element)); + + Assert.Contains("text", exception.Message); + } + + [Fact(DisplayName = "Attaching a binding replaces the previous one and leaves other retained content alone")] + public void Attach_ReplacesTheBindingAndKeepsForeignContent() + { + var foreign = new BpmnExtensionElement(new("http://camunda.org/schema/1.0/bpmn", "properties")); + var extensions = new BpmnExtensions(ExtensionElements: [foreign, Format.Write(new WriteLine("first"))]); + + var updated = BpmnActivityBindingFormat.Attach(extensions, Format.Write(new WriteLine("second"))); + + Assert.Contains(foreign, updated.ExtensionElements); + Assert.Equal("second", ValueOf(Assert.IsType(Format.Read(BpmnActivityBindingFormat.Find(updated)!)).Text)); + + // Two bindings on one element would leave a reader taking the first of them applying the older one. + Assert.Single(updated.ExtensionElements, element => element.Name.LocalName == BpmnActivityBindingFormat.BindingElementName); + } + + [Fact(DisplayName = "Retained content declaring no binding reports none")] + public void Find_ReturnsNullWhenNothingIsDeclared() + { + Assert.Null(BpmnActivityBindingFormat.Find(null)); + Assert.Null(BpmnActivityBindingFormat.Find(BpmnExtensions.Empty)); + } + + private static string? AttributeOf(BpmnExtensionElement element, string name) => + element.Attributes.FirstOrDefault(attribute => attribute.Name.LocalName == name)?.Value; + + private static BpmnForeignAttribute Attribute(string name, string value) => new(new(null, name), value); +} diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnBindingTestBase.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnBindingTestBase.cs new file mode 100644 index 000000000..38e487d85 --- /dev/null +++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnBindingTestBase.cs @@ -0,0 +1,58 @@ +using Bpmn.Interchange; +using Bpmn.Model; +using Elsa.Bpmn.Interchange.Binding; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding; + +/// +/// The application the binder and the elsa: extension format are exercised in. +/// +/// +/// A real container rather than substitutes: both halves go through Elsa's configured activity serializer and its +/// activity registry, and a stand-in for either would agree with whatever the test expected instead of with Elsa. +/// +public abstract class BpmnBindingTestBase : IAsyncLifetime +{ + private readonly IServiceProvider _services; + + protected BpmnBindingTestBase(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper) + .ConfigureElsa(elsa => elsa.UseBpmnInterchange()) + .Build(); + + Binder = _services.GetRequiredService(); + Format = _services.GetRequiredService(); + } + + protected BpmnWorkBinder Binder { get; } + + protected BpmnActivityBindingFormat Format { get; } + + /// The application the binder is exercised in, for a test that needs to run a bound scope end to end. + protected IServiceProvider Services => _services; + + public Task InitializeAsync() => _services.PopulateRegistriesAsync(); + + public Task DisposeAsync() => Task.CompletedTask; + + /// Elsa's own identity graph over the bound scope, which is what a published workflow is built from. + protected async Task> IdentityGraphOfAsync(IActivity root) => + (await _services.GetRequiredService().VisitAsync(root)).Flatten().ToList(); + + /// The binding ref the reader assigns to an element, using its default prefix. + protected static string Ref(string elementId) => $"{BpmnXmlReader.DefaultBindingRefPrefix}-{elementId}"; + + /// An element carrying an authored activity binding. + protected BpmnElement BoundElement(string elementId, string elementType, IActivity activity) => + new(elementId, elementType, bindingRef: Ref(elementId), extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(activity))); + + /// The literal or expression value an activity input carries. + protected static T ValueOf(Input input) => (T)input.Expression!.Value!; +} diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs new file mode 100644 index 000000000..a61064e73 --- /dev/null +++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Binding/BpmnWorkBinderTests.cs @@ -0,0 +1,280 @@ +using System.Text.Json; +using Bpmn.Interchange; +using Bpmn.Model; +using Elsa.Bpmn.Activities; +using Elsa.Bpmn.Interchange.Binding; +using Elsa.Bpmn.Interchange.Exceptions; +using Elsa.Scheduling.Activities; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Runtime.Activities; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding; + +/// +/// Turning BpmnWorkBinding declarations into the activities a BpmnProcess scope runs. +/// +public class BpmnWorkBinderTests(ITestOutputHelper testOutputHelper) : BpmnBindingTestBase(testOutputHelper) +{ + private const string ProcessId = "main"; + + [Fact(DisplayName = "A timer wait binds to a Delay of the declared ISO-8601 duration")] + public void TimerWait_BindsToADelay() + { + var scope = Bind(Definition(Element("wait", BpmnElementTypes.IntermediateCatchEvent)), Timer("wait", "PT5M")); + + var delay = Assert.IsType(WorkOf(scope, "wait")); + + Assert.Equal(TimeSpan.FromMinutes(5), ValueOf(delay.TimeSpan)); + } + + [Fact(DisplayName = "A timer wait declaring something that is not an ISO-8601 duration is refused")] + public void TimerWait_RefusesADurationElsaCannotWaitFor() + { + // A duration quietly defaulted to zero is a timer boundary event that fires the moment it is armed, which + // cancels the task it guards before that task has done anything. + var exception = Assert.Throws(() => Bind(Definition(Element("wait", BpmnElementTypes.IntermediateCatchEvent)), Timer("wait", "5 minutes"))); + + Assert.Contains("5 minutes", exception.Message); + } + + [Fact(DisplayName = "A message wait binds to an Event on the message name")] + public void MessageWait_BindsToAnEvent() + { + var scope = Bind( + Definition(Element("receive", BpmnElementTypes.ReceiveTask)), + new BpmnWorkBinding.MessageWait(ProcessId, "receive", Ref("receive"), BpmnBindingSlot.Primary, "OrderPlaced")); + + var @event = Assert.IsType(WorkOf(scope, "receive")); + + Assert.Equal("OrderPlaced", ValueOf(@event.EventName)); + + // A catch inside a scope is not a way into the workflow; only the process's own start events are, and saying + // so is the enclosing BpmnProcess's decision, not this activity's. + Assert.False(@event.CanStartWorkflow); + } + + [Fact(DisplayName = "A signal wait binds to an Event on the signal name")] + public void SignalWait_BindsToAnEvent() + { + var scope = Bind( + Definition(Element("await", BpmnElementTypes.IntermediateCatchEvent)), + new BpmnWorkBinding.SignalWait(ProcessId, "await", Ref("await"), BpmnBindingSlot.Primary, "Cancelled")); + + Assert.Equal("Cancelled", ValueOf(Assert.IsType(WorkOf(scope, "await")).EventName)); + } + + [Fact(DisplayName = "A message publish binds to a PublishEvent on the message name")] + public void MessagePublish_BindsToAPublishEvent() + { + var scope = Bind( + Definition(Element("send", BpmnElementTypes.SendTask)), + new BpmnWorkBinding.MessagePublish(ProcessId, "send", Ref("send"), BpmnBindingSlot.Primary, "OrderShipped")); + + Assert.Equal("OrderShipped", ValueOf(Assert.IsType(WorkOf(scope, "send")).EventName)); + } + + [Fact(DisplayName = "A call activity binds to a DispatchWorkflow on the called element, keeping fire-and-forget")] + public void CallProcess_BindsToADispatchWorkflow() + { + var scope = Bind( + Definition(Element("call", BpmnElementTypes.CallActivity)), + new BpmnWorkBinding.CallProcess(ProcessId, "call", Ref("call"), BpmnBindingSlot.Primary, "shipping-process", false)); + + var dispatch = Assert.IsType(WorkOf(scope, "call")); + + Assert.Equal("shipping-process", ValueOf(dispatch.WorkflowDefinitionId)); + + // BPMN has no standard way to say "fire and forget", so the library carries it on the binding. Dropping it + // here turns an asynchronous call into one the enclosing scope waits on, and the process stops where it used + // to carry on. + Assert.False(ValueOf(dispatch.WaitForCompletion)); + } + + [Fact(DisplayName = "A call activity naming no called element is refused")] + public void CallProcess_RefusesACallWithNothingToCall() + { + Assert.Throws(() => Bind( + Definition(Element("call", BpmnElementTypes.CallActivity)), + new BpmnWorkBinding.CallProcess(ProcessId, "call", Ref("call"), BpmnBindingSlot.Primary, null, true))); + } + + [Fact(DisplayName = "A nested process binds to a BpmnProcess scope that binds its own work")] + public void NestedProcess_BindsToANestedScope() + { + var body = new BpmnProcessDefinition("sub", Elements: [BoundElement("subWork", BpmnElementTypes.ServiceTask, new WriteLine("nested"))]); + + var scope = Bind( + Definition(Element("sub", BpmnElementTypes.SubProcess)), + new BpmnWorkBinding.NestedProcess(ProcessId, "sub", Ref("sub"), BpmnBindingSlot.Primary, body), + Unbound("subWork", processId: "sub")); + + var nested = Assert.IsType(WorkOf(scope, "sub")); + + Assert.Same(body, nested.Process); + Assert.Equal("nested", ValueOf(Assert.IsType(WorkOf(nested, "subWork")).Text)); + + // A nested scope's start events are internal to the process around it. The command applier refuses a nested + // scope that says otherwise, so a binder that marked one would only fail once the process ran. + Assert.False(nested.IsRootScope); + } + + [Fact(DisplayName = "A document-declared variable is copied onto the bound scope, and drives a collection-mode multi-instance")] + public async Task DocumentDeclaredVariable_DrivesACollectionModeMultiInstance() + { + // BpmnScopeVariables.TryRead resolves purely through Elsa's own ExpressionExecutionContext.GetVariable, which + // walks Container.Variables — never BpmnProcessDefinition.Variables. A collection the document declares but + // the binder never copies onto the scope is Absent to the interpreter, and a collection-mode multi-instance + // over it faults the element instead of running once per item. Running the bound scope for real, rather than + // only inspecting its shape, is what catches that: a structural assertion on scope.Variables would still pass + // if the interpreter could not actually see the value. + const string collectionVariableName = "items"; + + var each = new BpmnElement( + "each", + BpmnElementTypes.ServiceTask, + bindingRef: Ref("each"), + loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, collectionVariable: collectionVariableName), + extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(new WriteLine("iterated")))); + var after = BoundElement("after", BpmnElementTypes.ServiceTask, new WriteLine("after")); + + var definition = new BpmnProcessBuilder(ProcessId) + .Variable(collectionVariableName) + .StartEvent("start") + .Element(each) + .Element(after) + .EndEvent("end") + .ConnectSequence("start", "each", "after", "end") + .Build(); + + // The default travels as the declaration's own JsonElement, seeded here the way an imported .bpmn would carry + // it: the document names the variable and gives it a value, and the binder is what makes both visible. + definition = definition with + { + Variables = [new BpmnVariableDeclaration(collectionVariableName, null, JsonSerializer.SerializeToElement(new[] { "alpha", "beta", "gamma" }))] + }; + + var scope = Bind(definition, Unbound("each"), Unbound("after")); + + Assert.Contains(scope.Variables, variable => variable.Name == collectionVariableName); + + var eachActivityId = scope.WorkBindings[Ref("each")]; + var afterActivityId = scope.WorkBindings[Ref("after")]; + + var result = await Services.GetRequiredService().RunAsync(scope); + + Assert.Equal(3, result.Journal.ActivityExecutionContexts.Count(context => context.Activity.Id == eachActivityId)); + Assert.Equal(1, result.Journal.ActivityExecutionContexts.Count(context => context.Activity.Id == afterActivityId)); + Assert.Empty(result.WorkflowState.Incidents); + } + + [Fact(DisplayName = "A ScopeListener binding is bound like any other, under its own binding ref")] + public void ScopeListenerSlot_BindsWithNoSpecialCase() + { + // An event subprocess element carries two bindings: its body, and the listener its enclosing scope arms while + // it runs. Both are entries in the same map; the interpreter arms the listener at scope start on its own. + var element = new BpmnElement( + "escalationHandler", + BpmnElementTypes.SubProcess, + bindingRef: Ref("escalationHandler"), + triggeredByEvent: true, + listenerBindingRef: ListenerRef("escalationHandler")); + + var scope = Bind( + Definition(element), + new BpmnWorkBinding.NestedProcess(ProcessId, "escalationHandler", Ref("escalationHandler"), BpmnBindingSlot.Primary, new BpmnProcessDefinition("escalationHandler")), + new BpmnWorkBinding.MessageWait(ProcessId, "escalationHandler", ListenerRef("escalationHandler"), BpmnBindingSlot.ScopeListener, "Escalated")); + + Assert.IsType(WorkForRef(scope, Ref("escalationHandler"))); + Assert.Equal("Escalated", ValueOf(Assert.IsType(WorkForRef(scope, ListenerRef("escalationHandler"))).EventName)); + } + + [Fact(DisplayName = "An unbound task the document does not bind is refused, naming the element")] + public void UnboundTask_WithNoDeclarationIsRefused() + { + // The failure that has to be loud. Skipping the binding instead would produce a scope whose interpreter + // schedules work for a binding ref nothing maps, which surfaces much later and much further away. + var exception = Assert.Throws(() => Bind(Definition(Element("approve", BpmnElementTypes.UserTask)), Unbound("approve"))); + + Assert.Contains("approve", exception.Message); + Assert.Contains(BpmnActivityBindingFormat.BindingElementName, exception.Message); + } + + [Fact(DisplayName = "An unbound task the document binds resolves to the declared activity, inputs intact")] + public void UnboundTask_WithADeclarationResolves() + { + var scope = Bind( + Definition(BoundElement("approve", BpmnElementTypes.UserTask, new WriteLine("approved"))), + Unbound("approve")); + + Assert.Equal("approved", ValueOf(Assert.IsType(WorkOf(scope, "approve")).Text)); + } + + [Fact(DisplayName = "An activity binding on an element with no unbound task to bind is refused")] + public void DeadDeclaration_IsRefused() + { + // Six of the seven kinds never consult a declaration, so one written on a timer configures nothing. Ignoring + // it leaves the author's expression sitting in the file while the activity they configured never executes. + var element = new BpmnElement( + "wait", + BpmnElementTypes.IntermediateCatchEvent, + bindingRef: Ref("wait"), + extensions: BpmnActivityBindingFormat.Attach(null, Format.Write(new WriteLine("never runs")))); + + var exception = Assert.Throws(() => Bind(Definition(element), Timer("wait", "PT1M"))); + + Assert.Contains("wait", exception.Message); + } + + [Fact(DisplayName = "Two scopes declaring the same binding ref get their own activity instance and their own node")] + public async Task SameBindingRefInTwoScopes_ProducesDistinctActivityNodes() + { + // A binding ref is unique within a scope, not across scopes. One instance shared between two scopes still + // builds and still publishes: ActivityVisitor collects activities into a set and skips one it has already + // seen, so the second scope simply has no child in the identity graph and half the process never runs. + var body = new BpmnProcessDefinition("sub", Elements: [BoundElement("work", BpmnElementTypes.ServiceTask, new WriteLine("inner"))]); + + var scope = Bind( + Definition(BoundElement("work", BpmnElementTypes.ServiceTask, new WriteLine("outer")), Element("sub", BpmnElementTypes.SubProcess)), + Unbound("work"), + new BpmnWorkBinding.NestedProcess(ProcessId, "sub", Ref("sub"), BpmnBindingSlot.Primary, body), + Unbound("work", processId: "sub")); + + var outer = WorkOf(scope, "work"); + var inner = WorkOf(Assert.IsType(WorkOf(scope, "sub")), "work"); + + Assert.NotSame(outer, inner); + Assert.NotEqual(outer.Id, inner.Id); + + // The invariant stated the way Elsa sees it: two nodes, one per logical position. + var nodes = await IdentityGraphOfAsync(scope); + var texts = nodes.Select(node => node.Activity).OfType().Select(activity => ValueOf(activity.Text)).Order().ToList(); + + Assert.Equal(new[] { "inner", "outer" }, texts); + } + + private BpmnProcess Bind(BpmnProcessDefinition definition, params BpmnWorkBinding[] bindings) => Binder.Bind(definition, bindings); + + private static BpmnProcessDefinition Definition(params BpmnElement[] elements) => new(ProcessId, Elements: elements); + + private static BpmnElement Element(string elementId, string elementType) => new(elementId, elementType, bindingRef: Ref(elementId)); + + private static BpmnWorkBinding.TimerWait Timer(string elementId, string isoDuration) => + new(ProcessId, elementId, Ref(elementId), BpmnBindingSlot.Primary, isoDuration); + + private static BpmnWorkBinding.UnboundTask Unbound(string elementId, string processId = ProcessId) => + new(processId, elementId, Ref(elementId), BpmnBindingSlot.Primary, BpmnElementTypes.ServiceTask); + + private static string ListenerRef(string elementId) => $"{Ref(elementId)}-listener"; + + /// The activity the scope maps the given element's work to, resolved the way the host resolves it. + private static IActivity WorkOf(BpmnProcess scope, string elementId) => WorkForRef(scope, Ref(elementId)); + + private static IActivity WorkForRef(BpmnProcess scope, string bindingRef) + { + Assert.True(scope.WorkBindings.TryGetValue(bindingRef, out var activityId), $"The scope maps no work to binding ref '{bindingRef}'."); + return Assert.Single(scope.Activities, activity => activity.Id == activityId); + } +} diff --git a/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnLibraryDuplicationGuardTests.cs b/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnLibraryDuplicationGuardTests.cs index 7401f9010..f52ab4d00 100644 --- a/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnLibraryDuplicationGuardTests.cs +++ b/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnLibraryDuplicationGuardTests.cs @@ -94,9 +94,19 @@ public class BpmnLibraryDuplicationGuardTests .ToList(); } + // The declaring-type walk is load-bearing, not defensive. A collection expression targeting an + // IReadOnlyList makes the compiler synthesize a <>z__ReadOnlySingleElementList into the + // assembly; that outer type is filtered by its name, but its nested Enumerator struct is named + // plainly and carries no CompilerGeneratedAttribute of its own, so it reads as a hand-written type + // colliding with the identically synthesized one in Bpmn.Model. That is a false positive: nothing a + // person wrote is ever nested inside a <>-named type, and this guard's own fixture (BpmnGraph, + // nested in an ordinary class) still trips the detector. Without this, the guard fails the moment + // anyone writes a single-element collection expression in Elsa.Bpmn*, which teaches people to work + // around it rather than to trust it. private static bool IsCompilerGenerated(Type type) => type.Name.StartsWith('<') || - type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false); + type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) || + (type.DeclaringType is { } declaringType && IsCompilerGenerated(declaringType)); /// /// Exists solely as this guard's own fixture: its name deliberately collides with