@nanobpm/workflow 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/declarative.d.ts +172 -10
- package/dist/declarative.js +213 -355
- package/dist/nodes/branch.d.ts +11 -0
- package/dist/nodes/branch.js +40 -0
- package/dist/nodes/break.d.ts +8 -0
- package/dist/nodes/break.js +19 -0
- package/dist/nodes/continue.d.ts +8 -0
- package/dist/nodes/continue.js +18 -0
- package/dist/nodes/forEach.d.ts +24 -0
- package/dist/nodes/forEach.js +104 -0
- package/dist/nodes/index.d.ts +11 -0
- package/dist/nodes/index.js +18 -0
- package/dist/nodes/loop.d.ts +9 -0
- package/dist/nodes/loop.js +25 -0
- package/dist/nodes/parallel.d.ts +9 -0
- package/dist/nodes/parallel.js +40 -0
- package/dist/nodes/registry.d.ts +33 -0
- package/dist/nodes/registry.js +71 -0
- package/dist/nodes/run.d.ts +10 -0
- package/dist/nodes/run.js +21 -0
- package/dist/nodes/signal.d.ts +11 -0
- package/dist/nodes/signal.js +21 -0
- package/dist/nodes/switch.d.ts +11 -0
- package/dist/nodes/switch.js +51 -0
- package/dist/nodes/task.d.ts +11 -0
- package/dist/nodes/task.js +21 -0
- package/dist/nodes/timer.d.ts +9 -0
- package/dist/nodes/timer.js +34 -0
- package/dist/test-support/diff.d.ts +8 -0
- package/dist/test-support/diff.js +70 -0
- package/dist/test-support/index.d.ts +8 -0
- package/dist/test-support/index.js +49 -0
- package/dist/test-support/normalize.d.ts +19 -0
- package/dist/test-support/normalize.js +245 -0
- package/dist/test-support/parity.d.ts +28 -0
- package/dist/test-support/parity.js +61 -0
- package/dist/test-support/xml.d.ts +17 -0
- package/dist/test-support/xml.js +135 -0
- package/dist/types.d.ts +20 -56
- package/package.json +14 -6
- package/src/declarative.ts +327 -359
- package/src/nodes/README.md +123 -0
- package/src/nodes/branch.ts +48 -0
- package/src/nodes/break.ts +25 -0
- package/src/nodes/continue.ts +24 -0
- package/src/nodes/forEach.ts +137 -0
- package/src/nodes/index.ts +19 -0
- package/src/nodes/loop.ts +34 -0
- package/src/nodes/parallel.ts +48 -0
- package/src/nodes/registry.ts +105 -0
- package/src/nodes/run.ts +29 -0
- package/src/nodes/signal.ts +29 -0
- package/src/nodes/switch.ts +56 -0
- package/src/nodes/task.ts +29 -0
- package/src/nodes/timer.ts +42 -0
- package/src/test-support/diff.ts +69 -0
- package/src/test-support/index.ts +53 -0
- package/src/test-support/normalize.ts +274 -0
- package/src/test-support/parity.ts +85 -0
- package/src/test-support/xml.ts +140 -0
- package/src/types.ts +20 -30
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Built-in flow-node kind: `switch` — a multi-way exclusive choice (a BPMN
|
|
2
|
+
// exclusive gateway); each case routes when `subject` equals the case value, with
|
|
3
|
+
// an optional unconditional `default`. Contributed through the extension seam.
|
|
4
|
+
|
|
5
|
+
import type { FlowNode, SwitchCase } from "../types.js";
|
|
6
|
+
import type { Block, Edge } from "../declarative.js";
|
|
7
|
+
import { feelEquals } from "../declarative.js";
|
|
8
|
+
import { registerNodeKind } from "./registry.js";
|
|
9
|
+
|
|
10
|
+
declare module "../types.js" {
|
|
11
|
+
interface FlowNodeRegistry {
|
|
12
|
+
switch: { kind: "switch"; subject: string; cases: SwitchCase[]; default?: FlowNode[] };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
registerNodeKind("switch", {
|
|
17
|
+
build: (api) => (subject: string, cases: Record<string, Block> & { default?: Block }) => {
|
|
18
|
+
if (typeof subject !== "string" || subject.trim() === "") {
|
|
19
|
+
throw new Error(`switch() needs a non-empty subject expression`);
|
|
20
|
+
}
|
|
21
|
+
if (cases === null || typeof cases !== "object") {
|
|
22
|
+
throw new Error(`switch("${subject}") needs a cases object { value: (b) => {…} }`);
|
|
23
|
+
}
|
|
24
|
+
const caseNodes: SwitchCase[] = [];
|
|
25
|
+
for (const [value, fn] of Object.entries(cases)) {
|
|
26
|
+
if (value === "default") continue;
|
|
27
|
+
if (typeof fn !== "function") throw new Error(`switch("${subject}") cases must be blocks (b) => {…}`);
|
|
28
|
+
caseNodes.push({ value, body: api.child(fn, false) });
|
|
29
|
+
}
|
|
30
|
+
if (caseNodes.length === 0) throw new Error(`switch("${subject}") needs at least one case`);
|
|
31
|
+
if (cases.default !== undefined && typeof cases.default !== "function") {
|
|
32
|
+
throw new Error(`switch("${subject}") default must be a block (b) => {…}`);
|
|
33
|
+
}
|
|
34
|
+
const def = cases.default ? api.child(cases.default, false) : undefined;
|
|
35
|
+
api.out.push({ kind: "switch", subject, cases: caseNodes, default: def });
|
|
36
|
+
return api.self();
|
|
37
|
+
},
|
|
38
|
+
walk: (node, recurse) => {
|
|
39
|
+
for (const c of node.cases) recurse(c.body);
|
|
40
|
+
if (node.default) recurse(node.default);
|
|
41
|
+
},
|
|
42
|
+
emit: (node, incoming, loop, api) => {
|
|
43
|
+
const id = `Gw_${api.nextGw()}`;
|
|
44
|
+
const gw = api.addGateway(id, node.subject);
|
|
45
|
+
api.connect(incoming, id);
|
|
46
|
+
const out: Edge[] = [];
|
|
47
|
+
for (const c of node.cases) {
|
|
48
|
+
const e = api.newEdge(id, { condition: feelEquals(node.subject, c.value), name: c.value });
|
|
49
|
+
out.push(...api.emitList(c.body, [e], loop));
|
|
50
|
+
}
|
|
51
|
+
const de = api.newEdge(id, { name: "default" });
|
|
52
|
+
gw.defaultFlow = de.id;
|
|
53
|
+
out.push(...api.emitList(node.default ?? [], [de], loop));
|
|
54
|
+
return out;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Built-in flow-node kind: `task` — a durable service task served by a worker
|
|
2
|
+
// OUTSIDE this program (no locally-hosted handler). Its job type defaults to the
|
|
3
|
+
// derived `${flowId}:${name}` unless overridden with `{ jobType }`. Contributed
|
|
4
|
+
// from this module through the extension seam (epic #314, S0/#315).
|
|
5
|
+
|
|
6
|
+
import type { NodeEnvelopes } from "../types.js";
|
|
7
|
+
import { assertJobType } from "../xml.js";
|
|
8
|
+
import { registerNodeKind } from "./registry.js";
|
|
9
|
+
|
|
10
|
+
declare module "../types.js" {
|
|
11
|
+
interface FlowNodeRegistry {
|
|
12
|
+
task: { kind: "task"; name: string; envelopes?: NodeEnvelopes; jobType?: string };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
registerNodeKind("task", {
|
|
17
|
+
build: (api) => (name: string, opts?: { jobType?: string }) => {
|
|
18
|
+
api.claim(name);
|
|
19
|
+
const override = opts?.jobType;
|
|
20
|
+
if (override !== undefined) assertJobType("task jobType", override);
|
|
21
|
+
api.out.push({ kind: "task", name, envelopes: api.contractEnvelopes(name), jobType: override });
|
|
22
|
+
return api.self();
|
|
23
|
+
},
|
|
24
|
+
emit: (node, incoming, _loop, api) => {
|
|
25
|
+
api.addServiceTask(node);
|
|
26
|
+
api.connect(incoming, node.name);
|
|
27
|
+
return [api.newEdge(node.name)];
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Built-in flow-node kind: `timer` — a durable wait for a point in time (a BPMN
|
|
2
|
+
// timer intermediate catch event): exactly one of `{ after }` (a delay) or
|
|
3
|
+
// `{ at }` (an instant). Contributed through the extension seam (epic #314).
|
|
4
|
+
|
|
5
|
+
import type { TimerAt } from "../types.js";
|
|
6
|
+
import { assertTimerDate, assertTimerDuration } from "../xml.js";
|
|
7
|
+
import { registerNodeKind } from "./registry.js";
|
|
8
|
+
|
|
9
|
+
declare module "../types.js" {
|
|
10
|
+
interface FlowNodeRegistry {
|
|
11
|
+
timer: { kind: "timer"; name: string } & TimerAt;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
registerNodeKind("timer", {
|
|
16
|
+
build: (api) => (name: string, opts: { after: string } | { at: string }) => {
|
|
17
|
+
api.claim(name);
|
|
18
|
+
if (!opts || typeof opts !== "object") {
|
|
19
|
+
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
20
|
+
}
|
|
21
|
+
const hasAfter = "after" in opts && typeof opts.after === "string";
|
|
22
|
+
const hasAt = "at" in opts && typeof opts.at === "string";
|
|
23
|
+
if (hasAfter === hasAt) {
|
|
24
|
+
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
25
|
+
}
|
|
26
|
+
if ("after" in opts) {
|
|
27
|
+
const after = opts.after.trim();
|
|
28
|
+
assertTimerDuration(`timer("${name}") after`, after);
|
|
29
|
+
api.out.push({ kind: "timer", name, after });
|
|
30
|
+
} else {
|
|
31
|
+
const at = opts.at.trim();
|
|
32
|
+
assertTimerDate(`timer("${name}") at`, at);
|
|
33
|
+
api.out.push({ kind: "timer", name, at });
|
|
34
|
+
}
|
|
35
|
+
return api.self();
|
|
36
|
+
},
|
|
37
|
+
emit: (node, incoming, _loop, api) => {
|
|
38
|
+
api.addTimerCatchEvent(node);
|
|
39
|
+
api.connect(incoming, node.name);
|
|
40
|
+
return [api.newEdge(node.name)];
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// A legible red/green structural diff between two canonical models. Each of the
|
|
2
|
+
// three sections (flow nodes, sequence flows, message subscriptions) is compared
|
|
3
|
+
// as a MULTISET: a line present more times on one side than the other is
|
|
4
|
+
// reported, `-` (red) for "expected / golden only" and `+` (green) for "derived
|
|
5
|
+
// only". Equal models produce an empty diff.
|
|
6
|
+
|
|
7
|
+
import type { CanonicalModel } from "./normalize.js";
|
|
8
|
+
|
|
9
|
+
const useColor = (): boolean => {
|
|
10
|
+
if (process.env.NO_COLOR) return false;
|
|
11
|
+
if (process.env.FORCE_COLOR) return true;
|
|
12
|
+
return Boolean(process.stdout?.isTTY);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const RED = "\x1b[31m";
|
|
16
|
+
const GREEN = "\x1b[32m";
|
|
17
|
+
const DIM = "\x1b[2m";
|
|
18
|
+
const RESET = "\x1b[0m";
|
|
19
|
+
|
|
20
|
+
function paint(code: string, s: string): string {
|
|
21
|
+
return useColor() ? `${code}${s}${RESET}` : s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Multiset difference: for each distinct line, the count on `expected` minus the
|
|
25
|
+
* count on `actual`. Positive → missing from `actual` (removed); negative →
|
|
26
|
+
* extra in `actual` (added). */
|
|
27
|
+
function multisetDiff(expected: string[], actual: string[]): { removed: string[]; added: string[] } {
|
|
28
|
+
const counts = new Map<string, number>();
|
|
29
|
+
for (const line of expected) counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
30
|
+
for (const line of actual) counts.set(line, (counts.get(line) ?? 0) - 1);
|
|
31
|
+
const removed: string[] = [];
|
|
32
|
+
const added: string[] = [];
|
|
33
|
+
for (const [line, n] of counts) {
|
|
34
|
+
for (let k = 0; k < n; k++) removed.push(line);
|
|
35
|
+
for (let k = 0; k < -n; k++) added.push(line);
|
|
36
|
+
}
|
|
37
|
+
return { removed: removed.sort(), added: added.sort() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function section(title: string, expected: string[], actual: string[]): string[] {
|
|
41
|
+
const { removed, added } = multisetDiff(expected, actual);
|
|
42
|
+
if (removed.length === 0 && added.length === 0) return [];
|
|
43
|
+
const out = [paint(DIM, ` ${title}:`)];
|
|
44
|
+
for (const line of removed) out.push(paint(RED, ` - ${line}`));
|
|
45
|
+
for (const line of added) out.push(paint(GREEN, ` + ${line}`));
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when two canonical models are structurally equal (all three multisets
|
|
50
|
+
* match). */
|
|
51
|
+
export function modelsEqual(expected: CanonicalModel, actual: CanonicalModel): boolean {
|
|
52
|
+
return diffModels(expected, actual) === "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Render a legible red/green structural diff, or `""` when the two models are
|
|
56
|
+
* structurally equal. `-`/red is the `expected` (golden) side, `+`/green the
|
|
57
|
+
* `actual` (derived) side. */
|
|
58
|
+
export function diffModels(expected: CanonicalModel, actual: CanonicalModel): string {
|
|
59
|
+
const lines = [
|
|
60
|
+
...section("flow nodes", expected.nodes, actual.nodes),
|
|
61
|
+
...section("sequence flows", expected.flows, actual.flows),
|
|
62
|
+
...section("message subscriptions", expected.messages, actual.messages),
|
|
63
|
+
];
|
|
64
|
+
if (lines.length === 0) return "";
|
|
65
|
+
return [
|
|
66
|
+
paint(DIM, "structural mismatch (-expected/golden, +actual/derived):"),
|
|
67
|
+
...lines,
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @nanobpm/workflow/test-support — the DERIVATION-PARITY HARNESS (the oracle).
|
|
2
|
+
//
|
|
3
|
+
// This is the reusable test-support surface S1–S6 (epic #314) import to assert
|
|
4
|
+
// their derived flows against the hand-authored golden `.bpmn` models vendored in
|
|
5
|
+
// `packages/workflow/test/fixtures/nwf/`. It ships as the package subpath export
|
|
6
|
+
// `@nanobpm/workflow/test-support`:
|
|
7
|
+
//
|
|
8
|
+
// import {
|
|
9
|
+
// normalize,
|
|
10
|
+
// assertDerivationParity,
|
|
11
|
+
// deploySmoke,
|
|
12
|
+
// } from "@nanobpm/workflow/test-support";
|
|
13
|
+
//
|
|
14
|
+
// PUBLIC SURFACE
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// normalize(bpmnXml: string): CanonicalModel
|
|
18
|
+
// Strip DI (`bpmndi:BPMNDiagram` layout) and canonicalize element ids, child
|
|
19
|
+
// ordering, and sequence-flow ids so only SEMANTIC structure remains. Returns
|
|
20
|
+
// a `CanonicalModel` — three sorted multisets: `nodes` (flow nodes with their
|
|
21
|
+
// type + definition + edge-degree signature), `flows` (sequence, attach, and
|
|
22
|
+
// containment edges by endpoint), and `messages` (each subscribed message's
|
|
23
|
+
// full definition — name plus `zeebe:subscription correlationKey` and
|
|
24
|
+
// `zeebe:properties` envelope, so correlationKey/envelope differences count).
|
|
25
|
+
// Two models mean the same thing iff their `CanonicalModel`s are equal.
|
|
26
|
+
//
|
|
27
|
+
// assertDerivationParity(derived, goldenBpmnPath): void
|
|
28
|
+
// `derived` is a `DeclarativeFlow` (its BPMN is derived for you) or derived
|
|
29
|
+
// BPMN XML. Normalizes both it and the golden at `goldenBpmnPath` and asserts
|
|
30
|
+
// structural equality over flow nodes, sequence flows, message subscriptions,
|
|
31
|
+
// and timer/boundary definitions — throwing a legible red/green structural
|
|
32
|
+
// diff on mismatch.
|
|
33
|
+
//
|
|
34
|
+
// deploySmoke(derivedModel, opts?): Promise<DeploySmokeResult>
|
|
35
|
+
// Deploy the derived model to the wasm/live engine and assert acceptance.
|
|
36
|
+
// Supply `{ client }`, `{ baseUrl }`, or set `WORKFLOW_GATEWAY_URL`; with no
|
|
37
|
+
// engine reachable it resolves `{ skipped: true }` (never throws) so unit-only
|
|
38
|
+
// CI stays green, and throws only when a reachable engine REJECTS the model.
|
|
39
|
+
//
|
|
40
|
+
// LOWER-LEVEL HELPERS (for custom assertions):
|
|
41
|
+
// modelsEqual(a, b): boolean — structural equality predicate.
|
|
42
|
+
// diffModels(expected, actual): string — the red/green diff ("" when equal).
|
|
43
|
+
// parseXml / localName — the dependency-free XML reader.
|
|
44
|
+
|
|
45
|
+
export { normalize } from "./normalize.js";
|
|
46
|
+
export type { CanonicalModel } from "./normalize.js";
|
|
47
|
+
export { assertDerivationParity, deploySmoke } from "./parity.js";
|
|
48
|
+
export type { DerivedInput, DeploySmokeOptions, DeploySmokeResult } from "./parity.js";
|
|
49
|
+
export { diffModels, modelsEqual } from "./diff.js";
|
|
50
|
+
export { parseXml, localName } from "./xml.js";
|
|
51
|
+
export type { XmlElement } from "./xml.js";
|
|
52
|
+
// The internal builder invariant guard, exposed for the fail-fast guard test.
|
|
53
|
+
export { isFlowBuilder, assemblyFailureMessage } from "../declarative.js";
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Canonicalize a BPMN 2.0 document down to its SEMANTIC structure, so two models
|
|
2
|
+
// that mean the same thing compare equal regardless of element ids, child
|
|
3
|
+
// ordering, or diagram-interchange (DI) layout. This is the oracle S1–S6 assert
|
|
4
|
+
// their derived flows against.
|
|
5
|
+
//
|
|
6
|
+
// What "semantic structure" keeps (and what it deliberately drops):
|
|
7
|
+
// - DROPPED: the `bpmndi:BPMNDiagram` layout subtree (shapes, edges, bounds);
|
|
8
|
+
// every element `id` and every `sequenceFlow` id (canonicalized away — a
|
|
9
|
+
// flow node is identified by its TYPE + semantic definition, an edge by its
|
|
10
|
+
// endpoints); cosmetic `name` labels on flow nodes and gateways (the routing
|
|
11
|
+
// meaning lives in edge conditions and in derived task/message identities,
|
|
12
|
+
// not in a human label); child ORDER (everything is compared as a sorted
|
|
13
|
+
// multiset).
|
|
14
|
+
// - KEPT: flow nodes (tasks, gateways, events) with their semantic definition
|
|
15
|
+
// (task-definition job type, user-task / form / assignment / io-mapping /
|
|
16
|
+
// linked-resources extension elements, multi-instance characteristics, event
|
|
17
|
+
// definitions); sequence flows as endpoint pairs with their condition /
|
|
18
|
+
// default marker; message subscriptions (a catch event's `messageRef`
|
|
19
|
+
// resolved to the referenced message's full definition — name plus its
|
|
20
|
+
// `zeebe:subscription correlationKey` and `zeebe:properties` envelope);
|
|
21
|
+
// timer definitions; and boundary events (their `attachedToRef` modelled as
|
|
22
|
+
// a structural attach edge to the host, never as a raw-id attribute).
|
|
23
|
+
|
|
24
|
+
import { localName, parseXml, type XmlElement } from "./xml.js";
|
|
25
|
+
|
|
26
|
+
/** The id-agnostic, order-agnostic semantic structure of a BPMN model. Two
|
|
27
|
+
* models are structurally equal iff their three multisets are equal. */
|
|
28
|
+
export interface CanonicalModel {
|
|
29
|
+
/** Semantic descriptor of every flow node (task/gateway/event/subProcess),
|
|
30
|
+
* sorted. Each carries its type, definition, and edge-degree signature. */
|
|
31
|
+
nodes: string[];
|
|
32
|
+
/** Every sequence flow as `«fromNode» =[tag]=> «toNode»`, plus structural
|
|
33
|
+
* attach (boundary→host) and containment (subProcess→child) edges, sorted. */
|
|
34
|
+
flows: string[];
|
|
35
|
+
/** The full semantic identity of every message a catch event subscribes to,
|
|
36
|
+
* sorted (deduped): the serialized `<bpmn:message>` definition — its name plus
|
|
37
|
+
* the `zeebe:subscription correlationKey` and any `zeebe:properties` envelope —
|
|
38
|
+
* so a differing correlation key or envelope is a structural difference. */
|
|
39
|
+
messages: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const FLOW_NODES = new Set([
|
|
43
|
+
"startEvent",
|
|
44
|
+
"endEvent",
|
|
45
|
+
"intermediateCatchEvent",
|
|
46
|
+
"intermediateThrowEvent",
|
|
47
|
+
"boundaryEvent",
|
|
48
|
+
"task",
|
|
49
|
+
"serviceTask",
|
|
50
|
+
"userTask",
|
|
51
|
+
"scriptTask",
|
|
52
|
+
"sendTask",
|
|
53
|
+
"receiveTask",
|
|
54
|
+
"manualTask",
|
|
55
|
+
"businessRuleTask",
|
|
56
|
+
"callActivity",
|
|
57
|
+
"subProcess",
|
|
58
|
+
"adHocSubProcess",
|
|
59
|
+
"transaction",
|
|
60
|
+
"exclusiveGateway",
|
|
61
|
+
"parallelGateway",
|
|
62
|
+
"inclusiveGateway",
|
|
63
|
+
"eventBasedGateway",
|
|
64
|
+
"complexGateway",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const REF_ATTRS = new Set(["messageRef", "errorRef", "signalRef", "escalationRef"]);
|
|
68
|
+
|
|
69
|
+
function isDI(el: XmlElement): boolean {
|
|
70
|
+
const prefix = el.name.includes(":") ? el.name.slice(0, el.name.indexOf(":")) : "";
|
|
71
|
+
if (prefix === "bpmndi" || prefix === "di" || prefix === "dc" || prefix === "omgdi" || prefix === "omgdc") {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const l = localName(el.name);
|
|
75
|
+
return l === "BPMNDiagram" || l === "BPMNPlane" || l === "BPMNShape" || l === "BPMNEdge" || l === "BPMNLabel";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Node {
|
|
79
|
+
id: string;
|
|
80
|
+
type: string;
|
|
81
|
+
label: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Edge {
|
|
85
|
+
kind: "seq" | "attach" | "contain";
|
|
86
|
+
from: string;
|
|
87
|
+
to: string;
|
|
88
|
+
tag: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Accumulates the flat graph while walking every process / sub-process. */
|
|
92
|
+
class Collector {
|
|
93
|
+
readonly refNames = new Map<string, string>();
|
|
94
|
+
readonly messageDefs = new Map<string, XmlElement>();
|
|
95
|
+
readonly nodes = new Map<string, Node>();
|
|
96
|
+
readonly edges: Edge[] = [];
|
|
97
|
+
readonly messages = new Set<string>();
|
|
98
|
+
readonly defaultFlows = new Set<string>();
|
|
99
|
+
private readonly seqFlows: XmlElement[] = [];
|
|
100
|
+
|
|
101
|
+
/** Resolve a *Ref value to the referenced element's name (falling back to the
|
|
102
|
+
* raw ref only when the target is unknown), recording message subscriptions.
|
|
103
|
+
* A `messageRef` records the subscribed message's FULL semantic identity — the
|
|
104
|
+
* serialized `<bpmn:message>` definition (its name plus the `zeebe:subscription
|
|
105
|
+
* correlationKey` and any `zeebe:properties` envelope), not just its name — so
|
|
106
|
+
* two models whose catch events share a message name but differ in correlation
|
|
107
|
+
* key or envelope properties do NOT compare equal (the parity oracle would
|
|
108
|
+
* otherwise be blind to a wrong-correlationKey derivation). Falls back to the
|
|
109
|
+
* bare name only when the referenced message definition is unknown. */
|
|
110
|
+
private resolveRef(attr: string, value: string): string {
|
|
111
|
+
const name = this.refNames.get(value) ?? value;
|
|
112
|
+
if (attr === "messageRef") {
|
|
113
|
+
const def = this.messageDefs.get(value);
|
|
114
|
+
this.messages.add(def ? this.serialize(def) : name);
|
|
115
|
+
}
|
|
116
|
+
return `@${name}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Serialize a definition/extension descendant fully (all descendants), with
|
|
120
|
+
* sorted attributes (ids dropped, refs resolved) and sorted children, so the
|
|
121
|
+
* string is invariant to attribute/child ordering and to id renaming. */
|
|
122
|
+
private serialize(el: XmlElement): string {
|
|
123
|
+
const attrs = Object.entries(el.attrs)
|
|
124
|
+
.filter(([k]) => k !== "id")
|
|
125
|
+
.map(([k, v]) => `${k}=${REF_ATTRS.has(k) ? this.resolveRef(k, v) : v}`)
|
|
126
|
+
.sort();
|
|
127
|
+
const kids = el.children.filter((c) => !isDI(c)).map((c) => this.serialize(c)).sort();
|
|
128
|
+
const text = el.text ? `=${el.text}` : "";
|
|
129
|
+
return `${localName(el.name)}{${attrs.join(",")}}${text}[${kids.join(";")}]`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The id-agnostic semantic descriptor of a flow node: its type, its own
|
|
133
|
+
* discriminating attributes, and its definition subtree — but NOT nested flow
|
|
134
|
+
* nodes / sequence flows (those are separate graph entities) and NOT cosmetic
|
|
135
|
+
* id / name / structural-ref attributes. */
|
|
136
|
+
private nodeLabel(el: XmlElement): string {
|
|
137
|
+
const excluded = new Set(["id", "name", "default", "attachedToRef", "sourceRef", "targetRef"]);
|
|
138
|
+
const attrs = Object.entries(el.attrs)
|
|
139
|
+
.filter(([k]) => !excluded.has(k))
|
|
140
|
+
.map(([k, v]) => `${k}=${REF_ATTRS.has(k) ? this.resolveRef(k, v) : v}`)
|
|
141
|
+
.sort();
|
|
142
|
+
const defs = el.children
|
|
143
|
+
.filter((c) => {
|
|
144
|
+
const cl = localName(c.name);
|
|
145
|
+
// Skip nested flow nodes / sequence flows (separate graph entities) and
|
|
146
|
+
// the `incoming`/`outgoing` flow-id back-references (fully redundant with
|
|
147
|
+
// the sequence-flow edges, and carrying raw ids that must not leak here).
|
|
148
|
+
return !isDI(c) && !FLOW_NODES.has(cl) && cl !== "sequenceFlow" && cl !== "incoming" && cl !== "outgoing";
|
|
149
|
+
})
|
|
150
|
+
.map((c) => this.serialize(c))
|
|
151
|
+
.sort();
|
|
152
|
+
return `${localName(el.name)}(${attrs.join(",")})[${defs.join(";")}]`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private registerNode(el: XmlElement): void {
|
|
156
|
+
const id = el.attrs.id;
|
|
157
|
+
if (!id) return;
|
|
158
|
+
this.nodes.set(id, { id, type: localName(el.name), label: this.nodeLabel(el) });
|
|
159
|
+
if (el.attrs.default) this.defaultFlows.add(el.attrs.default);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Walk a container (process or sub-process), registering its flow nodes and
|
|
163
|
+
* sequence flows and recursing into nested sub-processes. `scope` is the
|
|
164
|
+
* enclosing sub-process id (containment edges point scope→child). */
|
|
165
|
+
collectContainer(container: XmlElement, scope: string | undefined): void {
|
|
166
|
+
for (const child of container.children) {
|
|
167
|
+
if (isDI(child)) continue;
|
|
168
|
+
const l = localName(child.name);
|
|
169
|
+
if (FLOW_NODES.has(l)) {
|
|
170
|
+
this.registerNode(child);
|
|
171
|
+
const id = child.attrs.id;
|
|
172
|
+
if (id && scope) this.edges.push({ kind: "contain", from: scope, to: id, tag: "" });
|
|
173
|
+
if (l === "boundaryEvent" && child.attrs.attachedToRef && id) {
|
|
174
|
+
this.edges.push({ kind: "attach", from: id, to: child.attrs.attachedToRef, tag: "" });
|
|
175
|
+
}
|
|
176
|
+
if (l === "subProcess" || l === "adHocSubProcess" || l === "transaction") {
|
|
177
|
+
this.collectContainer(child, id);
|
|
178
|
+
}
|
|
179
|
+
} else if (l === "sequenceFlow") {
|
|
180
|
+
this.seqFlows.push(child);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Turn collected sequence-flow elements into endpoint edges once every node
|
|
186
|
+
* and default marker is known. */
|
|
187
|
+
finalizeFlows(): void {
|
|
188
|
+
for (const sf of this.seqFlows) {
|
|
189
|
+
const from = sf.attrs.sourceRef;
|
|
190
|
+
const to = sf.attrs.targetRef;
|
|
191
|
+
if (!from || !to) continue;
|
|
192
|
+
const cond = sf.children.find((c) => localName(c.name) === "conditionExpression");
|
|
193
|
+
let tag = "";
|
|
194
|
+
if (cond?.text) tag = `if:${cond.text}`;
|
|
195
|
+
else if (sf.attrs.id && this.defaultFlows.has(sf.attrs.id)) tag = "default";
|
|
196
|
+
this.edges.push({ kind: "seq", from, to, tag });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Record every referenceable root definition (`message`/`error`/`signal`/
|
|
202
|
+
* `escalation`) so a `*Ref` can be resolved to a stable name. */
|
|
203
|
+
function collectRefs(el: XmlElement, c: Collector): void {
|
|
204
|
+
const l = localName(el.name);
|
|
205
|
+
if ((l === "message" || l === "error" || l === "signal" || l === "escalation") && el.attrs.id) {
|
|
206
|
+
c.refNames.set(el.attrs.id, el.attrs.name ?? el.attrs.errorCode ?? el.attrs.id);
|
|
207
|
+
if (l === "message") c.messageDefs.set(el.attrs.id, el);
|
|
208
|
+
}
|
|
209
|
+
for (const child of el.children) if (!isDI(child)) collectRefs(child, c);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function findContainers(el: XmlElement, out: XmlElement[]): void {
|
|
213
|
+
const l = localName(el.name);
|
|
214
|
+
if (l === "process") out.push(el);
|
|
215
|
+
for (const child of el.children) if (!isDI(child)) findContainers(child, out);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Normalize a BPMN XML document to its canonical semantic model. Strips DI and
|
|
219
|
+
* canonicalizes ids, child ordering, and sequence-flow ids so only semantic
|
|
220
|
+
* structure remains. */
|
|
221
|
+
export function normalize(bpmnXml: string): CanonicalModel {
|
|
222
|
+
const root = parseXml(bpmnXml);
|
|
223
|
+
const c = new Collector();
|
|
224
|
+
collectRefs(root, c);
|
|
225
|
+
|
|
226
|
+
const processes: XmlElement[] = [];
|
|
227
|
+
findContainers(root, processes);
|
|
228
|
+
if (processes.length === 0) throw new Error("normalize: no <process> element found");
|
|
229
|
+
for (const p of processes) c.collectContainer(p, undefined);
|
|
230
|
+
c.finalizeFlows();
|
|
231
|
+
|
|
232
|
+
// Per-node edge-degree signature — captures wiring (how many sequence flows in
|
|
233
|
+
// / out, whether it hosts or is a boundary, whether it contains or is
|
|
234
|
+
// contained) so a rewiring that preserves node labels still shows up.
|
|
235
|
+
const deg = new Map<string, { si: number; so: number; at: number; ah: number; ci: number; co: number }>();
|
|
236
|
+
const bump = (id: string) => {
|
|
237
|
+
let d = deg.get(id);
|
|
238
|
+
if (!d) {
|
|
239
|
+
d = { si: 0, so: 0, at: 0, ah: 0, ci: 0, co: 0 };
|
|
240
|
+
deg.set(id, d);
|
|
241
|
+
}
|
|
242
|
+
return d;
|
|
243
|
+
};
|
|
244
|
+
for (const e of c.edges) {
|
|
245
|
+
if (e.kind === "seq") {
|
|
246
|
+
bump(e.from).so += 1;
|
|
247
|
+
bump(e.to).si += 1;
|
|
248
|
+
} else if (e.kind === "attach") {
|
|
249
|
+
bump(e.from).at += 1; // this node is a boundary attached to something
|
|
250
|
+
bump(e.to).ah += 1; // this node hosts a boundary
|
|
251
|
+
} else {
|
|
252
|
+
bump(e.from).co += 1; // this sub-process contains children
|
|
253
|
+
bump(e.to).ci += 1; // this node is contained
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const labelWithDegree = (id: string): string => {
|
|
258
|
+
const node = c.nodes.get(id);
|
|
259
|
+
if (!node) return `«unknown:${id}»`;
|
|
260
|
+
const d = deg.get(id) ?? { si: 0, so: 0, at: 0, ah: 0, ci: 0, co: 0 };
|
|
261
|
+
return `${node.label}<in=${d.si},out=${d.so},boundary=${d.at},hosts=${d.ah},contained=${d.ci},contains=${d.co}>`;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const nodes = [...c.nodes.keys()].map(labelWithDegree).sort();
|
|
265
|
+
const flows = c.edges
|
|
266
|
+
.map((e) => {
|
|
267
|
+
const arrow = e.kind === "attach" ? "--attach-->" : e.kind === "contain" ? "--contains-->" : `=[${e.tag}]=>`;
|
|
268
|
+
return `${labelWithDegree(e.from)} ${arrow} ${labelWithDegree(e.to)}`;
|
|
269
|
+
})
|
|
270
|
+
.sort();
|
|
271
|
+
const messages = [...c.messages].sort();
|
|
272
|
+
|
|
273
|
+
return { nodes, flows, messages };
|
|
274
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The two assertions S1–S6 build their derivation-parity tests on:
|
|
2
|
+
//
|
|
3
|
+
// assertDerivationParity(derivedFlow, goldenBpmnPath)
|
|
4
|
+
// Derive the flow's BPMN, normalize it and the checked-in golden, and assert
|
|
5
|
+
// they are structurally equal — throwing a legible red/green diff otherwise.
|
|
6
|
+
//
|
|
7
|
+
// deploySmoke(derivedModel, opts?)
|
|
8
|
+
// Deploy the derived model to a live/wasm engine and assert acceptance. When
|
|
9
|
+
// no engine is reachable (no client, baseUrl, or WORKFLOW_GATEWAY_URL), it
|
|
10
|
+
// resolves `{ deployed: false, skipped: true }` so unit-only CI stays green —
|
|
11
|
+
// the caller decides whether to hard-require deployment.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { WorkflowClient, type WorkflowClientOptions } from "../client.js";
|
|
15
|
+
import { declarativeToBpmn } from "../declarative.js";
|
|
16
|
+
import type { DeclarativeFlow, DeployResult, Workflow } from "../types.js";
|
|
17
|
+
import { diffModels } from "./diff.js";
|
|
18
|
+
import { normalize } from "./normalize.js";
|
|
19
|
+
|
|
20
|
+
/** A derived flow, or the derived BPMN XML directly. */
|
|
21
|
+
export type DerivedInput = DeclarativeFlow | string;
|
|
22
|
+
|
|
23
|
+
function toBpmnXml(input: DerivedInput): string {
|
|
24
|
+
if (typeof input === "string") return input;
|
|
25
|
+
return declarativeToBpmn(input);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Derive `derived`'s BPMN, normalize it and the golden at `goldenBpmnPath`, and
|
|
29
|
+
* assert structural equality. Throws with a legible structural diff on
|
|
30
|
+
* mismatch. Returns silently on parity. */
|
|
31
|
+
export function assertDerivationParity(derived: DerivedInput, goldenBpmnPath: string): void {
|
|
32
|
+
const derivedXml = toBpmnXml(derived);
|
|
33
|
+
const goldenXml = readFileSync(goldenBpmnPath, "utf8");
|
|
34
|
+
const golden = normalize(goldenXml);
|
|
35
|
+
const actual = normalize(derivedXml);
|
|
36
|
+
const diff = diffModels(golden, actual);
|
|
37
|
+
if (diff) {
|
|
38
|
+
throw new Error(`derivation parity failed against ${goldenBpmnPath}:\n${diff}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Options for {@link deploySmoke}. Supply a pre-built `client`, a `baseUrl`
|
|
43
|
+
* (a client is built for you), or rely on the `WORKFLOW_GATEWAY_URL` env var. */
|
|
44
|
+
export interface DeploySmokeOptions {
|
|
45
|
+
client?: WorkflowClient;
|
|
46
|
+
baseUrl?: string;
|
|
47
|
+
token?: string;
|
|
48
|
+
transport?: "auto" | "falcon" | "rest";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The outcome of a {@link deploySmoke}: either a completed deployment, or a
|
|
52
|
+
* skip because no engine was reachable. */
|
|
53
|
+
export interface DeploySmokeResult {
|
|
54
|
+
deployed: boolean;
|
|
55
|
+
skipped: boolean;
|
|
56
|
+
reason?: string;
|
|
57
|
+
result?: DeployResult;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveClient(opts: DeploySmokeOptions): WorkflowClient | undefined {
|
|
61
|
+
if (opts.client) return opts.client;
|
|
62
|
+
const baseUrl = opts.baseUrl ?? process.env.WORKFLOW_GATEWAY_URL;
|
|
63
|
+
if (!baseUrl) return undefined;
|
|
64
|
+
const clientOpts: WorkflowClientOptions = {
|
|
65
|
+
baseUrl,
|
|
66
|
+
...(opts.token ? { token: opts.token } : {}),
|
|
67
|
+
...(opts.transport ? { transport: opts.transport } : {}),
|
|
68
|
+
};
|
|
69
|
+
return new WorkflowClient(clientOpts);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Deploy `model` to the engine and assert the deployment is accepted. Resolves
|
|
73
|
+
* a skip (never throws) when no engine is reachable, so it is safe to call in
|
|
74
|
+
* unit-only CI; it throws only when a reachable engine REJECTS the model. */
|
|
75
|
+
export async function deploySmoke(model: Workflow, opts: DeploySmokeOptions = {}): Promise<DeploySmokeResult> {
|
|
76
|
+
const client = resolveClient(opts);
|
|
77
|
+
if (!client) {
|
|
78
|
+
return { deployed: false, skipped: true, reason: "no engine reachable (set baseUrl/client/WORKFLOW_GATEWAY_URL)" };
|
|
79
|
+
}
|
|
80
|
+
const result = await client.deploy(model);
|
|
81
|
+
if (!result || typeof result !== "object" || Object.keys(result).length === 0) {
|
|
82
|
+
throw new Error(`deploySmoke: engine did not accept "${model.id}" (empty deploy result)`);
|
|
83
|
+
}
|
|
84
|
+
return { deployed: true, skipped: false, result };
|
|
85
|
+
}
|