diff --git a/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticEventNames.cs b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticEventNames.cs new file mode 100644 index 000000000..1d7cdbeb5 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticEventNames.cs @@ -0,0 +1,98 @@ +namespace Elsa.Studio.Workflows.Domain.Models.Bpmn; + +/// +/// The stable execution log eventName for each Bpmn.Model.State.BpmnDiagnosticKind member, and the +/// source every one of them carries. Mirrors elsa-core's own +/// Elsa.Bpmn.Hosting.BpmnDiagnosticEventNames -- the source of truth for these strings, and for the +/// projection rules that decide which diagnostics are journaled at all -- so Studio has one place to keep in step +/// with it rather than depending on Bpmn.Model's own enum values, which it never references. +/// +public static class BpmnDiagnosticEventNames +{ + /// The source every execution log entry projected from a BPMN diagnostic carries. + public const string Source = "BPMN"; + + /// + /// A token arrived at an element via a sequence flow, or an element (a start event, or an error/cancel boundary + /// firing without an inbound flow) emitted a token of its own. + /// + public const string TokenEmitted = "TokenEmitted"; + + /// An element started bound work: a single unit, or one instance of a multi-instance loop. + public const string Scheduled = "Scheduled"; + + /// A token arrived at a join and is waiting for its siblings. + public const string Waiting = "Waiting"; + + /// A join fired after its arrivals were satisfied. + public const string Joined = "Joined"; + + /// An end event consumed a token, or a multi-instance loop consumed a finished instance's token. + public const string Consumed = "Consumed"; + + /// A unit of work was cancelled. + public const string Canceled = "Canceled"; + + /// A terminate end event ended the process. + public const string Terminated = "Terminated"; + + /// An element's behavior failed. + public const string BehaviorFailure = "BehaviorFailure"; + + /// + /// The scope itself finished. Never projected: unlike every other kind, it names neither an element nor a flow, + /// and the scope's own activity lifecycle already journals its completion. + /// + public const string Completed = "Completed"; + + /// A unit of work faulted. + public const string Faulted = "Faulted"; + + /// A host completion carrying an attached compensation boundary registered a compensable. + public const string CompensationRegistered = "CompensationRegistered"; + + /// A compensate throw/end event triggered a compensation replay. + public const string CompensationTriggered = "CompensationTriggered"; + + /// A compensation handler ran to completion for one registered compensable. + public const string Compensated = "Compensated"; + + /// A cancel end event began (or completed) cancelling a transaction scope. + public const string TransactionCancelled = "TransactionCancelled"; + + /// An escalation throw/end event staged an enclosing-scope signal notification. + public const string EscalationRaised = "EscalationRaised"; + + /// An escalation notification matched an attached boundary and fired it. + public const string EscalationCaught = "EscalationCaught"; + + /// An escalation reached a scope that could not catch it; a no-op, never a fault. + public const string EscalationUnhandled = "EscalationUnhandled"; + + /// + /// An interrupting escalation boundary matched a notification whose host had already terminalized; a no-op, + /// never a fault. + /// + public const string EscalationLate = "EscalationLate"; + + /// An event subprocess was activated by its start-event trigger. + public const string EventSubprocessActivated = "EventSubprocessActivated"; + + /// An event subprocess body ran to completion. + public const string EventSubprocessCompleted = "EventSubprocessCompleted"; + + /// + /// A call activity's bound child failed and the engine routed the call-activity failure ladder instead of + /// normal outbound flows. + /// + public const string CallActivityFailureRouted = "CallActivityFailureRouted"; + + /// A message, signal or timer triggered scope listener was armed. + public const string ScopeListenerArmed = "ScopeListenerArmed"; + + /// A message, signal or timer triggered scope listener fired. + public const string ScopeListenerFired = "ScopeListenerFired"; + + /// A message, signal or timer triggered scope listener was retired. + public const string ScopeListenerRetired = "ScopeListenerRetired"; +} diff --git a/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticLogPayload.cs b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticLogPayload.cs new file mode 100644 index 000000000..307d48407 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticLogPayload.cs @@ -0,0 +1,21 @@ +namespace Elsa.Studio.Workflows.Domain.Models.Bpmn; + +/// +/// The payload every execution log entry projected from a BPMN diagnostic carries. Mirrors elsa-core's +/// Elsa.Bpmn.Hosting.BpmnDiagnosticLogPayload -- the source of truth for this shape -- field for field, since +/// that record is exactly what arrives, camelCase, as +/// for an entry whose Source is . +/// +/// The diagnostic's id in the interpreter's own pinned id stream (diag:N). +/// The BPMN element the diagnostic is about, or null when it names none. +/// The BPMN sequence flow the diagnostic is about, or null when it names none. +/// The token the diagnostic is about, or null when it names none. +/// The diagnostic kind's enum member name; see . +/// Free-form key/value details the interpreter attached, carried verbatim. +public sealed record BpmnDiagnosticLogPayload( + string DiagnosticId, + string? ElementId, + string? FlowId, + string? TokenId, + string Kind, + IReadOnlyDictionary? Details); diff --git a/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStats.cs b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStats.cs new file mode 100644 index 000000000..9175403d6 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStats.cs @@ -0,0 +1,35 @@ +namespace Elsa.Studio.Workflows.Domain.Models.Bpmn; + +/// +/// Instance state for one BPMN element or sequence flow, keyed by its BPMN document id -- an element id, or, for a +/// sequence flow, a flow id, since element and flow ids are both document-unique. +/// +/// +/// This is a write-only projection derived from diagnostics: is the only +/// thing that produces it, and nothing reads it back into the engine or edits it. It exists so a gateway, an +/// intermediate event or a sequence flow -- none of which has an Elsa activity id under Option A -- has something +/// for the instance viewer's overlay to show, alongside the existing activity-keyed for +/// bound work. Mirrors the ClientLib's canvas-neutral BpmnElementStats interface (src/bpmn/model.ts), +/// which owns the shape from the rendering side; every field here is nullable for the same reason that one is +/// all-optional: a projection that cannot compute a field says nothing about it rather than reporting a zero. +/// +public sealed class BpmnElementStats +{ + /// How many tokens have entered this element or been carried by this flow. + public int? Started { get; set; } + + /// How many tokens have left this element having completed, or how many times this flow was taken. + public int? Completed { get; set; } + + /// How many tokens are sitting on this element right now (a waiting catch event, an armed listener). + public int? Active { get; set; } + + /// Whether a token is parked here waiting for something external (e.g. a join awaiting its siblings). + public bool? Blocked { get; set; } + + /// Whether execution faulted at this element. + public bool? Faulted { get; set; } + + /// Whether a token here was cancelled (an interrupted activity, a lost event race, a torn-down scope). + public bool? Canceled { get; set; } +} diff --git a/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStatsProjector.cs b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStatsProjector.cs new file mode 100644 index 000000000..b5c941c3d --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStatsProjector.cs @@ -0,0 +1,164 @@ +using System.Linq; +using System.Text.Json; +using Elsa.Api.Client.Resources.WorkflowInstances.Models; + +namespace Elsa.Studio.Workflows.Domain.Models.Bpmn; + +/// +/// Folds the BPMN diagnostics projected onto a workflow instance's journal (see +/// and ) into a single element-keyed +/// map. +/// +/// +/// The one place the mapping from a diagnostic kind to a stats change lives: nothing on the ClientLib side repeats +/// it, so a BPMN element's badge and a sequence flow's "taken" styling always agree with what this class decided. +/// Entries from a nested scope (each nested BpmnProcess writes diagnostics on its own activity) fold into +/// the very same map, because BPMN element and flow ids are unique across the whole document, not merely within +/// one scope. +/// +public static class BpmnElementStatsProjector +{ + private static readonly JsonSerializerOptions PayloadSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + /// + /// Projects the BPMN diagnostic entries in into an element-keyed stats map. + /// Entries whose Source is not , or whose payload does not + /// deserialize as a , are ignored, so callers may pass an unfiltered + /// journal page. + /// + public static IReadOnlyDictionary Project(IEnumerable journalEntries) + { + var stats = new Dictionary(); + Fold(journalEntries, stats); + return stats; + } + + /// + /// Folds into an already-populated element-keyed stats map, mutating it in + /// place, so a caller that has already folded earlier journal pages need only fold the new ones rather than + /// re-fold the whole history on every refresh. + /// + public static void Fold(IEnumerable journalEntries, Dictionary stats) + { + foreach (var entry in journalEntries.Where(e => e.Source == BpmnDiagnosticEventNames.Source)) + { + if (!TryReadPayload(entry.Payload, out var payload)) + continue; + + if (!string.IsNullOrEmpty(payload.ElementId)) + ApplyToElement(GetOrAdd(stats, payload.ElementId), payload.Kind); + + // A diagnostic that names a flow id represents that flow having just been taken -- a sequence flow + // has no state beyond that -- regardless of which kind carried it (normally TokenEmitted). + if (!string.IsNullOrEmpty(payload.FlowId)) + ApplyToFlow(GetOrAdd(stats, payload.FlowId)); + } + } + + private static BpmnElementStats GetOrAdd(Dictionary stats, string id) + { + if (stats.TryGetValue(id, out var entry)) + return entry; + + entry = new BpmnElementStats(); + stats[id] = entry; + return entry; + } + + /// + /// Applies one diagnostic kind's effect on the element (or flow-target) it names. See the parenthetical list in + /// the design: token emitted/consumed change how many tokens are present; a join that is Waiting is + /// blocked, and stops being blocked once it is Joined; work being scheduled, completed or torn down + /// moves the same active/completed counters bound work's own ActivityStats reports, so an element with + /// no activity id still tells the same story. + /// + private static void ApplyToElement(BpmnElementStats stats, string kind) + { + switch (kind) + { + case BpmnDiagnosticEventNames.TokenEmitted: + case BpmnDiagnosticEventNames.Scheduled: + case BpmnDiagnosticEventNames.EventSubprocessActivated: + case BpmnDiagnosticEventNames.CompensationTriggered: + case BpmnDiagnosticEventNames.ScopeListenerArmed: + stats.Started = (stats.Started ?? 0) + 1; + stats.Active = (stats.Active ?? 0) + 1; + break; + + case BpmnDiagnosticEventNames.Waiting: + // A join arrived and is waiting on its siblings: a token is present (blocked), not yet consumed. + stats.Blocked = true; + break; + + case BpmnDiagnosticEventNames.Joined: + stats.Blocked = false; + stats.Completed = (stats.Completed ?? 0) + 1; + stats.Active = Decrement(stats.Active); + break; + + case BpmnDiagnosticEventNames.Consumed: + case BpmnDiagnosticEventNames.Terminated: + case BpmnDiagnosticEventNames.EventSubprocessCompleted: + case BpmnDiagnosticEventNames.Compensated: + case BpmnDiagnosticEventNames.EscalationCaught: + case BpmnDiagnosticEventNames.ScopeListenerFired: + stats.Completed = (stats.Completed ?? 0) + 1; + stats.Active = Decrement(stats.Active); + break; + + case BpmnDiagnosticEventNames.ScopeListenerRetired: + stats.Active = Decrement(stats.Active); + break; + + case BpmnDiagnosticEventNames.Canceled: + case BpmnDiagnosticEventNames.TransactionCancelled: + stats.Canceled = true; + stats.Active = Decrement(stats.Active); + break; + + case BpmnDiagnosticEventNames.Faulted: + case BpmnDiagnosticEventNames.BehaviorFailure: + case BpmnDiagnosticEventNames.CallActivityFailureRouted: + stats.Faulted = true; + break; + + // CompensationRegistered, EscalationRaised, EscalationUnhandled and EscalationLate are bookkeeping + // that leaves no per-element state worth showing; EscalationUnhandled and EscalationLate are + // documented as never a fault, so they must not be folded into Faulted. + default: + break; + } + } + + private static void ApplyToFlow(BpmnElementStats stats) + { + stats.Started = (stats.Started ?? 0) + 1; + stats.Completed = (stats.Completed ?? 0) + 1; + } + + private static int Decrement(int? value) => Math.Max(0, (value ?? 0) - 1); + + private static bool TryReadPayload(object? payload, out BpmnDiagnosticLogPayload result) + { + switch (payload) + { + case BpmnDiagnosticLogPayload direct: + result = direct; + return true; + + case JsonElement { ValueKind: JsonValueKind.Object } element: + var deserialized = element.Deserialize(PayloadSerializerOptions); + + if (deserialized != null) + { + result = deserialized; + return true; + } + + break; + } + + result = null!; + return false; + } +} diff --git a/src/modules/Elsa.Studio.Workflows.Core/UI/Contracts/IBpmnElementStatsSink.cs b/src/modules/Elsa.Studio.Workflows.Core/UI/Contracts/IBpmnElementStatsSink.cs new file mode 100644 index 000000000..ac7703295 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Core/UI/Contracts/IBpmnElementStatsSink.cs @@ -0,0 +1,22 @@ +using Elsa.Studio.Workflows.Domain.Models.Bpmn; + +namespace Elsa.Studio.Workflows.UI.Contracts; + +/// +/// An optional capability an may implement to accept the element-keyed instance +/// overlay: a gateway, an intermediate event or a sequence flow has no Elsa activity id under BPMN's Option A, so +/// alone never lights one up. +/// +/// +/// Kept as a separate interface, rather than a member on itself, so that every other +/// diagram designer -- which has no BPMN element ids to key anything on -- is untouched by this projection. +/// +public interface IBpmnElementStatsSink +{ + /// + /// Replaces the whole element-keyed instance overlay. The map is authoritative: an element or flow it no + /// longer mentions loses its badge or "taken" styling. + /// + /// The stats, keyed by BPMN element id or, for a sequence flow, by flow id. + Task UpdateElementStatsAsync(IReadOnlyDictionary elementStats); +} diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/model.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/model.ts index 0b4b58012..605350f31 100644 --- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/model.ts +++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/model.ts @@ -237,6 +237,8 @@ export interface BpmnViewFlow { readonly conditionOutcome: string | null; /** Whether this is the source gateway's default flow. */ readonly isDefault: boolean; + /** Instance state keyed by this flow's own id -- whether, and how often, it has been taken. */ + readonly stats: BpmnElementStats | null; /** Empty when the document carries no DI edge for this flow; see {@link geometrySource}. */ readonly waypoints: readonly BpmnPoint[]; readonly geometrySource: BpmnGeometrySource; diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/view-model.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/view-model.ts index 5955a29e1..c0db215c8 100644 --- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/view-model.ts +++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/view-model.ts @@ -185,6 +185,7 @@ export function buildBpmnViewModel(input: BpmnViewModelInput): BpmnViewModel { // is only set by documents that say it there instead; honour both so neither // representation of the same fact goes missing. isDefault: flow.isDefault === true || source.defaultFlowId === flow.flowId, + stats: input.elementStats?.[flow.flowId] ?? null, waypoints: edge?.waypoints ?? [], geometrySource: edge != null && edge.waypoints.length > 0 ? 'document' : 'fallback', labelGeometry: toBounds(edge?.labelBounds ?? null), diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/__tests__/stats.test.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/__tests__/stats.test.ts index 84f24f93f..0a91a2bd5 100644 --- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/__tests__/stats.test.ts +++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/__tests__/stats.test.ts @@ -7,9 +7,9 @@ * Both are asserted here in both directions. */ import { describe, expect, it } from 'vitest'; -import { statsBadgeAttrs } from '../cells'; -import { BADGE_SURFACE_BY_TONE } from '../palette'; -import { resolveBpmnStatsBadge } from '../stats'; +import { flowTakenLineAttrs, statsBadgeAttrs } from '../cells'; +import { BADGE_SURFACE_BY_TONE, EDGE } from '../palette'; +import { isBpmnFlowTaken, resolveBpmnStatsBadge } from '../stats'; describe('resolveBpmnStatsBadge', () => { it('says nothing about an element the overlay says nothing about', () => { @@ -114,3 +114,36 @@ describe('statsBadgeAttrs', () => { expect(attrs.statsBadgeGroup['aria-label']).toBe('Blocked (1)'); }); }); + +describe('isBpmnFlowTaken', () => { + it('says no about a flow the overlay says nothing about', () => { + expect(isBpmnFlowTaken(null)).toBe(false); + expect(isBpmnFlowTaken(undefined)).toBe(false); + expect(isBpmnFlowTaken({})).toBe(false); + }); + + it('says yes once the flow has been taken, by either counter the projector might set', () => { + expect(isBpmnFlowTaken({ completed: 1 })).toBe(true); + expect(isBpmnFlowTaken({ started: 1 })).toBe(true); + }); +}); + +describe('flowTakenLineAttrs', () => { + it('paints an untaken flow in the plain edge colour, at the plain width', () => { + expect(flowTakenLineAttrs(null)).toEqual({ stroke: EDGE, strokeWidth: 1.5 }); + expect(flowTakenLineAttrs({})).toEqual({ stroke: EDGE, strokeWidth: 1.5 }); + }); + + it('paints a taken flow in the same tone a completed node badge uses, thicker', () => { + const attrs = flowTakenLineAttrs({ completed: 1 }); + + expect(attrs.stroke).toBe(BADGE_SURFACE_BY_TONE.completed); + expect(attrs.strokeWidth).toBeGreaterThan(1.5); + }); + + it('returns both properties whichever way it decides, so a flow that stops being taken falls back instead of keeping its old colour', () => { + // updateBpmnElementStats merges this into the edge's existing `line` attrs rather than + // replacing them; a partial object here would leave a cleared flow looking taken forever. + expect(Object.keys(flowTakenLineAttrs(null)).sort()).toEqual(Object.keys(flowTakenLineAttrs({ completed: 1 })).sort()); + }); +}); diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/cells.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/cells.ts index 0043d9a52..99c06d1e5 100644 --- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/cells.ts +++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/cells.ts @@ -62,7 +62,7 @@ import { TEXT, UNBOUND, } from './palette'; -import { resolveBpmnStatsBadge, type BpmnStatsBadge } from './stats'; +import { isBpmnFlowTaken, resolveBpmnStatsBadge, type BpmnStatsBadge } from './stats'; const CATCHING_EVENT_TYPES: readonly string[] = ['startEvent', 'intermediateCatchEvent', BOUNDARY_EVENT_ELEMENT_TYPE]; const RINGED_EVENT_TYPES: readonly string[] = ['intermediateCatchEvent', 'intermediateThrowEvent', BOUNDARY_EVENT_ELEMENT_TYPE]; @@ -113,6 +113,8 @@ export interface BpmnFlowCellData { readonly targetElementId: string; readonly isDefault: boolean; readonly conditionOutcome: string | null; + /** Instance state keyed by this flow's own id. Always null for an association. */ + readonly stats: BpmnElementStats | null; } export type BpmnCellData = BpmnElementCellData | BpmnContainerCellData | BpmnFlowCellData; @@ -888,6 +890,7 @@ function edgeForFlow(flow: BpmnViewFlow, context: BuildContext): Edge.Metadata { targetElementId: flow.targetElementId, isDefault: flow.isDefault, conditionOutcome: flow.conditionOutcome, + stats: flow.stats, }; const source = context.elementsByScopedId.get(scopedKey(flow.scopeId, flow.sourceElementId)) ?? null; const target = context.elementsByScopedId.get(scopedKey(flow.scopeId, flow.targetElementId)) ?? null; @@ -900,7 +903,7 @@ function edgeForFlow(flow: BpmnViewFlow, context: BuildContext): Edge.Metadata { source: terminal(flow.sourceElementId, source, waypoints[0]), target: terminal(flow.targetElementId, target, waypoints[waypoints.length - 1]), vertices: waypoints.length > 2 ? waypoints.slice(1, -1).map(point => ({ x: point.x, y: point.y })) : [], - attrs: { line: flowLineAttrs(flow, source) }, + attrs: { line: { ...flowLineAttrs(flow, source), ...flowTakenLineAttrs(flow.stats) } }, labels: flow.name == null || flow.name.length === 0 ? [] : [edgeLabel(flow.name)], data, }; @@ -919,6 +922,7 @@ function edgeForAssociation( targetElementId, isDefault: false, conditionOutcome: null, + stats: null, }; return { @@ -975,6 +979,25 @@ function flowLineAttrs(flow: BpmnViewFlow, source: BpmnViewElement | null): Reco return attrs; } +/** + * The line-only style override for whether a sequence flow has been taken, reusing the "completed" + * badge tone -- the same colour a node turns once its own token count says it is done -- so a taken + * flow reads as part of the same visual language rather than inventing a second one. + * + * Always returns both properties, never a partial object: a flow that stops being taken (the map no + * longer mentions it) must fall back to the plain, untaken line exactly as loudly as one that starts + * being taken lights up, since {@link updateBpmnElementStats} merges this into the edge's existing + * `line` attrs rather than replacing them wholesale. + */ +export function flowTakenLineAttrs(stats: BpmnElementStats | null | undefined): Record { + const taken = isBpmnFlowTaken(stats); + + return { + stroke: taken ? BADGE_SURFACE_BY_TONE.completed : EDGE, + strokeWidth: taken ? 2.5 : 1.5, + }; +} + function edgeLabel(text: string): Record { return { position: { distance: 0.5 }, diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/mount.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/mount.ts index 57f2d3e13..f3e7f6bd6 100644 --- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/mount.ts +++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/mount.ts @@ -13,8 +13,10 @@ import type { DotNetComponentRef } from '../api/graph-bindings'; import { whenCanvasHasHeight } from '../internal/canvas-ready'; import { buildBpmnX6Cells, + flowTakenLineAttrs, statsBadgeAttrs, type BpmnElementCellData, + type BpmnFlowCellData, type BpmnX6Cells, } from './cells'; import { BPMN_DESIGNER_CLASS } from './constants'; @@ -179,6 +181,17 @@ export function updateBpmnElementStats( node.setData({ ...data, stats }, { overwrite: true }); node.attr(statsBadgeAttrs(resolveBpmnStatsBadge(stats, data.activityStats))); } + + for (const edge of binding.graph.getEdges()) { + const data = flowData(edge.getData()); + + if (data == null) continue; + + const stats = elementStats?.[data.id] ?? null; + + edge.setData({ ...data, stats }, { overwrite: true }); + edge.attr({ line: flowTakenLineAttrs(stats) }); + } } /** Updates the activity-keyed overlay for one Elsa activity, on every element bound to it. */ @@ -227,6 +240,11 @@ function elementData(data: unknown): BpmnElementCellData | null { return (data as BpmnElementCellData | null)?.cellKind === 'element' ? data as BpmnElementCellData : null; } +/** Only a sequence flow can be "taken"; an association's id is a synthetic pair, never a document id. */ +function flowData(data: unknown): BpmnFlowCellData | null { + return (data as BpmnFlowCellData | null)?.cellKind === 'flow' ? data as BpmnFlowCellData : null; +} + function toSelection(data: BpmnElementCellData): BpmnElementSelection { return { elementId: data.elementId, diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/stats.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/stats.ts index 45b367a8f..ff411e58b 100644 --- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/stats.ts +++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/stats.ts @@ -76,3 +76,14 @@ function firstNumber(...values: readonly (number | null | undefined)[]): number return null; } + +/** + * Whether a sequence flow's own stats entry says it has been taken at least once. + * + * A flow has no state beyond that: `BpmnElementStatsProjector` (the C# side, the one place this + * mapping lives) marks a flow's entry the moment a token travels along it, by its own flow id, so + * either counter being positive is enough. + */ +export function isBpmnFlowTaken(stats: BpmnElementStats | null | undefined): boolean { + return (stats?.completed ?? 0) > 0 || (stats?.started ?? 0) > 0; +} diff --git a/src/modules/Elsa.Studio.Workflows.Designer/Components/BpmnDesigner.razor.cs b/src/modules/Elsa.Studio.Workflows.Designer/Components/BpmnDesigner.razor.cs index 83d31611f..f16b61268 100644 --- a/src/modules/Elsa.Studio.Workflows.Designer/Components/BpmnDesigner.razor.cs +++ b/src/modules/Elsa.Studio.Workflows.Designer/Components/BpmnDesigner.razor.cs @@ -6,6 +6,7 @@ using Elsa.Studio.Workflows.Designer.Services; using Elsa.Studio.Workflows.Domain.Contracts; using Elsa.Studio.Workflows.Domain.Models; +using Elsa.Studio.Workflows.Domain.Models.Bpmn; using Elsa.Studio.Workflows.Extensions; using Elsa.Studio.Workflows.UI.Contracts; using Microsoft.AspNetCore.Components; @@ -153,6 +154,14 @@ public async Task LoadBpmnAsync(JsonObject activity, string? sourceXml, IDiction public async Task UpdateActivityStatsAsync(string activityId, ActivityStats stats) => await ScheduleGraphActionAsync(() => _graphApi!.UpdateActivityStatsAsync(activityId, stats)); + /// + /// Replaces the whole element-keyed instance overlay: a gateway, an intermediate event or a sequence flow, none + /// of which has an Elsa activity id, keyed instead by BPMN element id (or, for a flow, by flow id). + /// + /// The stats to apply, keyed by BPMN element or flow id. + public async Task UpdateElementStatsAsync(IReadOnlyDictionary elementStats) => + await ScheduleGraphActionAsync(() => _graphApi!.UpdateElementStatsAsync(elementStats)); + /// /// Keeps the held activity tree in step with an edit made elsewhere (the properties panel): the /// matching node -- the root itself or, recursively, a child bound to a BPMN element or to a diff --git a/src/modules/Elsa.Studio.Workflows.Designer/Interop/BpmnGraphApi.cs b/src/modules/Elsa.Studio.Workflows.Designer/Interop/BpmnGraphApi.cs index 37a6c7dd3..bf550703d 100644 --- a/src/modules/Elsa.Studio.Workflows.Designer/Interop/BpmnGraphApi.cs +++ b/src/modules/Elsa.Studio.Workflows.Designer/Interop/BpmnGraphApi.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using Elsa.Studio.Workflows.Designer.Models; using Elsa.Studio.Workflows.Domain.Models; +using Elsa.Studio.Workflows.Domain.Models.Bpmn; using Microsoft.JSInterop; namespace Elsa.Studio.Workflows.Designer.Interop; @@ -49,6 +50,14 @@ public async Task> LoadDiagramAsync(JsonObject inp public async Task UpdateActivityStatsAsync(string activityId, ActivityStats? stats) => await InvokeAsync(module => module.InvokeVoidAsync("updateBpmnActivityStats", _graphId, activityId, stats)); + /// + /// Replaces the whole element-keyed instance overlay: instance state for a gateway, an intermediate event or a + /// sequence flow, keyed by BPMN element id (or, for a flow, by flow id). + /// + /// The stats to apply, or null (or empty) to clear the overlay entirely. + public async Task UpdateElementStatsAsync(IReadOnlyDictionary? elementStats) => + await InvokeAsync(module => module.InvokeVoidAsync("updateBpmnElementStats", _graphId, elementStats)); + /// /// Selects the specified element, optionally centering the viewport on it. /// diff --git a/src/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerElementStatsMountRaceTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerElementStatsMountRaceTests.cs new file mode 100644 index 000000000..f7b4e8729 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerElementStatsMountRaceTests.cs @@ -0,0 +1,113 @@ +using System.Text.Json.Nodes; +using Bunit; +using Elsa.Api.Client.Resources.ActivityDescriptors.Models; +using Elsa.Studio.Extensions; +using Elsa.Studio.Localization; +using Elsa.Studio.Workflows.DiagramDesigners.Bpmn; +using Elsa.Studio.Workflows.Designer.Extensions; +using Elsa.Studio.Workflows.Designer.Models; +using Elsa.Studio.Workflows.Designer.Options; +using Elsa.Studio.Workflows.Domain.Contracts; +using Elsa.Studio.Workflows.Domain.Models.Bpmn; +using Elsa.Studio.Workflows.Extensions; +using Elsa.Studio.Workflows.UI.Contexts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +/// +/// Covers the element-stats mount race (issue's W13 follow-up): a refresh can name a +/// before its -- and, in turn, the canvas +/// underneath it -- has ever been rendered, most notably the one unconditional refresh a freshly opened instance +/// gets on load, which runs before the designer has had a chance to render anything at all. That update must not +/// be silently dropped: it is the only chance a finished instance (no observer-driven refresh to fall back on) +/// ever gets to show its gateway, event and taken-flow overlay. +/// +public sealed class BpmnDiagramDesignerElementStatsMountRaceTests : BunitContext, IAsyncLifetime +{ + public BpmnDiagramDesignerElementStatsMountRaceTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + JSInterop.Setup("loadBpmnDiagram", _ => true).SetResult([]); + Services.AddMudServices(); + Services.AddLogging(); + Services.AddCoreInternal(); + Services.AddWorkflowsCore(); + Services.AddWorkflowsDesigner(); + Services.AddSingleton(); + Services.AddSingleton(); + } + + Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask; + async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync(); + + [Fact] + public async Task UpdateElementStatsAsync_BeforeTheCanvasIsMounted_IsAppliedOnceItMounts() + { + var handler = JSInterop.SetupVoid("updateBpmnElementStats", _ => true); + + var designer = new BpmnDiagramDesigner(new TestLocalizer(), Microsoft.Extensions.Options.Options.Create(new DesignerOptions()), null!, null!, null!, null!); + var activity = CreateActivity("root"); + var elementStats = new Dictionary + { + ["Element_1"] = new() { Completed = 1 } + }; + + // Simulate the race: the refresh arrives before the designer's wrapper -- and its canvas -- has ever been + // rendered, exactly as it does today during DiagramDesignerWrapper.OnInitializedAsync's unconditional + // first refresh, which runs before the render tree that would mount BpmnDesignerWrapper exists yet. + await designer.UpdateElementStatsAsync(elementStats); + + Assert.Empty(handler.Invocations); + + // Mounting the designer's render fragment is what today's production code does next, once the render + // tree containing it is actually processed by the renderer. + var context = new DisplayContext(activity); + Render(designer.DisplayDesigner(context)); + + var invocation = Assert.Single(handler.Invocations); + var appliedStats = Assert.IsType>(invocation.Arguments[1]); + Assert.Equal(1, appliedStats["Element_1"].Completed); + } + + /// + /// Creates a BpmnProcess root carrying one element, so mounts the + /// canvas rather than the "empty scope" notice. + /// + private static JsonObject CreateActivity(string id) => new() + { + ["id"] = id, + ["type"] = "Elsa.BpmnProcess", + ["version"] = 1, + ["activities"] = new JsonArray(), + ["process"] = new JsonObject + { + ["processId"] = "process", + ["elements"] = new JsonArray + { + new JsonObject { ["elementId"] = "Element_1", ["elementType"] = "task" } + } + } + }; + + private sealed class TestLocalizer : ILocalizer + { + public LocalizedString this[string? key] => new(key ?? string.Empty, key ?? string.Empty); + public LocalizedString this[string? key, params object[] arguments] => new(key ?? string.Empty, string.Format(key ?? string.Empty, arguments)); + } + + private sealed class NoOpActivityRegistry : IActivityRegistry + { + public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public IEnumerable List() => []; + public ActivityDescriptor? Find(string activityType, int? version = default) => null; + public IEnumerable FindAll(string activityType) => []; + public void MarkStale() + { + } + } +} diff --git a/src/modules/Elsa.Studio.Workflows.Tests/BpmnElementStatsProjectorTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/BpmnElementStatsProjectorTests.cs new file mode 100644 index 000000000..7c7b4b706 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Tests/BpmnElementStatsProjectorTests.cs @@ -0,0 +1,233 @@ +using System.Text.Json; +using Elsa.Api.Client.Resources.WorkflowInstances.Models; +using Elsa.Studio.Workflows.Domain.Models.Bpmn; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +/// +/// Covers : folding the BPMN diagnostics elsa-core projects onto a workflow +/// instance's journal (see and ) into +/// the element-keyed overlay the instance viewer's canvas reads. +/// +/// +/// The parallel-gateway scenario below is this item's own verification scenario (D1): a join waiting on a still +/// blocked sibling, and a completed branch whose flows were taken, both visible with nothing bound to the gateway +/// itself. +/// +public class BpmnElementStatsProjectorTests +{ + [Fact(DisplayName = "A parallel split where one branch blocks: the join is waiting, and the completed branch's flows are taken")] + public void Project_ParallelSplitOneBranchBlocked_ShowsTheJoinWaitingAndTheCompletedBranchesFlowsTaken() + { + var entries = new[] + { + DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, flowId: "flow-split-completed"), + DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, flowId: "flow-completed-join"), + DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join"), + }; + + var stats = BpmnElementStatsProjector.Project(entries); + + Assert.True(stats["join"].Blocked); + Assert.True(IsTaken(stats["flow-split-completed"])); + Assert.True(IsTaken(stats["flow-completed-join"])); + } + + [Fact(DisplayName = "A join firing clears its blocked flag and counts as completed")] + public void Project_Joined_ClearsBlockedAndCountsAsCompleted() + { + var entries = new[] + { + DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join"), + DiagnosticEntry(BpmnDiagnosticEventNames.Joined, elementId: "join"), + }; + + var stats = BpmnElementStatsProjector.Project(entries).Single(x => x.Key == "join").Value; + + Assert.False(stats.Blocked); + Assert.Equal(1, stats.Completed); + } + + [Fact(DisplayName = "A single diagnostic naming both an element and a flow updates both entries independently")] + public void Project_DiagnosticNamingBothAnElementAndAFlow_UpdatesBothEntries() + { + var entries = new[] { DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, elementId: "catch-event", flowId: "flow-into-catch") }; + + var stats = BpmnElementStatsProjector.Project(entries); + + Assert.Equal(1, stats["catch-event"].Started); + Assert.Equal(1, stats["catch-event"].Active); + Assert.True(IsTaken(stats["flow-into-catch"])); + } + + [Theory(DisplayName = "Faulted and BehaviorFailure both mark the element faulted")] + [InlineData(nameof(BpmnDiagnosticEventNames.Faulted))] + [InlineData(nameof(BpmnDiagnosticEventNames.BehaviorFailure))] + [InlineData(nameof(BpmnDiagnosticEventNames.CallActivityFailureRouted))] + public void Project_FaultKinds_MarkTheElementFaulted(string kind) + { + var entries = new[] { DiagnosticEntry(kind, elementId: "task") }; + + var stats = BpmnElementStatsProjector.Project(entries); + + Assert.True(stats["task"].Faulted); + } + + [Theory(DisplayName = "EscalationUnhandled and EscalationLate are documented as never a fault, and must not be folded into one")] + [InlineData(nameof(BpmnDiagnosticEventNames.EscalationUnhandled))] + [InlineData(nameof(BpmnDiagnosticEventNames.EscalationLate))] + public void Project_NeverFaultKinds_DoNotMarkTheElementFaulted(string kind) + { + var entries = new[] { DiagnosticEntry(kind, elementId: "boundary") }; + + var stats = BpmnElementStatsProjector.Project(entries); + + Assert.False(stats["boundary"].Faulted ?? false); + } + + [Fact(DisplayName = "Canceled marks the element cancelled and decrements its active count")] + public void Project_Canceled_MarksCancelledAndDecrementsActive() + { + var entries = new[] + { + DiagnosticEntry(BpmnDiagnosticEventNames.Scheduled, elementId: "task"), + DiagnosticEntry(BpmnDiagnosticEventNames.Canceled, elementId: "task"), + }; + + var stats = BpmnElementStatsProjector.Project(entries)["task"]; + + Assert.True(stats.Canceled); + Assert.Equal(0, stats.Active); + } + + [Fact(DisplayName = "Entries from a nested scope's own activity land in the same, single element-keyed map")] + public void Project_EntriesFromDifferentScopeActivities_FoldIntoTheSameMap() + { + // Diagnostics for the outer BpmnProcess scope and a nested one are both written on their own scope's + // activity id (never a child's), but element and flow ids are unique across the whole document, so the + // resulting map is global per instance rather than segregated by which scope wrote the entry. + var entries = new[] + { + DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, elementId: "outer-task", activityId: "outer-scope"), + DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "inner-join", activityId: "inner-scope"), + }; + + var stats = BpmnElementStatsProjector.Project(entries); + + Assert.Equal(2, stats.Count); + Assert.Equal(1, stats["outer-task"].Started); + Assert.True(stats["inner-join"].Blocked); + } + + [Fact(DisplayName = "Entries whose Source is not BPMN are ignored, even if their event name coincides with a diagnostic kind")] + public void Project_NonBpmnSourcedEntries_AreIgnored() + { + var entry = DiagnosticEntry(BpmnDiagnosticEventNames.Faulted, elementId: "task") with { Source = "SomethingElse" }; + + var stats = BpmnElementStatsProjector.Project([entry]); + + Assert.Empty(stats); + } + + [Fact(DisplayName = "The scope's own terminal Completed diagnostic, which names neither an element nor a flow, contributes nothing")] + public void Project_ScopeCompletionDiagnostic_ContributesNothing() + { + var entries = new[] { DiagnosticEntry(BpmnDiagnosticEventNames.Completed) }; + + var stats = BpmnElementStatsProjector.Project(entries); + + Assert.Empty(stats); + } + + [Fact(DisplayName = "A payload that arrives as a JsonElement -- exactly how it comes over the wire -- is read the same as a payload constructed directly")] + public void Project_PayloadAsJsonElement_IsReadCorrectly() + { + var json = """{"diagnosticId":"diag:1","elementId":"join","flowId":null,"tokenId":null,"kind":"Waiting","details":{}}"""; + var payload = JsonSerializer.Deserialize(json); + var entry = new WorkflowExecutionLogRecord( + Id: "log-1", + ActivityInstanceId: "instance-1", + ParentActivityInstanceId: null, + ActivityId: "scope", + ActivityType: "Elsa.BpmnProcess", + ActivityTypeVersion: 1, + ActivityName: null, + NodeId: "scope-node", + Timestamp: DateTimeOffset.UtcNow, + Sequence: 1, + EventName: BpmnDiagnosticEventNames.Waiting, + Message: null, + Source: BpmnDiagnosticEventNames.Source, + ActivityState: null, + Payload: payload); + + var stats = BpmnElementStatsProjector.Project([entry]); + + Assert.True(stats["join"].Blocked); + } + + [Fact(DisplayName = "Fold mutates an already-populated map in place, combining new entries with what was already folded")] + public void Fold_AlreadyPopulatedMap_CombinesNewEntriesWithExistingOnes() + { + var stats = new Dictionary(); + BpmnElementStatsProjector.Fold([DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join")], stats); + + BpmnElementStatsProjector.Fold([DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, elementId: "task")], stats); + + Assert.Equal(2, stats.Count); + Assert.True(stats["join"].Blocked); + Assert.Equal(1, stats["task"].Started); + } + + [Fact(DisplayName = "Folding the same element's records across two refreshes matches a single pass over all of them")] + public void Fold_SameElementAcrossTwoRefreshes_MatchesASinglePassOverAllRecords() + { + // The join's Waiting record (refresh 1's whole take) lands first and marks it blocked; its later Joined + // record (refresh 2's whole take) must still clear that blocked flag rather than the map getting stuck on + // whatever refresh 1 last saw. + var waiting = DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join"); + var joined = DiagnosticEntry(BpmnDiagnosticEventNames.Joined, elementId: "join"); + + var stats = new Dictionary(); + BpmnElementStatsProjector.Fold([waiting], stats); + BpmnElementStatsProjector.Fold([joined], stats); + + var expected = BpmnElementStatsProjector.Project([waiting, joined]); + + Assert.Equal(expected.Keys.OrderBy(k => k), stats.Keys.OrderBy(k => k)); + var expectedJoin = expected["join"]; + var actualJoin = stats["join"]; + Assert.Equal(expectedJoin.Started, actualJoin.Started); + Assert.Equal(expectedJoin.Completed, actualJoin.Completed); + Assert.Equal(expectedJoin.Active, actualJoin.Active); + Assert.Equal(expectedJoin.Blocked, actualJoin.Blocked); + Assert.Equal(expectedJoin.Faulted, actualJoin.Faulted); + Assert.Equal(expectedJoin.Canceled, actualJoin.Canceled); + Assert.False(actualJoin.Blocked); + Assert.Equal(1, actualJoin.Completed); + } + + private static bool IsTaken(BpmnElementStats stats) => (stats.Started ?? 0) > 0 || (stats.Completed ?? 0) > 0; + + private static WorkflowExecutionLogRecord DiagnosticEntry( + string kind, + string? elementId = null, + string? flowId = null, + string activityId = "scope") => new( + Id: Guid.NewGuid().ToString(), + ActivityInstanceId: "instance-1", + ParentActivityInstanceId: null, + ActivityId: activityId, + ActivityType: "Elsa.BpmnProcess", + ActivityTypeVersion: 1, + ActivityName: null, + NodeId: $"{activityId}-node", + Timestamp: DateTimeOffset.UtcNow, + Sequence: 1, + EventName: kind, + Message: null, + Source: BpmnDiagnosticEventNames.Source, + ActivityState: null, + Payload: new BpmnDiagnosticLogPayload(Guid.NewGuid().ToString(), elementId, flowId, null, kind, new Dictionary())); +} diff --git a/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperElementStatsTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperElementStatsTests.cs new file mode 100644 index 000000000..16e84cfc4 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperElementStatsTests.cs @@ -0,0 +1,345 @@ +using System.Reflection; +using System.Text.Json.Nodes; +using Elsa.Api.Client.Resources.WorkflowInstances.Models; +using Elsa.Api.Client.Resources.WorkflowInstances.Requests; +using Elsa.Api.Client.Shared.Models; +using Elsa.Studio.Workflows.Domain.Contracts; +using Elsa.Studio.Workflows.Domain.Models; +using Elsa.Studio.Workflows.Domain.Models.Bpmn; +using Elsa.Studio.Workflows.Extensions; +using Elsa.Studio.Workflows.Shared.Components; +using Elsa.Studio.Workflows.UI.Contexts; +using Elsa.Studio.Workflows.UI.Contracts; +using Microsoft.AspNetCore.Components; +using Refit; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +/// +/// Covers : the element-keyed BPMN overlay's own +/// refresh channel, alongside the existing activity-keyed +/// one. Exercises the wrapper directly (constructed with new, its injected properties and private fields set +/// through reflection) rather than through bUnit rendering, since none of what is under test here depends on the +/// render pipeline. +/// +public class DiagramDesignerWrapperElementStatsTests +{ + [Fact(DisplayName = "Does nothing when there is no workflow instance to read from")] + public async Task RefreshElementStatsAsync_NoWorkflowInstanceId_DoesNotCallTheJournal() + { + var journal = new RecordingWorkflowInstanceService(); + var wrapper = CreateWrapper(journal, new RecordingSinkDesigner(), workflowInstanceId: null); + + await wrapper.RefreshElementStatsAsync(); + + Assert.Empty(journal.Calls); + } + + [Fact(DisplayName = "Does nothing when the current designer does not accept an element-keyed overlay")] + public async Task RefreshElementStatsAsync_DesignerIsNotASink_DoesNotCallTheJournal() + { + var journal = new RecordingWorkflowInstanceService(); + var wrapper = CreateWrapper(journal, new NonSinkDesigner(), workflowInstanceId: "instance-1"); + + await wrapper.RefreshElementStatsAsync(); + + Assert.Empty(journal.Calls); + } + + [Fact(DisplayName = "Does nothing when the workflow carries no Elsa.BpmnProcess scope at all")] + public async Task RefreshElementStatsAsync_NoBpmnProcessInTheGraph_DoesNotCallTheJournal() + { + var journal = new RecordingWorkflowInstanceService(); + var wrapper = CreateWrapper(journal, new RecordingSinkDesigner(), workflowInstanceId: "instance-1", includeBpmnProcess: false); + + await wrapper.RefreshElementStatsAsync(); + + Assert.Empty(journal.Calls); + } + + [Fact(DisplayName = "Filters the journal by every Elsa.BpmnProcess activity id in the whole graph, including a nested scope")] + public async Task RefreshElementStatsAsync_FiltersByEveryBpmnProcessActivityId() + { + var journal = new RecordingWorkflowInstanceService( + new PagedListResponse { Items = [] }); + var wrapper = CreateWrapper(journal, new RecordingSinkDesigner(), workflowInstanceId: "instance-1"); + + await wrapper.RefreshElementStatsAsync(); + + var call = Assert.Single(journal.Calls); + Assert.Equal(new[] { "bpmn-outer", "bpmn-inner" }, call.Filter?.ActivityIds); + } + + [Fact(DisplayName = "Folds the fetched journal and pushes the result to the current designer's sink")] + public async Task RefreshElementStatsAsync_FoldsTheJournal_AndPushesItToTheSink() + { + var entry = DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join"); + var journal = new RecordingWorkflowInstanceService( + new PagedListResponse { Items = [entry] }); + var sink = new RecordingSinkDesigner(); + var wrapper = CreateWrapper(journal, sink, workflowInstanceId: "instance-1"); + + await wrapper.RefreshElementStatsAsync(); + + Assert.Equal(1, sink.UpdateCallCount); + Assert.True(sink.ReceivedStats!["join"].Blocked); + } + + [Fact(DisplayName = "Pages through the journal until a short page ends it, within a single refresh's page cap")] + public async Task RefreshElementStatsAsync_PagesThroughTheJournal() + { + var fullPage = Enumerable.Range(0, 200).Select(i => DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, flowId: $"flow-{i}")).ToArray(); + var shortPage = new[] { DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join") }; + var journal = new RecordingWorkflowInstanceService( + new PagedListResponse { Items = fullPage }, + new PagedListResponse { Items = shortPage }); + var sink = new RecordingSinkDesigner(); + var wrapper = CreateWrapper(journal, sink, workflowInstanceId: "instance-1"); + + await wrapper.RefreshElementStatsAsync(); + + Assert.Equal(2, journal.Calls.Count); + Assert.Equal(0, journal.Calls[0].Skip); + Assert.Equal(200, journal.Calls[1].Skip); + Assert.True(sink.ReceivedStats!["join"].Blocked); + Assert.Equal(201, sink.ReceivedStats!.Count); + } + + [Fact(DisplayName = "A backlog bigger than one refresh's page cap is folded a cap's worth at a time, continued on the next refresh")] + public async Task RefreshElementStatsAsync_BacklogLargerThanPageCap_IsFoldedAcrossRefreshes() + { + // Three full pages of 200 records each, plus a short page that ends the journal -- 650 total -- against a + // page cap of 2, so no single refresh fetches more than 2 pages (400 records) at once. + var fullPages = Enumerable.Range(0, 3) + .Select(p => new PagedListResponse + { + Items = Enumerable.Range(0, 200) + .Select(i => DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, flowId: $"flow-{p}-{i}")) + .ToArray() + }); + var shortPage = new PagedListResponse + { + Items = Enumerable.Range(0, 50).Select(i => DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, flowId: $"flow-tail-{i}")).ToArray() + }; + var journal = new RecordingWorkflowInstanceService([.. fullPages, shortPage]); + var sink = new RecordingSinkDesigner(); + var wrapper = CreateWrapper(journal, sink, workflowInstanceId: "instance-1"); + + wrapper.ElementStatsMaxPagesPerRefresh = 2; + + await wrapper.RefreshElementStatsAsync(); + + Assert.Equal(2, journal.Calls.Count); + Assert.Equal(0, journal.Calls[0].Skip); + Assert.Equal(200, journal.Calls[1].Skip); + Assert.Equal(400, sink.ReceivedStats!.Count); + + await wrapper.RefreshElementStatsAsync(); + + Assert.Equal(4, journal.Calls.Count); + Assert.Equal(400, journal.Calls[2].Skip); + Assert.Equal(600, journal.Calls[3].Skip); + Assert.Equal(650, sink.ReceivedStats!.Count); + } + + [Fact(DisplayName = "A second refresh after new records arrive requests only the new records, and folds them alongside the old ones")] + public async Task RefreshElementStatsAsync_SecondRefreshWithNewRecords_RequestsOnlyTheNewRecordsAndFoldsBoth() + { + var firstEntry = DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join"); + var secondEntry = DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, elementId: "task"); + var journal = new RecordingWorkflowInstanceService( + new PagedListResponse { Items = [firstEntry] }, + new PagedListResponse { Items = [secondEntry] }); + var sink = new RecordingSinkDesigner(); + var wrapper = CreateWrapper(journal, sink, workflowInstanceId: "instance-1"); + + await wrapper.RefreshElementStatsAsync(); + await wrapper.RefreshElementStatsAsync(); + + Assert.Equal(2, journal.Calls.Count); + Assert.Equal(0, journal.Calls[0].Skip); + Assert.Equal(1, journal.Calls[1].Skip); + Assert.True(sink.ReceivedStats!["join"].Blocked); + Assert.Equal(1, sink.ReceivedStats!["task"].Started); + } + + [Fact(DisplayName = "A refresh after the displayed instance changes starts over, rather than carrying over the previous instance's high-water mark or stats")] + public async Task RefreshElementStatsAsync_InstanceChanges_StartsOver() + { + var firstInstanceEntry = DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join"); + var secondInstanceEntry = DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, elementId: "task"); + var journal = new RecordingWorkflowInstanceService( + new PagedListResponse { Items = [firstInstanceEntry] }, + new PagedListResponse { Items = [secondInstanceEntry] }); + var sink = new RecordingSinkDesigner(); + var wrapper = CreateWrapper(journal, sink, workflowInstanceId: "instance-1"); + + await wrapper.RefreshElementStatsAsync(); + + typeof(DiagramDesignerWrapper) + .GetProperty(nameof(DiagramDesignerWrapper.WorkflowInstanceId))! + .SetValue(wrapper, "instance-2"); + await wrapper.RefreshElementStatsAsync(); + + Assert.Equal(2, journal.Calls.Count); + Assert.Equal(0, journal.Calls[0].Skip); + Assert.Equal(0, journal.Calls[1].Skip); + Assert.DoesNotContain("join", sink.ReceivedStats!.Keys); + Assert.Equal(1, sink.ReceivedStats!["task"].Started); + } + + private static WorkflowExecutionLogRecord DiagnosticEntry(string kind, string? elementId = null, string? flowId = null) => new( + Id: Guid.NewGuid().ToString(), + ActivityInstanceId: "instance-1", + ParentActivityInstanceId: null, + ActivityId: "bpmn-outer", + ActivityType: BpmnProcessConstants.ActivityTypeName, + ActivityTypeVersion: 1, + ActivityName: null, + NodeId: "bpmn-outer-node", + Timestamp: DateTimeOffset.UtcNow, + Sequence: 1, + EventName: kind, + Message: null, + Source: BpmnDiagnosticEventNames.Source, + ActivityState: null, + Payload: new BpmnDiagnosticLogPayload(Guid.NewGuid().ToString(), elementId, flowId, null, kind, new Dictionary())); + + /// + /// Constructs a bare with its private WorkflowInstanceService and + /// _activityGraph/_diagramDesigner fields set through reflection, mirroring the + /// SetDesigner-style helpers WorkflowInstanceDesignerDisconnectRefreshTests already uses for the + /// same reason: none of this depends on the component ever being rendered. + /// + private static DiagramDesignerWrapper CreateWrapper( + IWorkflowInstanceService journal, + IDiagramDesigner designer, + string? workflowInstanceId, + bool includeBpmnProcess = true) + { + var wrapper = new DiagramDesignerWrapper(); + + typeof(DiagramDesignerWrapper) + .GetProperty(nameof(DiagramDesignerWrapper.WorkflowInstanceId))! + .SetValue(wrapper, workflowInstanceId); + + typeof(DiagramDesignerWrapper) + .GetProperty("WorkflowInstanceService", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(wrapper, journal); + + typeof(DiagramDesignerWrapper) + .GetField("_diagramDesigner", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(wrapper, designer); + + var graph = BuildActivityGraph(includeBpmnProcess); + typeof(DiagramDesignerWrapper) + .GetField("_activityGraph", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(wrapper, graph); + + return wrapper; + } + + private static ActivityGraph BuildActivityGraph(bool includeBpmnProcess) + { + var root = Activity("root", "Elsa.Flowchart"); + var rootNode = new ActivityNode(root); + + if (includeBpmnProcess) + { + var outer = Activity("bpmn-outer", BpmnProcessConstants.ActivityTypeName); + var inner = Activity("bpmn-inner", BpmnProcessConstants.ActivityTypeName); + var outerNode = new ActivityNode(outer); + var innerNode = new ActivityNode(inner); + + outerNode.Children.Add(innerNode); + innerNode.Parents.Add(outerNode); + rootNode.Children.Add(outerNode); + outerNode.Parents.Add(rootNode); + } + + var graph = new ActivityGraph(root, new FixedActivityVisitor(rootNode)); + graph.IndexAsync().GetAwaiter().GetResult(); + return graph; + } + + private static JsonObject Activity(string id, string typeName) => new() + { + ["id"] = id, + ["nodeId"] = $"{id}-node", + ["type"] = typeName + }; + + private sealed class FixedActivityVisitor(ActivityNode root) : IActivityVisitor + { + public Task VisitAsync(JsonObject activity, CancellationToken cancellationToken = default) => Task.FromResult(root); + } + + /// A diagram designer that accepts the element-keyed overlay and records what it was given. + private sealed class RecordingSinkDesigner : IDiagramDesigner, IBpmnElementStatsSink + { + public IReadOnlyDictionary? ReceivedStats { get; private set; } + public int UpdateCallCount { get; private set; } + + public Task UpdateElementStatsAsync(IReadOnlyDictionary elementStats) + { + ReceivedStats = elementStats; + UpdateCallCount++; + return Task.CompletedTask; + } + + public Task LoadRootActivityAsync(JsonObject activity, IDictionary? activityStatsMap) => throw new NotSupportedException(); + public Task UpdateActivityAsync(string id, JsonObject activity) => throw new NotSupportedException(); + public Task UpdateActivityStatsAsync(string id, ActivityStats stats) => throw new NotSupportedException(); + public Task SelectActivityAsync(string id) => throw new NotSupportedException(); + public Task ReadRootActivityAsync() => throw new NotSupportedException(); + public RenderFragment DisplayDesigner(DisplayContext context) => throw new NotSupportedException(); + } + + /// An ordinary diagram designer -- a flowchart's, say -- that does not accept an element-keyed overlay. + private sealed class NonSinkDesigner : IDiagramDesigner + { + public Task LoadRootActivityAsync(JsonObject activity, IDictionary? activityStatsMap) => throw new NotSupportedException(); + public Task UpdateActivityAsync(string id, JsonObject activity) => throw new NotSupportedException(); + public Task UpdateActivityStatsAsync(string id, ActivityStats stats) => throw new NotSupportedException(); + public Task SelectActivityAsync(string id) => throw new NotSupportedException(); + public Task ReadRootActivityAsync() => throw new NotSupportedException(); + public RenderFragment DisplayDesigner(DisplayContext context) => throw new NotSupportedException(); + } + + private sealed class RecordingWorkflowInstanceService : IWorkflowInstanceService + { + private readonly Queue> _pages; + + public RecordingWorkflowInstanceService(params PagedListResponse[] pages) => _pages = new(pages); + + public List<(JournalFilter? Filter, int? Skip, int? Take)> Calls { get; } = []; + + public Task> GetJournalAsync( + string instanceId, + JournalFilter? filter = null, + int? skip = null, + int? take = null, + CancellationToken cancellationToken = default) + { + Calls.Add((filter, skip, take)); + + var page = _pages.Count > 0 + ? _pages.Dequeue() + : new PagedListResponse { Items = [] }; + + return Task.FromResult(page); + } + + public Task> ListAsync(ListWorkflowInstancesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task DeleteAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task BulkDeleteAsync(IEnumerable instanceIds, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task CancelAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task BulkCancelAsync(BulkCancelWorkflowInstancesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task ExportAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task BulkExportAsync(IEnumerable ids, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task BulkImportAsync(IEnumerable streamParts, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetVariablesAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } +} diff --git a/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperFinishedInstanceElementStatsTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperFinishedInstanceElementStatsTests.cs new file mode 100644 index 000000000..3be16ba7e --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperFinishedInstanceElementStatsTests.cs @@ -0,0 +1,201 @@ +using System.Text.Json.Nodes; +using Bunit; +using Elsa.Api.Client.Extensions; +using Elsa.Api.Client.Resources.ActivityDescriptors.Models; +using Elsa.Api.Client.Resources.ActivityExecutions.Models; +using Elsa.Api.Client.Resources.Resilience.Models; +using Elsa.Api.Client.Resources.WorkflowDefinitions.Models; +using Elsa.Api.Client.Resources.WorkflowInstances.Models; +using Elsa.Api.Client.Resources.WorkflowInstances.Requests; +using Elsa.Api.Client.Shared.Models; +using Elsa.Studio.DomInterop.Contracts; +using Elsa.Studio.DomInterop.Models; +using Elsa.Studio.Extensions; +using Elsa.Studio.Localization; +using Elsa.Studio.Workflows.DiagramDesigners.Bpmn; +using Elsa.Studio.Workflows.Designer.Models; +using Elsa.Studio.Workflows.Domain.Contracts; +using Elsa.Studio.Workflows.Domain.Models; +using Elsa.Studio.Workflows.Domain.Models.Bpmn; +using Elsa.Studio.Workflows.Extensions; +using Elsa.Studio.Workflows.Shared.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using MudBlazor; +using MudBlazor.Services; +using Refit; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +/// +/// Covers the case the mount race hid completely: a finished workflow instance never gets a +/// -driven +/// observer tick (UpdateObserverAsync only creates one while the instance is running), so the one +/// unconditional refresh already performs on load -- before it has ever +/// rendered its -- is the only chance the overlay ever gets to reach the canvas. +/// +public sealed class DiagramDesignerWrapperFinishedInstanceElementStatsTests : BunitContext, IAsyncLifetime +{ + private const string ElementId = "join"; + + public DiagramDesignerWrapperFinishedInstanceElementStatsTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + JSInterop.Setup("loadBpmnDiagram", _ => true).SetResult([]); + Services.AddMudServices(); + Services.AddLogging(); + Services.AddCoreInternal(); + Services.AddRemoteBackend(); + Services.AddWorkflowsModule(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(); + Services.AddSingleton(new StubActivityExecutionService()); + Services.AddSingleton(new StubWorkflowInstanceService(JournalEntry())); + } + + Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask; + async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync(); + + [Fact] + public void OpeningAFinishedInstance_RefreshesTheElementOverlayExactlyOnce_WithNoObserverTicksAtAll() + { + var handler = JSInterop.SetupVoid("updateBpmnElementStats", _ => true); + var root = CreateBpmnRoot(); + + Render(); + Render(parameters => parameters + .Add(x => x.WorkflowDefinitionVersionId, "version-1") + .Add(x => x.Activity, root) + .Add(x => x.WorkflowDefinition, CreateDefinition(root)) + .Add(x => x.WorkflowInstanceId, "instance-1")); + + var invocation = Assert.Single(handler.Invocations); + var appliedStats = Assert.IsType>(invocation.Arguments[1]); + Assert.True(appliedStats[ElementId].Blocked); + } + + private static WorkflowExecutionLogRecord JournalEntry() => new( + Id: Guid.NewGuid().ToString(), + ActivityInstanceId: "instance-1", + ParentActivityInstanceId: null, + ActivityId: "order-process", + ActivityType: BpmnProcessConstants.ActivityTypeName, + ActivityTypeVersion: 1, + ActivityName: null, + NodeId: "Workflow1:order-process", + Timestamp: DateTimeOffset.UtcNow, + Sequence: 1, + EventName: BpmnDiagnosticEventNames.Waiting, + Message: null, + Source: BpmnDiagnosticEventNames.Source, + ActivityState: null, + Payload: new BpmnDiagnosticLogPayload(Guid.NewGuid().ToString(), ElementId, null, null, BpmnDiagnosticEventNames.Waiting, new Dictionary())); + + private static JsonObject CreateBpmnRoot() => new() + { + ["id"] = "order-process", + ["nodeId"] = "Workflow1:order-process", + ["name"] = "Order Process", + ["type"] = BpmnProcessConstants.ActivityTypeName, + ["version"] = 1, + ["customProperties"] = new JsonObject { ["canStartWorkflow"] = true }, + ["process"] = new JsonObject + { + ["processId"] = "order-process", + ["name"] = "Order Process", + ["isExecutable"] = true, + ["elements"] = new JsonArray + { + new JsonObject { ["elementId"] = "StartEvent_1", ["elementType"] = "startEvent" }, + new JsonObject { ["elementId"] = ElementId, ["elementType"] = "parallelGateway" }, + new JsonObject { ["elementId"] = "EndEvent_1", ["elementType"] = "endEvent" } + }, + ["sequenceFlows"] = new JsonArray() + }, + ["workBindings"] = new JsonObject(), + ["activities"] = new JsonArray() + }; + + private static WorkflowDefinition CreateDefinition(JsonObject root) => new() + { + Id = "version-1", + DefinitionId = "definition-1", + Name = "Order Process", + Root = root + }; + + private sealed class StubActivityExecutionService : IActivityExecutionService + { + public Task GetReportAsync(string workflowInstanceId, JsonObject containerActivity, CancellationToken cancellationToken = default) => + Task.FromResult(new ActivityExecutionReport([])); + + public Task> ListAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> ListSummariesAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetCallStackAsync(string activityExecutionId, bool? includeCrossWorkflowChain = null, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetRetriesAsync(string activityInstanceId, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class StubWorkflowInstanceService(params WorkflowExecutionLogRecord[] entries) : IWorkflowInstanceService + { + public Task> GetJournalAsync( + string instanceId, + JournalFilter? filter = null, + int? skip = null, + int? take = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new PagedListResponse { Items = skip is null or 0 ? entries : [] }); + + public Task> ListAsync(ListWorkflowInstancesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task DeleteAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task BulkDeleteAsync(IEnumerable instanceIds, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task CancelAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task BulkCancelAsync(BulkCancelWorkflowInstancesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task ExportAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task BulkExportAsync(IEnumerable ids, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task BulkImportAsync(IEnumerable streamParts, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetVariablesAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class TestActivityRegistry : IActivityRegistry + { + private readonly IReadOnlyDictionary _descriptors = new Dictionary + { + [BpmnProcessConstants.ActivityTypeName] = new() + { + TypeName = BpmnProcessConstants.ActivityTypeName, + Name = BpmnProcessConstants.ActivityTypeName, + DisplayName = "BPMN Process", + Version = 1, + IsBrowsable = true, + IsContainer = true + } + }; + + public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public IEnumerable List() => _descriptors.Values; + public ActivityDescriptor? Find(string activityType, int? version = null) => _descriptors.GetValueOrDefault(activityType); + public IEnumerable FindAll(string activityType) => _descriptors.TryGetValue(activityType, out var descriptor) ? [descriptor] : []; + + public void MarkStale() + { + } + } + + private sealed class NoOpDomAccessor : IDomAccessor + { + public Task GetBoundingClientRectAsync(ElementRef elementRef, CancellationToken cancellationToken = default) => Task.FromResult(new DomRect()); + public Task GetVisibleHeightAsync(ElementRef elementRef, CancellationToken cancellationToken = default) => Task.FromResult(0d); + public Task ClickElementAsync(ElementRef elementRef, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class TestLocalizer : ILocalizer + { + public LocalizedString this[string? key] => new(key ?? string.Empty, key ?? string.Empty); + public LocalizedString this[string? key, params object[] arguments] => new(key ?? string.Empty, string.Format(key ?? string.Empty, arguments)); + } +} diff --git a/src/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerElementStatsRefreshTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerElementStatsRefreshTests.cs new file mode 100644 index 000000000..1b9ae6723 --- /dev/null +++ b/src/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerElementStatsRefreshTests.cs @@ -0,0 +1,198 @@ +using System.Reflection; +using System.Text.Json.Nodes; +using Bunit; +using Elsa.Api.Client.RealTime.Messages; +using Elsa.Api.Client.Resources.ActivityExecutions.Models; +using Elsa.Api.Client.Resources.WorkflowInstances.Enums; +using Elsa.Api.Client.Resources.WorkflowInstances.Models; +using Elsa.Studio.Contracts; +using Elsa.Studio.DomInterop.Contracts; +using Elsa.Studio.Localization; +using Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components; +using Elsa.Studio.Workflows.Contracts; +using Elsa.Studio.Workflows.Domain.Contracts; +using Elsa.Studio.Workflows.Domain.Models; +using Elsa.Studio.Workflows.Shared.Components; +using Elsa.Studio.Workflows.UI.Contexts; +using Elsa.Studio.Workflows.UI.Contracts; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.JSInterop; +using Xunit; + +namespace Elsa.Studio.Workflows.Tests; + +/// +/// Covers that refreshes the element-keyed BPMN overlay +/// () on the very same event that already refreshes +/// the activity-keyed one -- -- so a gateway or +/// a sequence flow, which never appears in that message's own Stats, is refreshed on the same cadence. +/// +/// +/// The refresh added here rides the very same event subscription WorkflowInstanceDesignerDisconnectRefreshTests +/// already proves is detached on disposal (the #992 guarantee): it is not a new timer with a disposal race of its +/// own to pin, so this file's own coverage is limited to proving the new call is actually wired into that handler. +/// +public sealed class WorkflowInstanceDesignerElementStatsRefreshTests : BunitContext, IAsyncLifetime +{ + public WorkflowInstanceDesignerElementStatsRefreshTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddSingleton(new TestLocalizer()); + Services.AddSingleton(new ActivityRegistryStub()); + Services.AddSingleton(DispatchProxy.Create()); + Services.AddSingleton(DispatchProxy.Create()); + Services.AddSingleton(DispatchProxy.Create()); + Services.AddSingleton(DispatchProxy.Create()); + Services.AddSingleton(DispatchProxy.Create()); + Services.AddSingleton(DispatchProxy.Create()); + Services.AddSingleton(DispatchProxy.Create()); + Services.AddSingleton(new RemoteFeatureProviderStub()); + } + + Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask; + async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync(); + + [Fact] + public async Task OnActivityExecutionLogUpdated_RefreshesTheElementKeyedOverlay() + { + var cut = RenderDesigner(); + var designer = new RecordingDiagramDesignerWrapper(); + SetDesigner(cut.Instance, designer); + + await InvokeOnActivityExecutionLogUpdated(cut.Instance, new ActivityExecutionLogUpdatedMessage([])); + + Assert.Equal(1, designer.RefreshElementStatsCallCount); + } + + [Fact] + public async Task OnActivityExecutionLogUpdated_RefreshesTheElementKeyedOverlay_AlongsideActivityStats() + { + // Both the pre-existing activity-keyed stats and the new element-keyed overlay are refreshed from the + // one message: the point of D1 is that a gateway or a flow, which never appears in message.Stats, is + // still covered by something driven from the very same journal update. + var stats = new ActivityExecutionStats { ActivityId = "activity-1", ActivityNodeId = "node-1" }; + var cut = RenderDesigner(); + var designer = new RecordingDiagramDesignerWrapper(); + var innerDesigner = new RecordingActivityStatsDesigner(); + SetDesigner(cut.Instance, designer); + SetInnerDiagramDesigner(designer, innerDesigner); + + await InvokeOnActivityExecutionLogUpdated(cut.Instance, new ActivityExecutionLogUpdatedMessage([stats])); + + Assert.Equal(1, innerDesigner.UpdateActivityStatsCallCount); + Assert.Equal(1, designer.RefreshElementStatsCallCount); + } + + private IRenderedComponent RenderDesigner() + { + var workflowInstance = new WorkflowInstance + { + Id = "instance-1", + DefinitionId = "definition-1", + Status = WorkflowStatus.Finished + }; + + return Render(parameters => parameters + .Add(x => x.WorkflowInstance, workflowInstance)); + } + + /// + /// A that skips its own markup and first-render setup, exactly like + /// WorkflowInstanceDesignerDisconnectRefreshTests.TestWorkflowInstanceDesigner: nothing under test here + /// depends on the real render tree, and rendering it for real would pull in dependencies (Radzen's splitter, + /// MudBlazor's tabs) this test has no reason to stub. + /// + private sealed class TestWorkflowInstanceDesigner : WorkflowInstanceDesigner + { + protected override Task OnAfterRenderAsync(bool firstRender) => Task.CompletedTask; + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + } + } + + private static Task InvokeOnActivityExecutionLogUpdated(WorkflowInstanceDesigner instance, ActivityExecutionLogUpdatedMessage message) + { + var method = typeof(WorkflowInstanceDesigner).GetMethod("OnActivityExecutionLogUpdated", BindingFlags.Instance | BindingFlags.NonPublic)!; + return (Task)method.Invoke(instance, [message])!; + } + + /// + /// Attaches a bare subclass to _designer, mirroring + /// WorkflowInstanceDesignerDisconnectRefreshTests.SetDesigner. + /// + private static void SetDesigner(WorkflowInstanceDesigner instance, DiagramDesignerWrapper designer) + { + var field = typeof(WorkflowInstanceDesigner).GetField("_designer", BindingFlags.Instance | BindingFlags.NonPublic)!; + field.SetValue(instance, designer); + } + + /// Attaches a fake to a wrapper's own private _diagramDesigner field. + private static void SetInnerDiagramDesigner(DiagramDesignerWrapper wrapper, IDiagramDesigner innerDesigner) + { + var field = typeof(DiagramDesignerWrapper).GetField("_diagramDesigner", BindingFlags.Instance | BindingFlags.NonPublic)!; + field.SetValue(wrapper, innerDesigner); + } + + /// A whose element-stats refresh is counted instead of run for real. + private sealed class RecordingDiagramDesignerWrapper : DiagramDesignerWrapper + { + public int RefreshElementStatsCallCount { get; private set; } + + internal override Task RefreshElementStatsAsync() + { + RefreshElementStatsCallCount++; + return Task.CompletedTask; + } + } + + /// The fake a forwards to; records + /// activity-stats updates so the test can prove both overlays are refreshed from the one message. + private sealed class RecordingActivityStatsDesigner : IDiagramDesigner + { + public int UpdateActivityStatsCallCount { get; private set; } + + public Task UpdateActivityStatsAsync(string id, ActivityStats stats) + { + UpdateActivityStatsCallCount++; + return Task.CompletedTask; + } + + public Task LoadRootActivityAsync(JsonObject activity, IDictionary? activityStatsMap) => throw new NotSupportedException(); + public Task UpdateActivityAsync(string id, JsonObject activity) => throw new NotSupportedException(); + public Task SelectActivityAsync(string id) => throw new NotSupportedException(); + public Task ReadRootActivityAsync() => throw new NotSupportedException(); + public RenderFragment DisplayDesigner(DisplayContext context) => throw new NotSupportedException(); + } + + private sealed class RemoteFeatureProviderStub : IRemoteFeatureProvider + { + public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => Task.FromResult(false); + public Task> ListAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class ActivityRegistryStub : IActivityRegistry + { + public Task RefreshAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public IEnumerable List() => throw new NotSupportedException(); + public Elsa.Api.Client.Resources.ActivityDescriptors.Models.ActivityDescriptor? Find(string activityType, int? version = null) => throw new NotSupportedException(); + public IEnumerable FindAll(string activityType) => throw new NotSupportedException(); + public void MarkStale() => throw new NotSupportedException(); + } + + private sealed class TestLocalizer : ILocalizer + { + public LocalizedString this[string? key] => new(key ?? string.Empty, key ?? string.Empty); + public LocalizedString this[string? key, params object[] arguments] => new(key ?? string.Empty, string.Format(key ?? string.Empty, arguments)); + } + + /// A that throws for every call, for services this test never exercises. + private class ThrowingProxy : DispatchProxy + { + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => + throw new InvalidOperationException($"Unexpected call to {targetMethod!.DeclaringType!.Name}.{targetMethod.Name}."); + } +} diff --git a/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs index 885f04101..b6c97f8f8 100644 --- a/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs +++ b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs @@ -290,6 +290,11 @@ private async Task OnActivityExecutionLogUpdated(ActivityExecutionLogUpdatedMess await _designer.UpdateActivityStatsAsync(activityId, Map(stats)); } + // Refreshed on the same cadence as the activity-keyed stats above: a gateway, an intermediate event or a + // sequence flow has no activity id of its own, so it never appears in message.Stats, but the journal update + // that produced this message is exactly what its own BPMN diagnostics ride along on. + await _designer.RefreshElementStatsAsync(); + await InvokeAsync(StateHasChanged); // If we received an update for the selected activity, refresh the activity details. diff --git a/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDesignerWrapper.razor.cs b/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDesignerWrapper.razor.cs index e53a08812..2bc434220 100644 --- a/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDesignerWrapper.razor.cs +++ b/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDesignerWrapper.razor.cs @@ -2,6 +2,7 @@ using Elsa.Studio.Workflows.Designer.Components; using Elsa.Studio.Workflows.Designer.Options; using Elsa.Studio.Workflows.Domain.Models; +using Elsa.Studio.Workflows.Domain.Models.Bpmn; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; @@ -46,6 +47,14 @@ public partial class BpmnDesignerWrapper private BpmnDesigner? Designer { get; set; } private bool UseReactFlow => DesignerOptions.Value.UseReactFlow; + /// + /// The latest element-keyed instance overlay handed to , retained so it + /// can be applied on as soon as it exists (see ). Mirrors + /// BpmnDiagramDesigner._pendingElementStats one level up, since can still be null + /// when a call arrives just after this wrapper itself has mounted. + /// + private IReadOnlyDictionary? _pendingElementStats; + /// /// Whether the scope being displayed has nothing to draw: no process payload at all, or one that declares /// no elements. That is what a BpmnProcess added from the toolbox looks like, and what a scope whose @@ -94,6 +103,39 @@ public async Task UpdateActivityStatsAsync(string id, ActivityStats stats) await Designer.UpdateActivityStatsAsync(id, stats); } + /// + /// Updates the element-keyed instance overlay (gateways, events and sequence flows). + /// + public async Task UpdateElementStatsAsync(IReadOnlyDictionary elementStats) + { + _pendingElementStats = elementStats; + + if (Designer != null) + await Designer.UpdateElementStatsAsync(elementStats); + } + + /// + /// Synchronously stakes the latest element-stats overlay for to flush once + /// exists, without going through 's async call. + /// + /// + /// This is what lets hand a retained overlay to this wrapper the moment it is + /// captured -- from inside a synchronous component-reference-capture callback, where starting and discarding an + /// async call would swallow any exception it threw. + /// + internal void SetPendingElementStats(IReadOnlyDictionary elementStats) => _pendingElementStats = elementStats; + + /// + /// Applies a retained element-stats overlay that arrived before existed, the moment it + /// does -- the same "drain pending work on first render" shape itself uses for its own + /// initial LoadBpmnAsync call. + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && Designer != null && _pendingElementStats != null) + await Designer.UpdateElementStatsAsync(_pendingElementStats); + } + /// /// Selects the element bound to the specified activity. /// diff --git a/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDiagramDesigner.cs b/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDiagramDesigner.cs index dd162e07c..e83a6dc64 100644 --- a/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDiagramDesigner.cs +++ b/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDiagramDesigner.cs @@ -28,7 +28,7 @@ public class BpmnDiagramDesigner( IDialogService dialogService, IBpmnInterchangeService bpmnInterchangeService, IFiles files, - IUserMessageService userMessageService) : IDiagramDesignerToolboxProvider + IUserMessageService userMessageService) : IDiagramDesignerToolboxProvider, IBpmnElementStatsSink { /// /// The custom property key elsa-core stores the imported BPMN document's source XML under. @@ -41,6 +41,21 @@ public class BpmnDiagramDesigner( private string? _sourceXml; private WorkflowDefinition? _workflowDefinition; + /// + /// The latest element-keyed instance overlay handed to , held only until + /// is captured -- from that point on, is the + /// one that retains and flushes it (see ). + /// + /// + /// A refresh can arrive before the canvas exists -- most notably the one unconditional refresh a freshly + /// opened instance gets on load (see DiagramDesignerWrapper.LoadActivityCoreAsync), which runs during + /// OnInitializedAsync, well before this designer's own has been + /// created. Dropping that refresh instead of retaining it would mean a finished instance -- one that never + /// gets a later observer-driven refresh to fall back on -- never gets its overlay at all. Only the latest + /// value is kept, matching the semantics already documents. + /// + private IReadOnlyDictionary? _pendingElementStats; + /// public async Task LoadRootActivityAsync(JsonObject activity, IDictionary? activityStatsMap) { @@ -78,6 +93,13 @@ public async Task SelectActivityAsync(string id) await InvokeDesignerActionAsync(x => x.SelectActivityAsync(id)); } + /// + public async Task UpdateElementStatsAsync(IReadOnlyDictionary elementStats) + { + _pendingElementStats = elementStats; + await InvokeDesignerActionAsync(x => x.UpdateElementStatsAsync(elementStats)); + } + /// /// /// Returns the whole root activity JSON exactly as it was given and since edited by @@ -106,7 +128,20 @@ public RenderFragment DisplayDesigner(DisplayContext context) builder.AddAttribute(sequence++, nameof(BpmnDesignerWrapper.ActivityStats), context.ActivityStats); builder.AddAttribute(sequence++, nameof(BpmnDesignerWrapper.ActivitySelected), context.ActivitySelectedCallback); builder.AddAttribute(sequence++, nameof(BpmnDesignerWrapper.ActivityDoubleClick), context.ActivityDoubleClickCallback); - builder.AddComponentReferenceCapture(sequence++, @ref => _designerWrapper = (BpmnDesignerWrapper)@ref); + builder.AddComponentReferenceCapture(sequence++, @ref => + { + var isFirstMount = _designerWrapper == null; + _designerWrapper = (BpmnDesignerWrapper)@ref; + + // Hand off the latest retained overlay the moment the wrapper exists, rather than only on the + // next explicit UpdateElementStatsAsync call, which -- for a finished instance -- may never come + // (see the remarks on _pendingElementStats). This is a synchronous field assignment, not an async + // call: this callback is itself synchronous, so starting and discarding a task here would swallow + // any exception it threw. BpmnDesignerWrapper's own awaited first-render flush is what actually + // delivers the value once its canvas is ready. + if (isFirstMount && _pendingElementStats != null) + _designerWrapper.SetPendingElementStats(_pendingElementStats); + }); builder.CloseComponent(); }; diff --git a/src/modules/Elsa.Studio.Workflows/Shared/Components/DiagramDesignerWrapper.razor.cs b/src/modules/Elsa.Studio.Workflows/Shared/Components/DiagramDesignerWrapper.razor.cs index ec150fa5b..863387675 100644 --- a/src/modules/Elsa.Studio.Workflows/Shared/Components/DiagramDesignerWrapper.razor.cs +++ b/src/modules/Elsa.Studio.Workflows/Shared/Components/DiagramDesignerWrapper.razor.cs @@ -1,12 +1,15 @@ using System.Text.Json.Nodes; using Elsa.Api.Client.Extensions; using Elsa.Api.Client.Resources.WorkflowDefinitions.Models; +using Elsa.Api.Client.Resources.WorkflowInstances.Models; +using Elsa.Api.Client.Resources.WorkflowInstances.Requests; using Elsa.Api.Client.Shared.Models; using Elsa.Studio.Workflows.Domain.Contexts; using Elsa.Studio.Workflows.Domain.Contracts; using Elsa.Studio.Workflows.Domain.Extensions; using Elsa.Studio.Workflows.DiagramDesigners; using Elsa.Studio.Workflows.Domain.Models; +using Elsa.Studio.Workflows.Domain.Models.Bpmn; using Elsa.Studio.Workflows.Extensions; using Elsa.Studio.Workflows.Models; using Elsa.Studio.Workflows.Shared.Args; @@ -33,6 +36,21 @@ public partial class DiagramDesignerWrapper private List _breadcrumbItems = new(); private IDictionary _activityStats = new Dictionary(); + private readonly Dictionary _elementStats = new(); + private string? _elementStatsInstanceId; + private HashSet _elementStatsBpmnProcessActivityIds = new(); + private int _elementStatsHighWaterMark; + + /// + /// The most journal pages a single tick will fetch, at 200 records a + /// page (see pageSize in ): 50 pages bounds one tick to 10,000 + /// records. A backlog larger than that -- a first load of a long-running instance, or a burst built up while + /// the tab was backgrounded -- is not truncated, only spread across refreshes: the high-water mark advances by + /// whatever was actually fetched, so the next tick picks up exactly where this one left off. Settable (rather + /// than a plain const) only so a test can lower it to exercise the cap without paging 10,000 records. + /// + internal int ElementStatsMaxPagesPerRefresh { get; set; } = 50; + private ActivityGraph _activityGraph = null!; private IDictionary _indexedActivityNodes = new Dictionary(); @@ -116,6 +134,9 @@ public partial class DiagramDesignerWrapper [Inject] private IWorkflowDefinitionService WorkflowDefinitionService { get; set; } = null!; + [Inject] + private IWorkflowInstanceService WorkflowInstanceService { get; set; } = null!; + [Inject] private ISnackbar Snackbar { get; set; } = null!; @@ -207,6 +228,92 @@ public async Task UpdateActivityStatsAsync(string activityId, ActivityStats stat await _diagramDesigner!.UpdateActivityStatsAsync(activityId, stats); } + /// + /// Refreshes the element-keyed BPMN instance overlay (gateways, events and sequence flows -- anything without + /// an Elsa activity id) from the workflow instance's journal, and pushes it to the current diagram designer. + /// + /// + /// A no-op when there is no workflow instance to read from, or the current designer does not accept an + /// element-keyed overlay () -- fetching and folding the journal for a + /// flowchart or state machine instance would be wasted work. Called on the same cadence + /// already refreshes + /// on, so the two overlays stay in step. + /// + internal virtual async Task RefreshElementStatsAsync() + { + if (WorkflowInstanceId == null || _diagramDesigner is not IBpmnElementStatsSink sink) + return; + + var elementStats = await FetchElementStatsAsync(WorkflowInstanceId); + await sink.UpdateElementStatsAsync(elementStats); + } + + /// + /// Reads the BPMN diagnostics projected onto the journal of every Elsa.BpmnProcess scope anywhere in + /// the workflow (not merely the currently displayed container: a nested scope's diagnostics land on that + /// scope's own activity, and BPMN element and flow ids are unique across the whole document), and folds them + /// into an element-keyed stats map. + /// + /// + /// Incremental: is the number of matching journal records already + /// folded into , so a tick only ever fetches the records that arrived since the + /// previous one, rather than re-fetching and re-folding the whole journal from the start every time. This is + /// only safe because the journal is append-only in the order the API returns it (see the Sequence on + /// ): the high-water mark is a plain count of already-folded records, + /// not an id or timestamp, because has no way to filter by either. The map and + /// mark are reset whenever the displayed instance or the set of Elsa.BpmnProcess scope activity ids + /// changes, since a high-water mark from a different instance or a different filter has nothing to do with + /// the one about to be fetched. A single tick fetches at most + /// pages, so a backlog larger than that -- a first load, or a burst built up while the tab was backgrounded -- + /// is folded a page cap's worth at a time across successive refreshes rather than in one unbounded loop. + /// + private async Task> FetchElementStatsAsync(string workflowInstanceId) + { + var bpmnProcessActivityIds = GetBpmnProcessActivityIds(); + + if (bpmnProcessActivityIds.Count == 0) + return _elementStats; + + if (workflowInstanceId != _elementStatsInstanceId + || !_elementStatsBpmnProcessActivityIds.SetEquals(bpmnProcessActivityIds)) + { + _elementStats.Clear(); + _elementStatsHighWaterMark = 0; + _elementStatsInstanceId = workflowInstanceId; + _elementStatsBpmnProcessActivityIds = new HashSet(bpmnProcessActivityIds); + } + + var filter = new JournalFilter { ActivityIds = bpmnProcessActivityIds }; + var entries = new List(); + const int pageSize = 200; + var skip = _elementStatsHighWaterMark; + + for (var page = 0; page < ElementStatsMaxPagesPerRefresh; page++) + { + var response = await WorkflowInstanceService.GetJournalAsync(workflowInstanceId, filter, skip, pageSize); + entries.AddRange(response.Items); + skip += response.Items.Count; + + if (response.Items.Count < pageSize) + break; + } + + _elementStatsHighWaterMark = skip; + BpmnElementStatsProjector.Fold(entries, _elementStats); + + return _elementStats; + } + + /// + /// Every Elsa.BpmnProcess activity's own id, anywhere in the whole workflow -- the outermost scope and + /// every nested one -- since BpmnScopeHost only ever writes a diagnostic onto the scope's own activity. + /// + private ICollection GetBpmnProcessActivityIds() => + _activityGraph.ActivityNodeLookup.Values + .Where(node => node.Activity.GetTypeName() == BpmnProcessConstants.ActivityTypeName) + .Select(node => node.Activity.GetId()) + .ToList(); + /// Reads the activity from the designer. public async Task ReadActivityAsync() { @@ -457,6 +564,8 @@ private async Task RefreshActivityStatsAsync() Uncompleted = x.UncompletedCount, Metadata = x.Metadata }); + + await RefreshElementStatsAsync(); } }