@bpmnkit/plugins 0.0.16 → 0.0.17

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.
@@ -2,9 +2,16 @@ import { injectProcessRunnerStyles } from "./css.js";
2
2
  // ── IndexedDB persistence for input variables ───────────────────────────────
3
3
  function openRunnerDb() {
4
4
  return new Promise((resolve, reject) => {
5
- const req = indexedDB.open("bpmnkit-process-runner-v1", 1);
6
- req.onupgradeneeded = () => {
7
- req.result.createObjectStore("data");
5
+ const req = indexedDB.open("bpmnkit-process-runner-v1", 2);
6
+ req.onupgradeneeded = (e) => {
7
+ const db = req.result;
8
+ if (!db.objectStoreNames.contains("data")) {
9
+ db.createObjectStore("data");
10
+ }
11
+ // Version 2: add scenarios store keyed by projectId
12
+ if (e.oldVersion < 2 && !db.objectStoreNames.contains("scenarios")) {
13
+ db.createObjectStore("scenarios");
14
+ }
8
15
  };
9
16
  req.onsuccess = () => resolve(req.result);
10
17
  req.onerror = () => reject(req.error);
@@ -46,8 +53,62 @@ async function saveInputVars(projectId, vars) {
46
53
  // ignore — IndexedDB unavailable
47
54
  }
48
55
  }
56
+ function scenariosKey(projectId) {
57
+ return projectId !== null ? `scenarios:${projectId}` : "scenarios";
58
+ }
59
+ async function loadScenarios(projectId) {
60
+ try {
61
+ const db = await openRunnerDb();
62
+ return new Promise((resolve) => {
63
+ const req = db
64
+ .transaction("scenarios", "readonly")
65
+ .objectStore("scenarios")
66
+ .get(scenariosKey(projectId));
67
+ req.onsuccess = () => {
68
+ const raw = req.result;
69
+ resolve(Array.isArray(raw) ? raw : []);
70
+ };
71
+ req.onerror = () => resolve([]);
72
+ });
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ }
78
+ async function saveScenarios(projectId, scenarios) {
79
+ try {
80
+ const db = await openRunnerDb();
81
+ await new Promise((resolve) => {
82
+ const tx = db.transaction("scenarios", "readwrite");
83
+ tx.objectStore("scenarios").put(scenarios, scenariosKey(projectId));
84
+ tx.oncomplete = () => resolve();
85
+ tx.onerror = () => resolve();
86
+ });
87
+ }
88
+ catch {
89
+ // ignore
90
+ }
91
+ }
49
92
  // ── Internal state ──────────────────────────────────────────────────────────
50
93
  const AUTO_PLAY_DELAY_MS = 600;
94
+ /** Default probability (0–1) that a service task is chaos-injected. */
95
+ const CHAOS_FAILURE_PROBABILITY = 0.2;
96
+ // ── Chaos helpers ───────────────────────────────────────────────────────────
97
+ /** Element types that are eligible for chaos injection (worker tasks). */
98
+ const CHAOS_ELIGIBLE_TYPES = new Set(["serviceTask", "sendTask", "businessRuleTask", "scriptTask"]);
99
+ function buildChaosSchedule(elementIds, probability) {
100
+ const schedule = new Map();
101
+ const types = ["service-failure", "null-response", "random-delay"];
102
+ for (const id of elementIds) {
103
+ if (Math.random() < probability) {
104
+ const injType = types[Math.floor(Math.random() * types.length)];
105
+ if (injType !== undefined) {
106
+ schedule.set(id, { elementId: id, type: injType });
107
+ }
108
+ }
109
+ }
110
+ return schedule;
111
+ }
51
112
  // ── Plugin factory ──────────────────────────────────────────────────────────
52
113
  const PLAY_ICON = '<svg viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5l10 5.5-10 5.5V2.5z"/></svg>';
53
114
  export function createProcessRunnerPlugin(options) {
@@ -69,6 +130,20 @@ export function createProcessRunnerPlugin(options) {
69
130
  const variables = new Map();
70
131
  /** Errors emitted during the current run. */
71
132
  const errors = [];
133
+ // ── Time-travel debugger state ──────────────────────────────────────────
134
+ const MAX_EVENT_LOG = 10_000;
135
+ /** Full ordered event log for the current run. */
136
+ const eventLog = [];
137
+ /** null = live (tail of log); number = scrubbed to that index. */
138
+ let scrubIndex = null;
139
+ /** Whether chaos mode is enabled. */
140
+ let chaosEnabled = false;
141
+ /** Active chaos schedule for the current run. */
142
+ let chaosSchedule = new Map();
143
+ /** Injections that triggered during the last chaos run (cleared on new run). */
144
+ const lastChaosInjections = [];
145
+ /** True = last chaos run completed; false = failed/stuck; null = no chaos run yet. */
146
+ let lastChaosRunCompleted = null;
72
147
  /** Input variables configured by the user (persisted in IndexedDB). */
73
148
  const inputVars = [];
74
149
  const toolbarEl = document.createElement("div");
@@ -95,10 +170,12 @@ export function createProcessRunnerPlugin(options) {
95
170
  const feelTabBtn = makeTabBtn("FEEL", false);
96
171
  const errorsTabBtn = makeTabBtn("Errors", false);
97
172
  const inputTabBtn = makeTabBtn("Input", false);
173
+ const testsTabBtn = makeTabBtn("Tests", false);
98
174
  playTabBarEl.appendChild(varTabBtn);
99
175
  playTabBarEl.appendChild(feelTabBtn);
100
176
  playTabBarEl.appendChild(errorsTabBtn);
101
177
  playTabBarEl.appendChild(inputTabBtn);
178
+ playTabBarEl.appendChild(testsTabBtn);
102
179
  function makePaneEl(hidden) {
103
180
  const d = document.createElement("div");
104
181
  d.className = hidden
@@ -110,25 +187,138 @@ export function createProcessRunnerPlugin(options) {
110
187
  const feelPaneEl = makePaneEl(true);
111
188
  const errorsPaneEl = makePaneEl(true);
112
189
  const ivarsPaneEl = makePaneEl(true);
190
+ const testsPaneEl = makePaneEl(true);
191
+ // ── Timeline scrubber ───────────────────────────────────────────────────
192
+ const scrubberRowEl = document.createElement("div");
193
+ scrubberRowEl.className = "bpmnkit-runner-scrubber-row";
194
+ scrubberRowEl.style.display = "none";
195
+ const scrubberEl = document.createElement("input");
196
+ scrubberEl.type = "range";
197
+ scrubberEl.className = "bpmnkit-runner-scrubber";
198
+ scrubberEl.min = "0";
199
+ scrubberEl.max = "0";
200
+ scrubberEl.value = "0";
201
+ const scrubberLiveBtn = document.createElement("button");
202
+ scrubberLiveBtn.className = "bpmnkit-runner-scrubber-live";
203
+ scrubberLiveBtn.textContent = "Live";
204
+ const scrubberReplayBtn = document.createElement("button");
205
+ scrubberReplayBtn.className = "bpmnkit-runner-scrubber-replay";
206
+ scrubberReplayBtn.textContent = "Replay from here";
207
+ scrubberReplayBtn.style.display = "none";
208
+ const scrubberIndexEl = document.createElement("span");
209
+ scrubberIndexEl.className = "bpmnkit-runner-scrubber-index";
210
+ scrubberRowEl.appendChild(scrubberEl);
211
+ scrubberRowEl.appendChild(scrubberIndexEl);
212
+ scrubberRowEl.appendChild(scrubberLiveBtn);
213
+ scrubberRowEl.appendChild(scrubberReplayBtn);
214
+ playPanelEl.appendChild(scrubberRowEl);
113
215
  playPanelEl.appendChild(playTabBarEl);
114
216
  playPanelEl.appendChild(varsPaneEl);
115
217
  playPanelEl.appendChild(feelPaneEl);
116
218
  playPanelEl.appendChild(errorsPaneEl);
117
219
  playPanelEl.appendChild(ivarsPaneEl);
220
+ playPanelEl.appendChild(testsPaneEl);
118
221
  function switchPlayTab(tab) {
119
222
  varTabBtn.classList.toggle("bpmnkit-runner-play-tab--active", tab === "variables");
120
223
  feelTabBtn.classList.toggle("bpmnkit-runner-play-tab--active", tab === "feel");
121
224
  errorsTabBtn.classList.toggle("bpmnkit-runner-play-tab--active", tab === "errors");
122
225
  inputTabBtn.classList.toggle("bpmnkit-runner-play-tab--active", tab === "input");
226
+ testsTabBtn.classList.toggle("bpmnkit-runner-play-tab--active", tab === "tests");
123
227
  varsPaneEl.classList.toggle("bpmnkit-runner-play-pane--hidden", tab !== "variables");
124
228
  feelPaneEl.classList.toggle("bpmnkit-runner-play-pane--hidden", tab !== "feel");
125
229
  errorsPaneEl.classList.toggle("bpmnkit-runner-play-pane--hidden", tab !== "errors");
126
230
  ivarsPaneEl.classList.toggle("bpmnkit-runner-play-pane--hidden", tab !== "input");
231
+ testsPaneEl.classList.toggle("bpmnkit-runner-play-pane--hidden", tab !== "tests");
127
232
  }
128
233
  varTabBtn.addEventListener("click", () => switchPlayTab("variables"));
129
234
  feelTabBtn.addEventListener("click", () => switchPlayTab("feel"));
130
235
  errorsTabBtn.addEventListener("click", () => switchPlayTab("errors"));
131
236
  inputTabBtn.addEventListener("click", () => switchPlayTab("input"));
237
+ testsTabBtn.addEventListener("click", () => switchPlayTab("tests"));
238
+ function computeStateAt(idx) {
239
+ const vars = new Map();
240
+ const feels = [];
241
+ const errs = [];
242
+ const capped = Math.min(idx, eventLog.length - 1);
243
+ for (let i = 0; i <= capped; i++) {
244
+ const evt = eventLog[i];
245
+ if (evt === undefined)
246
+ continue;
247
+ if (evt.type === "variable:set" && typeof evt.name === "string") {
248
+ vars.set(evt.name, evt.value);
249
+ }
250
+ else if (evt.type === "feel:evaluated" &&
251
+ typeof evt.elementId === "string" &&
252
+ typeof evt.property === "string" &&
253
+ typeof evt.expression === "string") {
254
+ feels.push({
255
+ elementId: evt.elementId,
256
+ property: evt.property,
257
+ expression: evt.expression,
258
+ result: evt.result,
259
+ });
260
+ }
261
+ else if (evt.type === "element:failed") {
262
+ if (typeof evt.elementId === "string" && typeof evt.error === "string") {
263
+ errs.push({ elementId: evt.elementId, message: evt.error });
264
+ }
265
+ }
266
+ else if (evt.type === "process:failed" && typeof evt.error === "string") {
267
+ errs.push({ message: evt.error });
268
+ }
269
+ }
270
+ return { variables: vars, feelEvals: feels, errors: errs };
271
+ }
272
+ function updateScrubber() {
273
+ const len = eventLog.length;
274
+ if (len === 0) {
275
+ scrubberRowEl.style.display = "none";
276
+ return;
277
+ }
278
+ scrubberRowEl.style.display = "";
279
+ scrubberEl.max = String(len - 1);
280
+ const isLive = scrubIndex === null;
281
+ scrubberEl.value = isLive ? String(len - 1) : String(scrubIndex);
282
+ scrubberIndexEl.textContent = isLive
283
+ ? `${len} events (live)`
284
+ : `Event ${(scrubIndex ?? 0) + 1} / ${len}`;
285
+ scrubberLiveBtn.style.display = isLive ? "none" : "";
286
+ scrubberReplayBtn.style.display = isLive ? "none" : "";
287
+ }
288
+ function renderAtCurrentScrub() {
289
+ if (scrubIndex === null) {
290
+ renderVariables();
291
+ renderFeelEvals();
292
+ renderErrors();
293
+ }
294
+ else {
295
+ const state = computeStateAt(scrubIndex);
296
+ renderVariables(state.variables);
297
+ renderFeelEvals(state.feelEvals);
298
+ renderErrors(state.errors);
299
+ }
300
+ updateScrubber();
301
+ }
302
+ scrubberEl.addEventListener("input", () => {
303
+ const idx = Number(scrubberEl.value);
304
+ scrubIndex = idx >= eventLog.length - 1 ? null : idx;
305
+ renderAtCurrentScrub();
306
+ });
307
+ scrubberLiveBtn.addEventListener("click", () => {
308
+ scrubIndex = null;
309
+ renderAtCurrentScrub();
310
+ });
311
+ scrubberReplayBtn.addEventListener("click", () => {
312
+ if (scrubIndex === null)
313
+ return;
314
+ // Snapshot variables at the scrub point and re-run
315
+ const state = computeStateAt(scrubIndex);
316
+ const initVars = {};
317
+ for (const [k, v] of state.variables)
318
+ initVars[k] = v;
319
+ scrubIndex = null;
320
+ startInstance(initVars);
321
+ });
132
322
  // ── Render functions ────────────────────────────────────────────────────
133
323
  function emptyEl(text) {
134
324
  const d = document.createElement("div");
@@ -140,13 +330,14 @@ export function createProcessRunnerPlugin(options) {
140
330
  while (el.firstChild !== null)
141
331
  el.removeChild(el.firstChild);
142
332
  }
143
- function renderVariables() {
333
+ function renderVariables(snapshot) {
144
334
  clearEl(varsPaneEl);
145
- if (variables.size === 0) {
335
+ const src = snapshot ?? variables;
336
+ if (src.size === 0) {
146
337
  varsPaneEl.appendChild(emptyEl("No variables yet."));
147
338
  return;
148
339
  }
149
- for (const [name, value] of variables) {
340
+ for (const [name, value] of src) {
150
341
  const row = document.createElement("div");
151
342
  row.className = "bpmnkit-runner-play-var-row";
152
343
  const nameEl = document.createElement("span");
@@ -160,14 +351,15 @@ export function createProcessRunnerPlugin(options) {
160
351
  varsPaneEl.appendChild(row);
161
352
  }
162
353
  }
163
- function renderFeelEvals() {
354
+ function renderFeelEvals(snapshot) {
164
355
  clearEl(feelPaneEl);
165
- if (feelEvals.length === 0) {
356
+ const src = snapshot ?? feelEvals;
357
+ if (src.length === 0) {
166
358
  feelPaneEl.appendChild(emptyEl("No FEEL expressions evaluated yet."));
167
359
  return;
168
360
  }
169
361
  const groups = new Map();
170
- for (const ev of feelEvals) {
362
+ for (const ev of src) {
171
363
  let arr = groups.get(ev.elementId);
172
364
  if (arr === undefined) {
173
365
  arr = [];
@@ -209,13 +401,23 @@ export function createProcessRunnerPlugin(options) {
209
401
  feelPaneEl.appendChild(groupEl);
210
402
  }
211
403
  }
212
- function renderErrors() {
404
+ function renderErrors(snapshot) {
213
405
  clearEl(errorsPaneEl);
214
- if (errors.length === 0) {
406
+ // Chaos summary banner — only in live (non-scrubbed) mode after a chaos run
407
+ if (snapshot === undefined && chaosEnabled && lastChaosRunCompleted !== null) {
408
+ const banner = document.createElement("div");
409
+ banner.className = "bpmnkit-runner-chaos-summary";
410
+ const stuck = lastChaosRunCompleted ? 0 : 1;
411
+ const errCount = errors.filter((e) => e.elementId !== undefined).length;
412
+ banner.textContent = `Chaos run: ${stuck > 0 ? "1 stuck instance" : "completed"}, ${errCount} unhandled error${errCount !== 1 ? "s" : ""} found`;
413
+ errorsPaneEl.appendChild(banner);
414
+ }
415
+ const src = snapshot ?? errors;
416
+ if (src.length === 0) {
215
417
  errorsPaneEl.appendChild(emptyEl("No errors."));
216
418
  return;
217
419
  }
218
- for (const err of errors) {
420
+ for (const err of src) {
219
421
  const rowEl = document.createElement("div");
220
422
  rowEl.className = "bpmnkit-runner-play-error-row";
221
423
  if (err.elementId !== undefined) {
@@ -306,6 +508,169 @@ export function createProcessRunnerPlugin(options) {
306
508
  });
307
509
  ivarsPaneEl.appendChild(addBtn);
308
510
  }
511
+ // ── Tests tab ────────────────────────────────────────────────────────────
512
+ const scenarios = [];
513
+ const scenarioResults = new Map();
514
+ function renderTests() {
515
+ clearEl(testsPaneEl);
516
+ if (options.runScenario === undefined) {
517
+ testsPaneEl.appendChild(emptyEl("Pass runScenario in options to enable the Tests tab."));
518
+ return;
519
+ }
520
+ // Run all button
521
+ const headerEl = document.createElement("div");
522
+ headerEl.className = "bpmnkit-runner-tests-header";
523
+ const runAllBtn = document.createElement("button");
524
+ runAllBtn.className = "bpmnkit-runner-btn bpmnkit-runner-tests-run-all";
525
+ runAllBtn.textContent = `\u25B6 Run all (${scenarios.length})`;
526
+ runAllBtn.disabled = scenarios.length === 0;
527
+ runAllBtn.addEventListener("click", () => {
528
+ runAllBtn.disabled = true;
529
+ const runs = scenarios.map((s) => options.runScenario(s).then((r) => {
530
+ scenarioResults.set(s.id, r);
531
+ renderTests();
532
+ }));
533
+ void Promise.all(runs).then(() => renderTests());
534
+ });
535
+ headerEl.appendChild(runAllBtn);
536
+ const addBtn = document.createElement("button");
537
+ addBtn.className = "bpmnkit-runner-btn bpmnkit-runner-tests-add";
538
+ addBtn.textContent = "+ New scenario";
539
+ addBtn.addEventListener("click", () => {
540
+ const id = `scenario-${Date.now()}`;
541
+ scenarios.push({
542
+ id,
543
+ name: `Scenario ${scenarios.length + 1}`,
544
+ inputs: {},
545
+ mocks: {},
546
+ expect: {},
547
+ });
548
+ void saveScenarios(currentProjectId, scenarios);
549
+ renderTests();
550
+ });
551
+ headerEl.appendChild(addBtn);
552
+ // AI generate scenarios button
553
+ if (options.generateScenarios !== undefined) {
554
+ const genBtn = document.createElement("button");
555
+ genBtn.className = "bpmnkit-runner-btn bpmnkit-runner-tests-gen";
556
+ genBtn.textContent = "\u2728 Generate";
557
+ genBtn.title = "Generate test scenarios with AI";
558
+ genBtn.addEventListener("click", () => {
559
+ genBtn.disabled = true;
560
+ genBtn.textContent = "Generating\u2026";
561
+ void options.generateScenarios()
562
+ .then((newScenarios) => {
563
+ scenarios.push(...newScenarios);
564
+ void saveScenarios(currentProjectId, scenarios);
565
+ })
566
+ .catch(() => undefined)
567
+ .finally(() => renderTests());
568
+ });
569
+ headerEl.appendChild(genBtn);
570
+ }
571
+ // Import chaos findings as draft scenarios
572
+ if (lastChaosInjections.length > 0 && options.getJobType !== undefined) {
573
+ const importChaosBtn = document.createElement("button");
574
+ importChaosBtn.className = "bpmnkit-runner-btn bpmnkit-runner-tests-chaos-import";
575
+ importChaosBtn.textContent = `\u2193 Chaos (${lastChaosInjections.length})`;
576
+ importChaosBtn.title = "Import chaos findings as draft test scenarios";
577
+ importChaosBtn.addEventListener("click", () => {
578
+ const newScenarios = [];
579
+ for (let idx = 0; idx < lastChaosInjections.length; idx++) {
580
+ const inj = lastChaosInjections[idx];
581
+ if (inj === undefined)
582
+ continue;
583
+ const jobType = options.getJobType?.(inj.elementId) ?? null;
584
+ if (jobType === null)
585
+ continue;
586
+ newScenarios.push({
587
+ id: `chaos-${Date.now()}-${idx}`,
588
+ name: `Chaos: ${inj.elementId} (${inj.type})`,
589
+ mocks: { [jobType]: { error: `[Chaos] ${inj.type}` } },
590
+ expect: {},
591
+ });
592
+ }
593
+ if (newScenarios.length > 0) {
594
+ scenarios.push(...newScenarios);
595
+ void saveScenarios(currentProjectId, scenarios);
596
+ renderTests();
597
+ }
598
+ });
599
+ headerEl.appendChild(importChaosBtn);
600
+ }
601
+ testsPaneEl.appendChild(headerEl);
602
+ if (scenarios.length === 0) {
603
+ testsPaneEl.appendChild(emptyEl("No test scenarios yet. Click + New scenario."));
604
+ return;
605
+ }
606
+ for (let i = 0; i < scenarios.length; i++) {
607
+ const scenario = scenarios[i];
608
+ if (scenario === undefined)
609
+ continue;
610
+ const result = scenarioResults.get(scenario.id);
611
+ const rowEl = document.createElement("div");
612
+ rowEl.className = "bpmnkit-runner-tests-row";
613
+ if (result !== undefined) {
614
+ rowEl.classList.add(result.passed ? "bpmnkit-runner-tests-pass" : "bpmnkit-runner-tests-fail");
615
+ }
616
+ const statusEl = document.createElement("span");
617
+ statusEl.className = "bpmnkit-runner-tests-status";
618
+ statusEl.textContent = result === undefined ? "\u25CB" : result.passed ? "\u2713" : "\u2717";
619
+ const nameInput = document.createElement("input");
620
+ nameInput.className = "bpmnkit-runner-tests-name";
621
+ nameInput.value = scenario.name;
622
+ nameInput.addEventListener("input", () => {
623
+ if (scenarios[i] !== undefined) {
624
+ ;
625
+ scenarios[i].name = nameInput.value;
626
+ void saveScenarios(currentProjectId, scenarios);
627
+ }
628
+ });
629
+ const runOneBtn = document.createElement("button");
630
+ runOneBtn.className = "bpmnkit-runner-btn bpmnkit-runner-tests-run-one";
631
+ runOneBtn.textContent = "\u25B6";
632
+ runOneBtn.title = "Run this scenario";
633
+ runOneBtn.addEventListener("click", () => {
634
+ void options.runScenario(scenario).then((r) => {
635
+ scenarioResults.set(scenario.id, r);
636
+ renderTests();
637
+ });
638
+ });
639
+ const delBtn = document.createElement("button");
640
+ delBtn.className = "bpmnkit-runner-btn bpmnkit-runner-tests-del";
641
+ delBtn.textContent = "\u00D7";
642
+ delBtn.title = "Delete";
643
+ delBtn.addEventListener("click", () => {
644
+ scenarios.splice(i, 1);
645
+ scenarioResults.delete(scenario.id);
646
+ void saveScenarios(currentProjectId, scenarios);
647
+ renderTests();
648
+ });
649
+ rowEl.appendChild(statusEl);
650
+ rowEl.appendChild(nameInput);
651
+ rowEl.appendChild(runOneBtn);
652
+ rowEl.appendChild(delBtn);
653
+ testsPaneEl.appendChild(rowEl);
654
+ // Expandable diff on failure
655
+ if (result !== undefined && !result.passed) {
656
+ const diffEl = document.createElement("div");
657
+ diffEl.className = "bpmnkit-runner-tests-diff";
658
+ for (const f of result.failures) {
659
+ const failRow = document.createElement("div");
660
+ failRow.className = "bpmnkit-runner-tests-diff-row";
661
+ failRow.textContent = `${f.field}: expected ${JSON.stringify(f.expected)}, got ${JSON.stringify(f.actual)}`;
662
+ diffEl.appendChild(failRow);
663
+ }
664
+ for (const e of result.errors) {
665
+ const errRow = document.createElement("div");
666
+ errRow.className = "bpmnkit-runner-tests-diff-row bpmnkit-runner-tests-diff-error";
667
+ errRow.textContent = `Error${e.elementId !== undefined ? ` (${e.elementId})` : ""}: ${e.message}`;
668
+ diffEl.appendChild(errRow);
669
+ }
670
+ testsPaneEl.appendChild(diffEl);
671
+ }
672
+ }
673
+ }
309
674
  // ── Helpers ────────────────────────────────────────────────────────────
310
675
  function getPrimaryProcessId() {
311
676
  return currentProcessId;
@@ -314,9 +679,14 @@ export function createProcessRunnerPlugin(options) {
314
679
  feelEvals.length = 0;
315
680
  variables.clear();
316
681
  errors.length = 0;
682
+ eventLog.length = 0;
683
+ scrubIndex = null;
684
+ lastChaosInjections.length = 0;
685
+ lastChaosRunCompleted = null;
317
686
  renderVariables();
318
687
  renderFeelEvals();
319
688
  renderErrors();
689
+ updateScrubber();
320
690
  }
321
691
  /** Cancel running instance and reset run state. Stays in play mode. */
322
692
  function cleanup() {
@@ -345,6 +715,22 @@ export function createProcessRunnerPlugin(options) {
345
715
  options.onExitPlayMode?.();
346
716
  options.onHidePlayTab?.();
347
717
  }
718
+ function applyChaosThenResolve(chaos, resolve, reject) {
719
+ switch (chaos.type) {
720
+ case "service-failure":
721
+ reject(new Error(`[Chaos] Simulated service failure at "${chaos.elementId}"`));
722
+ break;
723
+ case "null-response":
724
+ // Completes with empty variables — downstream FEEL will get nulls
725
+ resolve();
726
+ break;
727
+ case "random-delay": {
728
+ const delay = 500 + Math.random() * 2000;
729
+ setTimeout(resolve, delay);
730
+ break;
731
+ }
732
+ }
733
+ }
348
734
  function startInstance(vars, stepMode = false) {
349
735
  if (currentInstance !== null)
350
736
  cleanup();
@@ -352,23 +738,72 @@ export function createProcessRunnerPlugin(options) {
352
738
  if (processId === undefined)
353
739
  return;
354
740
  mode = stepMode ? "running-step" : "running-auto";
741
+ // Build chaos schedule from flow elements of current process
742
+ if (chaosEnabled) {
743
+ // Gather element IDs from the current definitions
744
+ const defs = engine._defs;
745
+ const proc = defs?.processes?.find((p) => p.id === processId);
746
+ const eligibleIds = proc?.flowElements?.filter((e) => CHAOS_ELIGIBLE_TYPES.has(e.type)).map((e) => e.id) ?? [];
747
+ chaosSchedule = buildChaosSchedule(eligibleIds, CHAOS_FAILURE_PROBABILITY);
748
+ if (chaosSchedule.size > 0) {
749
+ errors.push({
750
+ message: `[Chaos] Scheduled injections for ${chaosSchedule.size} element(s): ${[...chaosSchedule.values()].map((i) => `${i.elementId}(${i.type})`).join(", ")}`,
751
+ });
752
+ renderErrors();
753
+ }
754
+ }
755
+ else {
756
+ chaosSchedule = new Map();
757
+ }
355
758
  updateToolbar();
356
759
  const beforeComplete = stepMode
357
- ? (_elementId) => new Promise((resolve) => {
358
- stepQueue.push(resolve);
359
- updateToolbar();
760
+ ? (elementId) => new Promise((resolve, reject) => {
761
+ const chaos = chaosEnabled ? chaosSchedule.get(elementId) : undefined;
762
+ if (chaos !== undefined) {
763
+ lastChaosInjections.push(chaos);
764
+ applyChaosThenResolve(chaos, resolve, reject);
765
+ }
766
+ else {
767
+ stepQueue.push(resolve);
768
+ updateToolbar();
769
+ }
360
770
  })
361
- : (_elementId) => new Promise((resolve) => {
362
- setTimeout(resolve, AUTO_PLAY_DELAY_MS);
771
+ : (elementId) => new Promise((resolve, reject) => {
772
+ const chaos = chaosEnabled ? chaosSchedule.get(elementId) : undefined;
773
+ if (chaos !== undefined) {
774
+ lastChaosInjections.push(chaos);
775
+ applyChaosThenResolve(chaos, resolve, reject);
776
+ }
777
+ else {
778
+ setTimeout(resolve, AUTO_PLAY_DELAY_MS);
779
+ }
363
780
  });
781
+ // Reset chaos tracking for new run
782
+ lastChaosInjections.length = 0;
783
+ lastChaosRunCompleted = null;
784
+ // Reset event log for new run
785
+ eventLog.length = 0;
786
+ scrubIndex = null;
787
+ updateScrubber();
364
788
  const instance = engine.start(processId, vars, { beforeComplete });
365
789
  currentInstance = instance;
366
790
  if (options.tokenHighlight !== undefined) {
367
791
  stopTrackHighlight = options.tokenHighlight.api.trackInstance(instance);
368
792
  }
369
793
  instance.onChange((evt) => {
794
+ // Record every event (capped at MAX_EVENT_LOG)
795
+ if (eventLog.length < MAX_EVENT_LOG) {
796
+ eventLog.push(evt);
797
+ }
798
+ // Update scrubber max — only if in live mode
799
+ if (scrubIndex === null)
800
+ updateScrubber();
370
801
  const type = evt.type;
371
802
  if (type === "process:completed" || type === "process:failed") {
803
+ if (chaosEnabled) {
804
+ lastChaosRunCompleted = type === "process:completed";
805
+ renderErrors();
806
+ }
372
807
  if (type === "process:failed") {
373
808
  const error = evt.error;
374
809
  if (typeof error === "string") {
@@ -440,6 +875,21 @@ export function createProcessRunnerPlugin(options) {
440
875
  return;
441
876
  const isRunning = mode !== "idle";
442
877
  const hasPendingStep = mode === "running-step" && stepQueue.length > 0;
878
+ // Chaos toggle (only show when idle)
879
+ if (!isRunning) {
880
+ const chaosLabel = document.createElement("label");
881
+ chaosLabel.className = "bpmnkit-runner-chaos-label";
882
+ const chaosCheckbox = document.createElement("input");
883
+ chaosCheckbox.type = "checkbox";
884
+ chaosCheckbox.className = "bpmnkit-runner-chaos-checkbox";
885
+ chaosCheckbox.checked = chaosEnabled;
886
+ chaosCheckbox.addEventListener("change", () => {
887
+ chaosEnabled = chaosCheckbox.checked;
888
+ });
889
+ chaosLabel.appendChild(chaosCheckbox);
890
+ chaosLabel.appendChild(document.createTextNode(" Chaos"));
891
+ toolbarEl.appendChild(chaosLabel);
892
+ }
443
893
  // Run button
444
894
  const runBtn = btn("\u25B6 Run");
445
895
  runBtn.disabled = isRunning;
@@ -494,10 +944,11 @@ export function createProcessRunnerPlugin(options) {
494
944
  renderFeelEvals();
495
945
  renderErrors();
496
946
  renderInputVars();
947
+ renderTests();
497
948
  if (options.playContainer !== undefined) {
498
949
  options.playContainer.appendChild(playPanelEl);
499
950
  }
500
- // Load persisted input variables for the initial project
951
+ // Load persisted input variables and scenarios for the initial project
501
952
  currentProjectId = options.getProjectId?.() ?? null;
502
953
  void loadInputVars(currentProjectId).then((loaded) => {
503
954
  inputVars.length = 0;
@@ -505,13 +956,31 @@ export function createProcessRunnerPlugin(options) {
505
956
  inputVars.push(v);
506
957
  renderInputVars();
507
958
  });
959
+ void loadScenarios(currentProjectId).then((loaded) => {
960
+ scenarios.length = 0;
961
+ for (const s of loaded)
962
+ scenarios.push(s);
963
+ renderTests();
964
+ });
965
+ // Auto-load sidecar on initial mount
966
+ if (options.loadSidecarScenarios !== undefined) {
967
+ void options.loadSidecarScenarios().then((sidecar) => {
968
+ if (sidecar !== null && sidecar.length > 0) {
969
+ scenarios.length = 0;
970
+ for (const s of sidecar)
971
+ scenarios.push(s);
972
+ void saveScenarios(currentProjectId, scenarios);
973
+ renderTests();
974
+ }
975
+ });
976
+ }
508
977
  const onAny = api.on;
509
978
  unsubs.push(api.on("diagram:load", (defs) => {
510
979
  currentProcessId = defs.processes[0]?.id;
511
980
  engine.deploy({ bpmn: defs });
512
981
  if (currentInstance !== null)
513
982
  cleanup();
514
- // Reload input vars if the project changed
983
+ // Reload input vars and scenarios if the project changed
515
984
  const pid = options.getProjectId?.() ?? null;
516
985
  if (pid !== currentProjectId) {
517
986
  currentProjectId = pid;
@@ -521,6 +990,26 @@ export function createProcessRunnerPlugin(options) {
521
990
  inputVars.push(v);
522
991
  renderInputVars();
523
992
  });
993
+ void loadScenarios(pid).then((loaded) => {
994
+ scenarios.length = 0;
995
+ for (const s of loaded)
996
+ scenarios.push(s);
997
+ scenarioResults.clear();
998
+ renderTests();
999
+ });
1000
+ // Auto-load sidecar test file alongside BPMN
1001
+ if (options.loadSidecarScenarios !== undefined) {
1002
+ void options.loadSidecarScenarios().then((sidecar) => {
1003
+ if (sidecar !== null && sidecar.length > 0) {
1004
+ scenarios.length = 0;
1005
+ for (const s of sidecar)
1006
+ scenarios.push(s);
1007
+ scenarioResults.clear();
1008
+ void saveScenarios(currentProjectId, scenarios);
1009
+ renderTests();
1010
+ }
1011
+ });
1012
+ }
524
1013
  }
525
1014
  updateToolbar();
526
1015
  }), api.on("diagram:clear", () => {