@bpmnkit/engine 0.1.14 → 0.1.19
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 +2 -0
- package/dist/engine.d.ts +8 -0
- package/dist/engine.js +5 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/instance.d.ts +3 -1
- package/dist/instance.js +43 -7
- package/dist/scenario.d.ts +7 -0
- package/dist/scenario.js +12 -0
- package/dist/secrets.d.ts +18 -0
- package/dist/secrets.js +35 -0
- package/dist/wasm-runner.d.ts +52 -0
- package/dist/wasm-runner.js +461 -0
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/@bpmnkit/engine)
|
|
7
7
|
[](https://github.com/bpmnkit/monorepo/blob/main/LICENSE)
|
|
8
8
|
[](https://github.com/bpmnkit/monorepo)
|
|
9
|
+
[](https://github.com/bpmnkit/monorepo)
|
|
10
|
+
[](https://github.com/bpmnkit/monorepo)
|
|
9
11
|
|
|
10
12
|
[Website](https://bpmnkit.com) · [Documentation](https://docs.bpmnkit.com) · [GitHub](https://github.com/bpmnkit/monorepo) · [Changelog](https://github.com/bpmnkit/monorepo/blob/main/packages/engine/CHANGELOG.md)
|
|
11
13
|
</div>
|
package/dist/engine.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BpmnDefinitions, DmnDefinitions, FormDefinition } from "@bpmnkit/core";
|
|
2
2
|
import { ProcessInstance } from "./instance.js";
|
|
3
|
+
import type { SecretResolver } from "./secrets.js";
|
|
3
4
|
import type { JobHandler } from "./types.js";
|
|
4
5
|
/** Options for {@link Engine.start}. */
|
|
5
6
|
export interface StartOptions {
|
|
@@ -9,11 +10,18 @@ export interface StartOptions {
|
|
|
9
10
|
*/
|
|
10
11
|
beforeComplete?: (elementId: string) => Promise<void>;
|
|
11
12
|
}
|
|
13
|
+
/** Options for the {@link Engine} constructor. */
|
|
14
|
+
export interface EngineOptions {
|
|
15
|
+
/** Resolver for `{{secrets.NAME}}` placeholders in connector configurations. */
|
|
16
|
+
secretResolver?: SecretResolver;
|
|
17
|
+
}
|
|
12
18
|
export declare class Engine {
|
|
13
19
|
private readonly processes;
|
|
14
20
|
private readonly decisions;
|
|
15
21
|
private readonly forms;
|
|
16
22
|
private readonly workers;
|
|
23
|
+
private readonly secretResolver;
|
|
24
|
+
constructor(options?: EngineOptions);
|
|
17
25
|
/**
|
|
18
26
|
* Deploy BPMN processes, DMN decisions, and form definitions.
|
|
19
27
|
* Calling deploy multiple times merges into the registry.
|
package/dist/engine.js
CHANGED
|
@@ -4,6 +4,10 @@ export class Engine {
|
|
|
4
4
|
decisions = new Map();
|
|
5
5
|
forms = new Map();
|
|
6
6
|
workers = new Map();
|
|
7
|
+
secretResolver;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.secretResolver = options?.secretResolver;
|
|
10
|
+
}
|
|
7
11
|
/**
|
|
8
12
|
* Deploy BPMN processes, DMN decisions, and form definitions.
|
|
9
13
|
* Calling deploy multiple times merges into the registry.
|
|
@@ -40,7 +44,7 @@ export class Engine {
|
|
|
40
44
|
if (process === undefined) {
|
|
41
45
|
throw new Error(`Process "${processId}" is not deployed`);
|
|
42
46
|
}
|
|
43
|
-
const instance = new ProcessInstance(process, this.decisions, this.forms, this.workers, variables ?? {});
|
|
47
|
+
const instance = new ProcessInstance(process, this.decisions, this.forms, this.workers, variables ?? {}, this.secretResolver);
|
|
44
48
|
if (options?.beforeComplete !== undefined) {
|
|
45
49
|
instance.beforeComplete = options.beforeComplete;
|
|
46
50
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { Engine } from "./engine.js";
|
|
2
|
-
export type { StartOptions } from "./engine.js";
|
|
2
|
+
export type { StartOptions, EngineOptions } from "./engine.js";
|
|
3
|
+
export { EnvSecretResolver, resolveSecretString } from "./secrets.js";
|
|
4
|
+
export type { SecretResolver } from "./secrets.js";
|
|
3
5
|
export { ProcessInstance } from "./instance.js";
|
|
4
6
|
export type { ProcessEvent, Job, JobHandler } from "./types.js";
|
|
5
7
|
export { VariableStore } from "./variables.js";
|
package/dist/index.js
CHANGED
package/dist/instance.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BpmnProcess, DmnDecision, FormDefinition } from "@bpmnkit/core";
|
|
2
|
+
import type { SecretResolver } from "./secrets.js";
|
|
2
3
|
import type { JobHandler, ProcessEvent } from "./types.js";
|
|
3
4
|
type InstanceState = "active" | "completed" | "terminated" | "failed";
|
|
4
5
|
export declare class ProcessInstance {
|
|
@@ -25,13 +26,14 @@ export declare class ProcessInstance {
|
|
|
25
26
|
private readonly decisions;
|
|
26
27
|
private readonly forms;
|
|
27
28
|
private readonly jobWorkers;
|
|
29
|
+
private readonly secretResolver;
|
|
28
30
|
/**
|
|
29
31
|
* Optional hook called just before an element completes (token moves on).
|
|
30
32
|
* Returning a Promise lets the caller pause execution — useful for
|
|
31
33
|
* step-by-step simulation. Set via {@link Engine.start} options.
|
|
32
34
|
*/
|
|
33
35
|
beforeComplete?: (elementId: string) => Promise<void>;
|
|
34
|
-
constructor(process: BpmnProcess, decisions: Map<string, DmnDecision>, forms: Map<string, FormDefinition>, jobWorkers: Map<string, JobHandler>, initialVars: Record<string, unknown
|
|
36
|
+
constructor(process: BpmnProcess, decisions: Map<string, DmnDecision>, forms: Map<string, FormDefinition>, jobWorkers: Map<string, JobHandler>, initialVars: Record<string, unknown>, secretResolver?: SecretResolver);
|
|
35
37
|
get state(): InstanceState;
|
|
36
38
|
get error(): string | undefined;
|
|
37
39
|
get activeElements(): string[];
|
package/dist/instance.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { generateId } from "@bpmnkit/core";
|
|
2
2
|
import { evaluate, parseExpression } from "@bpmnkit/feel";
|
|
3
3
|
import { evaluateDecision } from "./dmn.js";
|
|
4
|
+
import { resolveSecretString } from "./secrets.js";
|
|
4
5
|
import { scheduleTimer } from "./timers.js";
|
|
5
6
|
import { VariableStore } from "./variables.js";
|
|
6
7
|
import { parseZeebeExt } from "./zeebe.js";
|
|
@@ -29,18 +30,20 @@ export class ProcessInstance {
|
|
|
29
30
|
decisions;
|
|
30
31
|
forms;
|
|
31
32
|
jobWorkers;
|
|
33
|
+
secretResolver;
|
|
32
34
|
/**
|
|
33
35
|
* Optional hook called just before an element completes (token moves on).
|
|
34
36
|
* Returning a Promise lets the caller pause execution — useful for
|
|
35
37
|
* step-by-step simulation. Set via {@link Engine.start} options.
|
|
36
38
|
*/
|
|
37
39
|
beforeComplete;
|
|
38
|
-
constructor(process, decisions, forms, jobWorkers, initialVars) {
|
|
40
|
+
constructor(process, decisions, forms, jobWorkers, initialVars, secretResolver) {
|
|
39
41
|
this.id = generateId("pi");
|
|
40
42
|
this.processId = process.id;
|
|
41
43
|
this.decisions = decisions;
|
|
42
44
|
this.forms = forms;
|
|
43
45
|
this.jobWorkers = jobWorkers;
|
|
46
|
+
this.secretResolver = secretResolver;
|
|
44
47
|
this.variables = new VariableStore();
|
|
45
48
|
this.rootScopeId = `scope_${this.id}`;
|
|
46
49
|
this.variables.createScope(this.rootScopeId);
|
|
@@ -186,10 +189,27 @@ export class ProcessInstance {
|
|
|
186
189
|
const ext = parseZeebeExt(el.extensionElements);
|
|
187
190
|
if (ext.ioMapping) {
|
|
188
191
|
for (const inp of ext.ioMapping.inputs) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
let val;
|
|
193
|
+
if (this.secretResolver !== undefined && inp.source.includes("{{secrets.")) {
|
|
194
|
+
const resolved = await resolveSecretString(inp.source, this.secretResolver);
|
|
195
|
+
// If source starts with "=", it's a FEEL expression — evaluate after secret substitution.
|
|
196
|
+
// Otherwise it's a literal string — use the resolved value directly.
|
|
197
|
+
if (inp.source.trimStart().startsWith("=")) {
|
|
198
|
+
val = this.evalFeel(resolved, scopeId, {
|
|
199
|
+
elementId: el.id,
|
|
200
|
+
property: `input:${inp.target}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
val = resolved;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
val = this.evalFeel(inp.source, scopeId, {
|
|
209
|
+
elementId: el.id,
|
|
210
|
+
property: `input:${inp.target}`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
193
213
|
this.variables.setLocal(scopeId, inp.target, val);
|
|
194
214
|
this.emit({ type: "variable:set", name: inp.target, value: val, scopeId });
|
|
195
215
|
}
|
|
@@ -314,7 +334,17 @@ export class ProcessInstance {
|
|
|
314
334
|
return;
|
|
315
335
|
}
|
|
316
336
|
const jobId = generateId("job");
|
|
317
|
-
const
|
|
337
|
+
const rawHeaders = ext.taskHeaders ?? {};
|
|
338
|
+
let headers;
|
|
339
|
+
if (this.secretResolver !== undefined) {
|
|
340
|
+
headers = {};
|
|
341
|
+
for (const [k, v] of Object.entries(rawHeaders)) {
|
|
342
|
+
headers[k] = await resolveSecretString(v, this.secretResolver);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
headers = rawHeaders;
|
|
347
|
+
}
|
|
318
348
|
const vars = this.variables.getAll(ctx.scopeId);
|
|
319
349
|
let jobError;
|
|
320
350
|
await new Promise((resolve) => {
|
|
@@ -376,8 +406,14 @@ export class ProcessInstance {
|
|
|
376
406
|
if (cd === undefined)
|
|
377
407
|
return;
|
|
378
408
|
const decision = this.decisions.get(cd.decisionId);
|
|
379
|
-
if (decision === undefined)
|
|
409
|
+
if (decision === undefined) {
|
|
410
|
+
this.emit({
|
|
411
|
+
type: "element:failed",
|
|
412
|
+
elementId: _el.id,
|
|
413
|
+
error: `DMN decision '${cd.decisionId}' not found. Deploy the DMN before running the process.`,
|
|
414
|
+
});
|
|
380
415
|
return;
|
|
416
|
+
}
|
|
381
417
|
const result = evaluateDecision(decision, this.variables.getAll(scopeId));
|
|
382
418
|
this.variables.set(scopeId, cd.resultVariable, result);
|
|
383
419
|
this.emit({ type: "variable:set", name: cd.resultVariable, value: result, scopeId });
|
package/dist/scenario.d.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface ScenarioResult {
|
|
|
36
36
|
visitedElements: string[];
|
|
37
37
|
/** Final variable state. */
|
|
38
38
|
finalVariables: Record<string, unknown>;
|
|
39
|
+
/** FEEL expressions evaluated during the run, in order. */
|
|
40
|
+
feelEvals: Array<{
|
|
41
|
+
elementId: string;
|
|
42
|
+
property: string;
|
|
43
|
+
expression: string;
|
|
44
|
+
result: unknown;
|
|
45
|
+
}>;
|
|
39
46
|
/** Errors collected during the run. */
|
|
40
47
|
errors: Array<{
|
|
41
48
|
elementId?: string;
|
package/dist/scenario.js
CHANGED
|
@@ -21,6 +21,7 @@ export function runScenario(engine, defs, scenario, timeoutMs = DEFAULT_TIMEOUT_
|
|
|
21
21
|
passed: false,
|
|
22
22
|
visitedElements: [],
|
|
23
23
|
finalVariables: {},
|
|
24
|
+
feelEvals: [],
|
|
24
25
|
errors: [{ message: "No process found in definitions." }],
|
|
25
26
|
failures: [{ field: "processId", expected: "a deployed process", actual: undefined }],
|
|
26
27
|
durationMs: Date.now() - startMs,
|
|
@@ -42,6 +43,7 @@ export function runScenario(engine, defs, scenario, timeoutMs = DEFAULT_TIMEOUT_
|
|
|
42
43
|
}
|
|
43
44
|
const visitedElements = [];
|
|
44
45
|
const variableState = new Map();
|
|
46
|
+
const feelEvals = [];
|
|
45
47
|
const errors = [];
|
|
46
48
|
let settled = false;
|
|
47
49
|
let timeoutHandle;
|
|
@@ -95,6 +97,7 @@ export function runScenario(engine, defs, scenario, timeoutMs = DEFAULT_TIMEOUT_
|
|
|
95
97
|
passed: failures.length === 0,
|
|
96
98
|
visitedElements,
|
|
97
99
|
finalVariables,
|
|
100
|
+
feelEvals,
|
|
98
101
|
errors,
|
|
99
102
|
failures,
|
|
100
103
|
durationMs: Date.now() - startMs,
|
|
@@ -121,6 +124,7 @@ export function runScenario(engine, defs, scenario, timeoutMs = DEFAULT_TIMEOUT_
|
|
|
121
124
|
passed: false,
|
|
122
125
|
visitedElements: [],
|
|
123
126
|
finalVariables: {},
|
|
127
|
+
feelEvals: [],
|
|
124
128
|
errors: [{ message: msg }],
|
|
125
129
|
failures: [{ field: "start", expected: "process to start", actual: msg }],
|
|
126
130
|
durationMs: Date.now() - startMs,
|
|
@@ -134,6 +138,14 @@ export function runScenario(engine, defs, scenario, timeoutMs = DEFAULT_TIMEOUT_
|
|
|
134
138
|
else if (evt.type === "variable:set") {
|
|
135
139
|
variableState.set(evt.name, evt.value);
|
|
136
140
|
}
|
|
141
|
+
else if (evt.type === "feel:evaluated") {
|
|
142
|
+
feelEvals.push({
|
|
143
|
+
elementId: evt.elementId,
|
|
144
|
+
property: evt.property,
|
|
145
|
+
expression: evt.expression,
|
|
146
|
+
result: evt.result,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
137
149
|
else if (evt.type === "element:failed") {
|
|
138
150
|
errors.push({ elementId: evt.elementId, message: evt.error });
|
|
139
151
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Interface for resolving secret values by name. */
|
|
2
|
+
export interface SecretResolver {
|
|
3
|
+
resolve(name: string): Promise<string | undefined>;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Replace all `{{secrets.NAME}}` placeholders in `value` using the given resolver.
|
|
7
|
+
* Returns the original string unchanged if no placeholders are present.
|
|
8
|
+
* Throws if any referenced secret is not configured.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveSecretString(value: string, resolver: SecretResolver): Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Secret resolver that reads from `process.env`.
|
|
13
|
+
* Works in Node.js; always returns `undefined` in browser contexts.
|
|
14
|
+
*/
|
|
15
|
+
export declare class EnvSecretResolver implements SecretResolver {
|
|
16
|
+
resolve(name: string): Promise<string | undefined>;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=secrets.d.ts.map
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const SECRET_RE = /\{\{secrets\.([^}]+)\}\}/g;
|
|
2
|
+
/**
|
|
3
|
+
* Replace all `{{secrets.NAME}}` placeholders in `value` using the given resolver.
|
|
4
|
+
* Returns the original string unchanged if no placeholders are present.
|
|
5
|
+
* Throws if any referenced secret is not configured.
|
|
6
|
+
*/
|
|
7
|
+
export async function resolveSecretString(value, resolver) {
|
|
8
|
+
if (!value.includes("{{secrets."))
|
|
9
|
+
return value;
|
|
10
|
+
// Collect all unique names first to allow parallel resolution
|
|
11
|
+
const names = new Set();
|
|
12
|
+
for (const m of value.matchAll(SECRET_RE)) {
|
|
13
|
+
if (m[1])
|
|
14
|
+
names.add(m[1]);
|
|
15
|
+
}
|
|
16
|
+
const resolved = new Map();
|
|
17
|
+
await Promise.all([...names].map(async (name) => {
|
|
18
|
+
const val = await resolver.resolve(name);
|
|
19
|
+
if (val === undefined) {
|
|
20
|
+
throw new Error(`Secret "{{secrets.${name}}}" is not configured`);
|
|
21
|
+
}
|
|
22
|
+
resolved.set(name, val);
|
|
23
|
+
}));
|
|
24
|
+
return value.replace(SECRET_RE, (_match, name) => resolved.get(name) ?? "");
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Secret resolver that reads from `process.env`.
|
|
28
|
+
* Works in Node.js; always returns `undefined` in browser contexts.
|
|
29
|
+
*/
|
|
30
|
+
export class EnvSecretResolver {
|
|
31
|
+
async resolve(name) {
|
|
32
|
+
return typeof process !== "undefined" ? process.env[name] : undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=secrets.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario runner backed by the WasmEngine (reebe-wasm).
|
|
3
|
+
*
|
|
4
|
+
* Works in both browser and Node.js:
|
|
5
|
+
* - Browser: WASM is loaded via the default export (fetch-based).
|
|
6
|
+
* - Node.js: WASM bytes are read from disk via initSync to avoid
|
|
7
|
+
* Node's lack of fetch() support for file:// URLs.
|
|
8
|
+
*
|
|
9
|
+
* Creates a fresh WasmEngine per scenario for clean isolation, deploys the
|
|
10
|
+
* current BPMN XML (and all referenced DMN/BPMN resources recursively), drives
|
|
11
|
+
* jobs to completion using mock outputs from the scenario definition, then
|
|
12
|
+
* asserts expectations.
|
|
13
|
+
*/
|
|
14
|
+
export interface ScenarioLike {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
processId?: string;
|
|
18
|
+
inputs?: Record<string, unknown>;
|
|
19
|
+
mocks?: Record<string, {
|
|
20
|
+
outputs?: Record<string, unknown>;
|
|
21
|
+
error?: string;
|
|
22
|
+
}>;
|
|
23
|
+
expect?: {
|
|
24
|
+
path?: string[];
|
|
25
|
+
variables?: Record<string, unknown>;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export interface ScenarioResultLike {
|
|
29
|
+
scenarioId: string;
|
|
30
|
+
scenarioName: string;
|
|
31
|
+
passed: boolean;
|
|
32
|
+
visitedElements: string[];
|
|
33
|
+
finalVariables: Record<string, unknown>;
|
|
34
|
+
feelEvals: Array<{
|
|
35
|
+
elementId: string;
|
|
36
|
+
property: string;
|
|
37
|
+
expression: string;
|
|
38
|
+
result: unknown;
|
|
39
|
+
}>;
|
|
40
|
+
errors: Array<{
|
|
41
|
+
elementId?: string;
|
|
42
|
+
message: string;
|
|
43
|
+
}>;
|
|
44
|
+
failures: Array<{
|
|
45
|
+
field: string;
|
|
46
|
+
expected: unknown;
|
|
47
|
+
actual: unknown;
|
|
48
|
+
}>;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
}
|
|
51
|
+
export declare function runScenarioWasm(xml: string, scenario: ScenarioLike, getDecisionDmn?: (decisionId: string) => string | null, getProcessBpmn?: (processId: string) => string | null): Promise<ScenarioResultLike>;
|
|
52
|
+
//# sourceMappingURL=wasm-runner.d.ts.map
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario runner backed by the WasmEngine (reebe-wasm).
|
|
3
|
+
*
|
|
4
|
+
* Works in both browser and Node.js:
|
|
5
|
+
* - Browser: WASM is loaded via the default export (fetch-based).
|
|
6
|
+
* - Node.js: WASM bytes are read from disk via initSync to avoid
|
|
7
|
+
* Node's lack of fetch() support for file:// URLs.
|
|
8
|
+
*
|
|
9
|
+
* Creates a fresh WasmEngine per scenario for clean isolation, deploys the
|
|
10
|
+
* current BPMN XML (and all referenced DMN/BPMN resources recursively), drives
|
|
11
|
+
* jobs to completion using mock outputs from the scenario definition, then
|
|
12
|
+
* asserts expectations.
|
|
13
|
+
*/
|
|
14
|
+
// ── WASM initialisation ───────────────────────────────────────────────────────
|
|
15
|
+
let wasmReady = false;
|
|
16
|
+
async function ensureWasmInit() {
|
|
17
|
+
if (wasmReady)
|
|
18
|
+
return;
|
|
19
|
+
const mod = await import("@bpmnkit/reebe-wasm");
|
|
20
|
+
// Node.js: fetch() does not support file:// URLs generated by wasm-bindgen,
|
|
21
|
+
// so we read the .wasm bytes from disk and use initSync.
|
|
22
|
+
if (typeof process !== "undefined" && typeof process.versions?.node === "string") {
|
|
23
|
+
const { readFileSync } = await import("node:fs");
|
|
24
|
+
const { createRequire } = await import("node:module");
|
|
25
|
+
const { dirname, resolve } = await import("node:path");
|
|
26
|
+
const req = createRequire(import.meta.url);
|
|
27
|
+
const jsPath = req.resolve("@bpmnkit/reebe-wasm");
|
|
28
|
+
const wasmPath = resolve(dirname(jsPath), "reebe_wasm_bg.wasm");
|
|
29
|
+
mod.initSync({ module: readFileSync(wasmPath) });
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
await mod.default();
|
|
33
|
+
}
|
|
34
|
+
wasmReady = true;
|
|
35
|
+
}
|
|
36
|
+
// ── Recursive dependency deployment ──────────────────────────────────────────
|
|
37
|
+
function deployDependencies(xml, engine, getDecisionDmn, getProcessBpmn, deployed) {
|
|
38
|
+
const missingDecisions = [];
|
|
39
|
+
// DMN decisions referenced via decisionId="..."
|
|
40
|
+
if (getDecisionDmn) {
|
|
41
|
+
for (const [, id] of xml.matchAll(/decisionId="([^"]+)"/g)) {
|
|
42
|
+
if (!id || deployed.has(`dmn:${id}`))
|
|
43
|
+
continue;
|
|
44
|
+
deployed.add(`dmn:${id}`);
|
|
45
|
+
const dmnXml = getDecisionDmn(id);
|
|
46
|
+
if (dmnXml) {
|
|
47
|
+
try {
|
|
48
|
+
engine.deploy(dmnXml);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
missingDecisions.push(id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
missingDecisions.push(id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Sub-processes / call activities referenced via calledElement="..."
|
|
60
|
+
if (getProcessBpmn) {
|
|
61
|
+
for (const [, id] of xml.matchAll(/calledElement="([^"]+)"/g)) {
|
|
62
|
+
if (!id || deployed.has(`bpmn:${id}`))
|
|
63
|
+
continue;
|
|
64
|
+
deployed.add(`bpmn:${id}`);
|
|
65
|
+
const bpmnXml = getProcessBpmn(id);
|
|
66
|
+
if (bpmnXml) {
|
|
67
|
+
const nested = deployDependencies(bpmnXml, engine, getDecisionDmn, getProcessBpmn, deployed);
|
|
68
|
+
missingDecisions.push(...nested);
|
|
69
|
+
engine.deploy(bpmnXml);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return missingDecisions;
|
|
74
|
+
}
|
|
75
|
+
// ── BPMN helpers ─────────────────────────────────────────────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* Extract condition expressions keyed by sequence flow ID from BPMN XML.
|
|
78
|
+
* Used to reconstruct FEEL evaluations from SEQUENCE_FLOW_TAKEN event log records.
|
|
79
|
+
*/
|
|
80
|
+
function extractFlowConditions(bpmnXml) {
|
|
81
|
+
const result = new Map();
|
|
82
|
+
// Strategy: find each conditionExpression element, then look backward to the
|
|
83
|
+
// nearest enclosing <sequenceFlow id="..."> opening tag. Using a greedy
|
|
84
|
+
// prefix ([\s\S]*) forces the match to the LAST occurrence before the
|
|
85
|
+
// conditionExpression, which is always the immediately enclosing flow.
|
|
86
|
+
// This avoids the self-closing `/>` confusion that plagues forward-matching regexes.
|
|
87
|
+
const condPattern = /<(?:[a-zA-Z]+:)?conditionExpression[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?conditionExpression>/g;
|
|
88
|
+
for (const condMatch of bpmnXml.matchAll(condPattern)) {
|
|
89
|
+
const rawExpr = condMatch[1]?.trim() ?? "";
|
|
90
|
+
const condition = rawExpr.startsWith("<![CDATA[") && rawExpr.endsWith("]]>") ? rawExpr.slice(9, -3) : rawExpr;
|
|
91
|
+
// Slice everything before this conditionExpression and find the last sequenceFlow id
|
|
92
|
+
const before = bpmnXml.slice(0, condMatch.index);
|
|
93
|
+
const flowMatch = before.match(/[\s\S]*<(?:[a-zA-Z]+:)?sequenceFlow\b[^>]*\bid="([^"]+)"/);
|
|
94
|
+
if (flowMatch?.[1]) {
|
|
95
|
+
result.set(flowMatch[1], condition);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Ensure BRT result variables are propagated to the parent process scope.
|
|
102
|
+
*
|
|
103
|
+
* When a BPMN `businessRuleTask` has both `zeebe:calledDecision resultVariable="X"`
|
|
104
|
+
* and a `zeebe:ioMapping` element, the WASM engine only propagates explicitly mapped
|
|
105
|
+
* output variables. Without an output entry for X, the result stays in the element
|
|
106
|
+
* scope (scope_key = element instance key) and is cleaned up when the BRT completes —
|
|
107
|
+
* never reaching the process instance scope where the runner's snapshot reads variables.
|
|
108
|
+
*
|
|
109
|
+
* This function rewrites the XML so that each such BRT has an explicit
|
|
110
|
+
* `<zeebe:output source="=X" target="X"/>` entry, mirroring real Zeebe behaviour
|
|
111
|
+
* where resultVariable is always stored at the parent scope.
|
|
112
|
+
*/
|
|
113
|
+
function ensureBrtResultVariableOutputs(xml) {
|
|
114
|
+
const brtPattern = /(<(?:[a-zA-Z]+:)?businessRuleTask\b[^>]*>)([\s\S]*?)(<\/(?:[a-zA-Z]+:)?businessRuleTask>)/g;
|
|
115
|
+
return xml.replace(brtPattern, (_match, open, body, close) => {
|
|
116
|
+
const rvMatch = /resultVariable="([^"]+)"/.exec(body);
|
|
117
|
+
if (!rvMatch?.[1])
|
|
118
|
+
return open + body + close; // no calledDecision resultVariable
|
|
119
|
+
const resultVar = rvMatch[1];
|
|
120
|
+
// Already has an output entry for this variable? Nothing to do
|
|
121
|
+
if (new RegExp(`<(?:[a-zA-Z]+:)?output\\b[^>]*target="${resultVar}"`).test(body))
|
|
122
|
+
return open + body + close;
|
|
123
|
+
const outputEntry = `<zeebe:output source="=${resultVar}" target="${resultVar}"/>`;
|
|
124
|
+
const hasIoMapping = /<(?:[a-zA-Z]+:)?ioMapping\b/.test(body);
|
|
125
|
+
let patched;
|
|
126
|
+
if (!hasIoMapping) {
|
|
127
|
+
// No ioMapping at all — inject one before the closing extensionElements tag
|
|
128
|
+
patched = body.replace(/(<\/(?:[a-zA-Z]+:)?extensionElements>)/, `<zeebe:ioMapping>${outputEntry}</zeebe:ioMapping>$1`);
|
|
129
|
+
}
|
|
130
|
+
else if (/<(?:[a-zA-Z]+:)?ioMapping\s*\/>/.test(body)) {
|
|
131
|
+
// Self-closing <zeebe:ioMapping/> — expand and inject
|
|
132
|
+
patched = body.replace(/<(?:[a-zA-Z]+:)?ioMapping\s*\/>/, `<zeebe:ioMapping>${outputEntry}</zeebe:ioMapping>`);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
// Open/close <zeebe:ioMapping>…</zeebe:ioMapping> — inject before closing tag
|
|
136
|
+
patched = body.replace(/(<\/(?:[a-zA-Z]+:)?ioMapping>)/, `${outputEntry}$1`);
|
|
137
|
+
}
|
|
138
|
+
return open + patched + close;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function extractBrtInfos(bpmnXml) {
|
|
142
|
+
const infos = [];
|
|
143
|
+
const pattern = /<(?:[a-zA-Z]+:)?businessRuleTask\b([^>]*)>([\s\S]*?)<\/(?:[a-zA-Z]+:)?businessRuleTask>/g;
|
|
144
|
+
for (const m of bpmnXml.matchAll(pattern)) {
|
|
145
|
+
const attrs = m[1] ?? "";
|
|
146
|
+
const body = m[2] ?? "";
|
|
147
|
+
const idMatch = /\bid="([^"]+)"/.exec(attrs);
|
|
148
|
+
const decisionIdMatch = /decisionId="([^"]+)"/.exec(body);
|
|
149
|
+
const resultVarMatch = /resultVariable="([^"]+)"/.exec(body);
|
|
150
|
+
if (idMatch?.[1] && decisionIdMatch?.[1] && resultVarMatch?.[1]) {
|
|
151
|
+
infos.push({
|
|
152
|
+
elementId: idMatch[1],
|
|
153
|
+
decisionId: decisionIdMatch[1],
|
|
154
|
+
resultVar: resultVarMatch[1],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return infos;
|
|
159
|
+
}
|
|
160
|
+
function parseDmnDecisionTable(dmnXml, decisionId) {
|
|
161
|
+
const decisionMatch = new RegExp(`<(?:[a-zA-Z]+:)?decision\\b[^>]*\\bid="${decisionId}"[^>]*>[\\s\\S]*?<\\/(?:[a-zA-Z]+:)?decision>`).exec(dmnXml);
|
|
162
|
+
if (!decisionMatch)
|
|
163
|
+
return null;
|
|
164
|
+
const decisionBody = decisionMatch[0];
|
|
165
|
+
const tableMatch = /<(?:[a-zA-Z]+:)?decisionTable\b([^>]*)>([\s\S]*?)<\/(?:[a-zA-Z]+:)?decisionTable>/.exec(decisionBody);
|
|
166
|
+
if (!tableMatch)
|
|
167
|
+
return null;
|
|
168
|
+
const hitPolicy = /hitPolicy="([^"]*)"/.exec(tableMatch[1] ?? "")?.[1] ?? "UNIQUE";
|
|
169
|
+
const tableBody = tableMatch[2] ?? "";
|
|
170
|
+
const extractText = (xml) => /<(?:[a-zA-Z]+:)?text\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?text>/.exec(xml)?.[1]?.trim() ?? "";
|
|
171
|
+
const inputs = [];
|
|
172
|
+
for (const m of tableBody.matchAll(/<(?:[a-zA-Z]+:)?input\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?input>/g)) {
|
|
173
|
+
const exprBody = /<(?:[a-zA-Z]+:)?inputExpression\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?inputExpression>/.exec(m[1] ?? "")?.[1] ?? "";
|
|
174
|
+
inputs.push(extractText(exprBody));
|
|
175
|
+
}
|
|
176
|
+
const outputs = [];
|
|
177
|
+
for (const m of tableBody.matchAll(/<(?:[a-zA-Z]+:)?output\b([^/>]*?)(?:\/>|>[\s\S]*?<\/(?:[a-zA-Z]+:)?output>)/g)) {
|
|
178
|
+
outputs.push(/\bname="([^"]*)"/.exec(m[1] ?? "")?.[1] ?? "");
|
|
179
|
+
}
|
|
180
|
+
const rules = [];
|
|
181
|
+
for (const ruleMatch of tableBody.matchAll(/<(?:[a-zA-Z]+:)?rule\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?rule>/g)) {
|
|
182
|
+
const ruleBody = ruleMatch[1] ?? "";
|
|
183
|
+
const inputEntries = [];
|
|
184
|
+
for (const ie of ruleBody.matchAll(/<(?:[a-zA-Z]+:)?inputEntry\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?inputEntry>/g))
|
|
185
|
+
inputEntries.push(extractText(ie[1] ?? ""));
|
|
186
|
+
const outputEntries = [];
|
|
187
|
+
for (const oe of ruleBody.matchAll(/<(?:[a-zA-Z]+:)?outputEntry\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?outputEntry>/g))
|
|
188
|
+
outputEntries.push(extractText(oe[1] ?? ""));
|
|
189
|
+
rules.push({ inputEntries, outputEntries });
|
|
190
|
+
}
|
|
191
|
+
return { hitPolicy, inputs, outputs, rules };
|
|
192
|
+
}
|
|
193
|
+
function evalDmnDecisionTable(table, vars) {
|
|
194
|
+
const feel = feelModule;
|
|
195
|
+
if (!feel)
|
|
196
|
+
return null;
|
|
197
|
+
const ctx = { vars: vars };
|
|
198
|
+
const collectResults = [];
|
|
199
|
+
for (const rule of table.rules) {
|
|
200
|
+
let matches = true;
|
|
201
|
+
for (let i = 0; i < table.inputs.length; i++) {
|
|
202
|
+
const inputExpr = table.inputs[i] ?? "";
|
|
203
|
+
const testExpr = rule.inputEntries[i] ?? "";
|
|
204
|
+
if (!testExpr)
|
|
205
|
+
continue; // empty = any
|
|
206
|
+
const inputParsed = feel.parseExpression(inputExpr);
|
|
207
|
+
if (!inputParsed.ast) {
|
|
208
|
+
matches = false;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
const inputVal = feel.evaluate(inputParsed.ast, ctx);
|
|
212
|
+
const testParsed = feel.parseUnaryTests(testExpr);
|
|
213
|
+
if (!testParsed.ast) {
|
|
214
|
+
matches = false;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (!feel.evaluateUnaryTests(testParsed.ast, inputVal, ctx)) {
|
|
218
|
+
matches = false;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!matches)
|
|
223
|
+
continue;
|
|
224
|
+
if (table.outputs.length === 1) {
|
|
225
|
+
const expr = rule.outputEntries[0] ?? "";
|
|
226
|
+
if (expr) {
|
|
227
|
+
const parsed = feel.parseExpression(expr);
|
|
228
|
+
if (parsed.ast)
|
|
229
|
+
collectResults.push(feel.evaluate(parsed.ast, ctx));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
const row = {};
|
|
234
|
+
for (let i = 0; i < table.outputs.length; i++) {
|
|
235
|
+
const name = table.outputs[i];
|
|
236
|
+
const expr = rule.outputEntries[i] ?? "";
|
|
237
|
+
if (name && expr) {
|
|
238
|
+
const parsed = feel.parseExpression(expr);
|
|
239
|
+
if (parsed.ast)
|
|
240
|
+
row[name] = feel.evaluate(parsed.ast, ctx);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
collectResults.push(row);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Apply hit policy
|
|
247
|
+
if (table.hitPolicy === "FIRST")
|
|
248
|
+
return collectResults[0] ?? null;
|
|
249
|
+
if (table.hitPolicy === "UNIQUE" || table.hitPolicy === "ANY")
|
|
250
|
+
return collectResults[0] ?? null;
|
|
251
|
+
return collectResults; // COLLECT, RULE ORDER, etc.
|
|
252
|
+
}
|
|
253
|
+
// Lazily cached feel module reference (avoids repeated dynamic imports)
|
|
254
|
+
let feelModule = null;
|
|
255
|
+
async function ensureFeelModule() {
|
|
256
|
+
if (feelModule)
|
|
257
|
+
return;
|
|
258
|
+
feelModule = await import("@bpmnkit/feel");
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* For visited BRTs that have a resultVariable, evaluate the DMN decision table
|
|
262
|
+
* directly using the FEEL engine and return {resultVar: value} pairs.
|
|
263
|
+
*
|
|
264
|
+
* The WASM browser engine cleans up BRT local-scope variables on element
|
|
265
|
+
* completion and does not propagate the resultVariable to the process scope
|
|
266
|
+
* snapshot. Evaluating the DMN with the FEEL engine directly sidesteps this
|
|
267
|
+
* limitation.
|
|
268
|
+
*/
|
|
269
|
+
async function evaluateBrtResultVariables(bpmnXml, visitedElementIds, vars, getDecisionDmn) {
|
|
270
|
+
if (!getDecisionDmn)
|
|
271
|
+
return {};
|
|
272
|
+
const allBrts = extractBrtInfos(bpmnXml);
|
|
273
|
+
const visitedBrts = allBrts.filter((b) => visitedElementIds.includes(b.elementId));
|
|
274
|
+
if (visitedBrts.length === 0)
|
|
275
|
+
return {};
|
|
276
|
+
await ensureFeelModule();
|
|
277
|
+
const result = {};
|
|
278
|
+
for (const { decisionId, resultVar } of visitedBrts) {
|
|
279
|
+
const dmnXml = getDecisionDmn(decisionId);
|
|
280
|
+
if (!dmnXml)
|
|
281
|
+
continue;
|
|
282
|
+
const table = parseDmnDecisionTable(dmnXml, decisionId);
|
|
283
|
+
if (!table)
|
|
284
|
+
continue;
|
|
285
|
+
result[resultVar] = evalDmnDecisionTable(table, vars);
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
// ── Runner ────────────────────────────────────────────────────────────────────
|
|
290
|
+
const MAX_ROUNDS = 200;
|
|
291
|
+
const TIMEOUT_MS = 10_000;
|
|
292
|
+
export async function runScenarioWasm(xml, scenario, getDecisionDmn, getProcessBpmn) {
|
|
293
|
+
const startMs = Date.now();
|
|
294
|
+
await ensureWasmInit();
|
|
295
|
+
const mod = await import("@bpmnkit/reebe-wasm");
|
|
296
|
+
const engine = new mod.WasmEngine();
|
|
297
|
+
try {
|
|
298
|
+
const deployed = new Set();
|
|
299
|
+
const missingDecisions = deployDependencies(xml, engine, getDecisionDmn, getProcessBpmn, deployed);
|
|
300
|
+
// Deploy main BPMN (with BRT result variable output mappings patched in)
|
|
301
|
+
const deployResult = engine.deploy(ensureBrtResultVariableOutputs(xml));
|
|
302
|
+
const processId = scenario.processId ?? deployResult.deployments[0]?.bpmnProcessId;
|
|
303
|
+
if (!processId) {
|
|
304
|
+
return fail(scenario, startMs, "No process found in deployed BPMN.");
|
|
305
|
+
}
|
|
306
|
+
// Start instance
|
|
307
|
+
engine.create_process_instance(processId, JSON.stringify(scenario.inputs ?? {}));
|
|
308
|
+
// Drive jobs to completion
|
|
309
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
310
|
+
if (Date.now() - startMs > TIMEOUT_MS)
|
|
311
|
+
break;
|
|
312
|
+
const snap = engine.snapshot();
|
|
313
|
+
const pi = snap.processInstances.find((p) => p.bpmn_process_id === processId);
|
|
314
|
+
if (!pi || pi.state !== "ACTIVE")
|
|
315
|
+
break;
|
|
316
|
+
const activatable = snap.jobs.filter((j) => j.state === "ACTIVATABLE" && j.process_instance_key === pi.key);
|
|
317
|
+
if (activatable.length === 0)
|
|
318
|
+
break;
|
|
319
|
+
for (const job of activatable) {
|
|
320
|
+
try {
|
|
321
|
+
engine.activate_job(job.key, "test-worker", 30_000);
|
|
322
|
+
const mock = scenario.mocks?.[job.job_type];
|
|
323
|
+
if (mock?.error !== undefined) {
|
|
324
|
+
engine.fail_job(job.key, 0, mock.error);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
engine.complete_job(job.key, JSON.stringify(mock?.outputs ?? {}));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// Job may have already been handled — skip
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// Collect final state
|
|
336
|
+
const snap = engine.snapshot();
|
|
337
|
+
const pi = snap.processInstances.find((p) => p.bpmn_process_id === processId);
|
|
338
|
+
if (!pi)
|
|
339
|
+
return fail(scenario, startMs, "Process instance not found after run.");
|
|
340
|
+
const timedOut = pi.state === "ACTIVE" && Date.now() - startMs >= TIMEOUT_MS;
|
|
341
|
+
const visitedElements = snap.elementInstances
|
|
342
|
+
.filter((e) => e.process_instance_key === pi.key)
|
|
343
|
+
.map((e) => e.element_id);
|
|
344
|
+
// Collect variables: merge snapshot state (deduplicated, final values) with
|
|
345
|
+
// VARIABLE.CREATED events from the event log (catches DMN-produced variables
|
|
346
|
+
// that may not survive snapshot filtering due to scope key differences).
|
|
347
|
+
const piKeyStr = String(pi.key);
|
|
348
|
+
const finalVariables = {};
|
|
349
|
+
for (const rec of snap.eventLog) {
|
|
350
|
+
if (rec.record_type === "EVENT" &&
|
|
351
|
+
rec.value_type === "VARIABLE" &&
|
|
352
|
+
rec.intent === "CREATED" &&
|
|
353
|
+
String(rec.payload.processInstanceKey) === piKeyStr) {
|
|
354
|
+
const name = rec.payload.name;
|
|
355
|
+
if (name !== undefined)
|
|
356
|
+
finalVariables[name] = rec.payload.value;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Snapshot variables override (they represent the final upserted state)
|
|
360
|
+
for (const v of snap.variables.filter((v) => v.process_instance_key === pi.key)) {
|
|
361
|
+
finalVariables[v.name] = v.value;
|
|
362
|
+
}
|
|
363
|
+
// BRT resultVariable post-hoc evaluation: the WASM browser engine cleans up
|
|
364
|
+
// BRT local-scope variables on element completion so they never reach the
|
|
365
|
+
// process-scope snapshot. Evaluate each visited BRT's DMN directly with
|
|
366
|
+
// the FEEL engine and inject the result (snapshot value wins if present).
|
|
367
|
+
const brtResultVars = await evaluateBrtResultVariables(xml, visitedElements, finalVariables, getDecisionDmn);
|
|
368
|
+
for (const [k, v] of Object.entries(brtResultVars)) {
|
|
369
|
+
if (!(k in finalVariables))
|
|
370
|
+
finalVariables[k] = v;
|
|
371
|
+
}
|
|
372
|
+
// Reconstruct FEEL evaluations from SEQUENCE_FLOW_TAKEN events.
|
|
373
|
+
// The WASM engine evaluates conditions but doesn't emit dedicated FEEL records;
|
|
374
|
+
// we pair each taken flow with its conditionExpression from the BPMN XML.
|
|
375
|
+
const flowConditions = extractFlowConditions(xml);
|
|
376
|
+
const feelEvals = [];
|
|
377
|
+
for (const rec of snap.eventLog) {
|
|
378
|
+
if (rec.record_type === "EVENT" &&
|
|
379
|
+
rec.value_type === "PROCESS_INSTANCE" &&
|
|
380
|
+
rec.intent === "SEQUENCE_FLOW_TAKEN" &&
|
|
381
|
+
String(rec.payload.processInstanceKey) === piKeyStr) {
|
|
382
|
+
const flowId = rec.payload.elementId;
|
|
383
|
+
const sourceId = rec.payload.sourceElementId;
|
|
384
|
+
if (!flowId)
|
|
385
|
+
continue;
|
|
386
|
+
const expression = flowConditions.get(flowId);
|
|
387
|
+
if (expression !== undefined) {
|
|
388
|
+
feelEvals.push({
|
|
389
|
+
elementId: sourceId ?? flowId,
|
|
390
|
+
property: "condition",
|
|
391
|
+
expression,
|
|
392
|
+
result: true,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const errors = snap.incidents
|
|
398
|
+
.filter((i) => i.process_instance_key === pi.key)
|
|
399
|
+
.map((i) => ({ elementId: i.element_id, message: i.error_message ?? i.error_type }));
|
|
400
|
+
for (const id of missingDecisions) {
|
|
401
|
+
errors.push({ message: `DMN decision '${id}' not found. Create the DMN in the Models view.` });
|
|
402
|
+
}
|
|
403
|
+
if (timedOut) {
|
|
404
|
+
errors.push({ message: `Scenario timed out after ${TIMEOUT_MS}ms` });
|
|
405
|
+
}
|
|
406
|
+
// Evaluate assertions
|
|
407
|
+
const failures = [];
|
|
408
|
+
if (scenario.expect?.path !== undefined) {
|
|
409
|
+
let cursor = 0;
|
|
410
|
+
for (const expectedId of scenario.expect.path) {
|
|
411
|
+
const idx = visitedElements.indexOf(expectedId, cursor);
|
|
412
|
+
if (idx === -1) {
|
|
413
|
+
failures.push({
|
|
414
|
+
field: "path",
|
|
415
|
+
expected: expectedId,
|
|
416
|
+
actual: `not found in [${visitedElements.join(", ")}]`,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
cursor = idx + 1;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (scenario.expect?.variables !== undefined) {
|
|
425
|
+
for (const [key, expectedValue] of Object.entries(scenario.expect.variables)) {
|
|
426
|
+
const actualValue = finalVariables[key];
|
|
427
|
+
if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) {
|
|
428
|
+
failures.push({ field: `variables.${key}`, expected: expectedValue, actual: actualValue });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
scenarioId: scenario.id,
|
|
434
|
+
scenarioName: scenario.name,
|
|
435
|
+
passed: errors.length === 0 && failures.length === 0,
|
|
436
|
+
visitedElements,
|
|
437
|
+
finalVariables,
|
|
438
|
+
feelEvals,
|
|
439
|
+
errors,
|
|
440
|
+
failures,
|
|
441
|
+
durationMs: Date.now() - startMs,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
engine.free();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function fail(scenario, startMs, message) {
|
|
449
|
+
return {
|
|
450
|
+
scenarioId: scenario.id,
|
|
451
|
+
scenarioName: scenario.name,
|
|
452
|
+
passed: false,
|
|
453
|
+
visitedElements: [],
|
|
454
|
+
finalVariables: {},
|
|
455
|
+
feelEvals: [],
|
|
456
|
+
errors: [{ message }],
|
|
457
|
+
failures: [{ field: "start", expected: "process to deploy and start", actual: message }],
|
|
458
|
+
durationMs: Date.now() - startMs,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
//# sourceMappingURL=wasm-runner.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bpmnkit/engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
".": {
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
10
|
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./wasm-runner": {
|
|
13
|
+
"types": "./dist/wasm-runner.d.ts",
|
|
14
|
+
"import": "./dist/wasm-runner.js"
|
|
11
15
|
}
|
|
12
16
|
},
|
|
13
17
|
"files": [
|
|
@@ -17,8 +21,9 @@
|
|
|
17
21
|
"dist/**/*.d.ts"
|
|
18
22
|
],
|
|
19
23
|
"dependencies": {
|
|
20
|
-
"@bpmnkit/
|
|
21
|
-
"@bpmnkit/
|
|
24
|
+
"@bpmnkit/feel": "0.0.16",
|
|
25
|
+
"@bpmnkit/reebe-wasm": "npm:reebe-wasm@0.1.0",
|
|
26
|
+
"@bpmnkit/core": "0.0.20"
|
|
22
27
|
},
|
|
23
28
|
"description": "Lightweight BPMN 2.0 process execution engine for browsers and Node.js — zero dependencies",
|
|
24
29
|
"keywords": [
|