@object-ui/core 0.5.0 → 2.0.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.
Files changed (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +11 -0
  3. package/dist/actions/ActionRunner.d.ts +228 -4
  4. package/dist/actions/ActionRunner.js +397 -45
  5. package/dist/actions/TransactionManager.d.ts +193 -0
  6. package/dist/actions/TransactionManager.js +410 -0
  7. package/dist/actions/index.d.ts +1 -0
  8. package/dist/actions/index.js +1 -0
  9. package/dist/adapters/ApiDataSource.d.ts +69 -0
  10. package/dist/adapters/ApiDataSource.js +293 -0
  11. package/dist/adapters/ValueDataSource.d.ts +55 -0
  12. package/dist/adapters/ValueDataSource.js +287 -0
  13. package/dist/adapters/index.d.ts +3 -0
  14. package/dist/adapters/index.js +5 -2
  15. package/dist/adapters/resolveDataSource.d.ts +40 -0
  16. package/dist/adapters/resolveDataSource.js +59 -0
  17. package/dist/data-scope/DataScopeManager.d.ts +127 -0
  18. package/dist/data-scope/DataScopeManager.js +229 -0
  19. package/dist/data-scope/index.d.ts +10 -0
  20. package/dist/data-scope/index.js +10 -0
  21. package/dist/evaluator/ExpressionEvaluator.d.ts +11 -1
  22. package/dist/evaluator/ExpressionEvaluator.js +32 -8
  23. package/dist/evaluator/FormulaFunctions.d.ts +58 -0
  24. package/dist/evaluator/FormulaFunctions.js +350 -0
  25. package/dist/evaluator/index.d.ts +1 -0
  26. package/dist/evaluator/index.js +1 -0
  27. package/dist/index.d.ts +4 -0
  28. package/dist/index.js +4 -2
  29. package/dist/query/query-ast.d.ts +2 -2
  30. package/dist/query/query-ast.js +3 -3
  31. package/dist/registry/Registry.d.ts +10 -0
  32. package/dist/registry/Registry.js +2 -1
  33. package/dist/registry/WidgetRegistry.d.ts +120 -0
  34. package/dist/registry/WidgetRegistry.js +275 -0
  35. package/dist/theme/ThemeEngine.d.ts +82 -0
  36. package/dist/theme/ThemeEngine.js +400 -0
  37. package/dist/theme/index.d.ts +8 -0
  38. package/dist/theme/index.js +8 -0
  39. package/dist/validation/index.d.ts +1 -1
  40. package/dist/validation/index.js +1 -1
  41. package/dist/validation/validation-engine.d.ts +19 -1
  42. package/dist/validation/validation-engine.js +67 -2
  43. package/dist/validation/validators/index.d.ts +1 -1
  44. package/dist/validation/validators/index.js +1 -1
  45. package/dist/validation/validators/object-validation-engine.d.ts +2 -2
  46. package/dist/validation/validators/object-validation-engine.js +1 -1
  47. package/package.json +4 -3
  48. package/src/actions/ActionRunner.ts +577 -55
  49. package/src/actions/TransactionManager.ts +521 -0
  50. package/src/actions/__tests__/ActionRunner.params.test.ts +134 -0
  51. package/src/actions/__tests__/ActionRunner.test.ts +711 -0
  52. package/src/actions/__tests__/TransactionManager.test.ts +447 -0
  53. package/src/actions/index.ts +1 -0
  54. package/src/adapters/ApiDataSource.ts +349 -0
  55. package/src/adapters/ValueDataSource.ts +332 -0
  56. package/src/adapters/__tests__/ApiDataSource.test.ts +418 -0
  57. package/src/adapters/__tests__/ValueDataSource.test.ts +325 -0
  58. package/src/adapters/__tests__/resolveDataSource.test.ts +144 -0
  59. package/src/adapters/index.ts +6 -1
  60. package/src/adapters/resolveDataSource.ts +79 -0
  61. package/src/builder/__tests__/schema-builder.test.ts +235 -0
  62. package/src/data-scope/DataScopeManager.ts +269 -0
  63. package/src/data-scope/__tests__/DataScopeManager.test.ts +211 -0
  64. package/src/data-scope/index.ts +16 -0
  65. package/src/evaluator/ExpressionEvaluator.ts +34 -8
  66. package/src/evaluator/FormulaFunctions.ts +398 -0
  67. package/src/evaluator/__tests__/ExpressionContext.test.ts +110 -0
  68. package/src/evaluator/__tests__/FormulaFunctions.test.ts +447 -0
  69. package/src/evaluator/index.ts +1 -0
  70. package/src/index.ts +4 -3
  71. package/src/query/__tests__/window-functions.test.ts +1 -1
  72. package/src/query/query-ast.ts +3 -3
  73. package/src/registry/Registry.ts +12 -1
  74. package/src/registry/WidgetRegistry.ts +316 -0
  75. package/src/registry/__tests__/WidgetRegistry.test.ts +321 -0
  76. package/src/theme/ThemeEngine.ts +452 -0
  77. package/src/theme/__tests__/ThemeEngine.test.ts +606 -0
  78. package/src/theme/index.ts +22 -0
  79. package/src/validation/__tests__/object-validation-engine.test.ts +1 -1
  80. package/src/validation/__tests__/schema-validator.test.ts +118 -0
  81. package/src/validation/index.ts +1 -1
  82. package/src/validation/validation-engine.ts +61 -2
  83. package/src/validation/validators/index.ts +1 -1
  84. package/src/validation/validators/object-validation-engine.ts +2 -2
  85. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,711 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
10
+ import {
11
+ ActionRunner,
12
+ executeAction,
13
+ type ActionDef,
14
+ type ActionContext,
15
+ type ActionResult,
16
+ } from '../ActionRunner';
17
+
18
+ describe('ActionRunner', () => {
19
+ let runner: ActionRunner;
20
+ let context: ActionContext;
21
+
22
+ beforeEach(() => {
23
+ context = {
24
+ data: { id: 1, name: 'Test' },
25
+ record: { id: 1, status: 'active' },
26
+ user: { id: 'u1', role: 'admin' },
27
+ };
28
+ runner = new ActionRunner(context);
29
+ });
30
+
31
+ // ==========================================================================
32
+ // Basic execution
33
+ // ==========================================================================
34
+
35
+ describe('basic execution', () => {
36
+ it('should execute an onClick callback', async () => {
37
+ const onClick = vi.fn();
38
+ const result = await runner.execute({ onClick });
39
+ expect(result.success).toBe(true);
40
+ expect(onClick).toHaveBeenCalledOnce();
41
+ });
42
+
43
+ it('should handle async onClick', async () => {
44
+ const onClick = vi.fn().mockResolvedValue(undefined);
45
+ const result = await runner.execute({ onClick });
46
+ expect(result.success).toBe(true);
47
+ expect(onClick).toHaveBeenCalledOnce();
48
+ });
49
+
50
+ it('should catch errors and return failure', async () => {
51
+ const onClick = vi.fn().mockRejectedValue(new Error('boom'));
52
+ const result = await runner.execute({ onClick });
53
+ expect(result.success).toBe(false);
54
+ expect(result.error).toBe('boom');
55
+ });
56
+ });
57
+
58
+ // ==========================================================================
59
+ // Conditions and disabled
60
+ // ==========================================================================
61
+
62
+ describe('conditions', () => {
63
+ it('should skip action when condition evaluates to false', async () => {
64
+ const onClick = vi.fn();
65
+ const result = await runner.execute({
66
+ condition: '${record.status === "inactive"}',
67
+ onClick,
68
+ });
69
+ expect(result.success).toBe(false);
70
+ expect(result.error).toBe('Action condition not met');
71
+ expect(onClick).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('should execute action when condition evaluates to true', async () => {
75
+ const onClick = vi.fn();
76
+ const result = await runner.execute({
77
+ condition: '${record.status === "active"}',
78
+ onClick,
79
+ });
80
+ expect(result.success).toBe(true);
81
+ expect(onClick).toHaveBeenCalledOnce();
82
+ });
83
+
84
+ it('should skip action when disabled is true (boolean)', async () => {
85
+ const onClick = vi.fn();
86
+ const result = await runner.execute({ disabled: true, onClick });
87
+ expect(result.success).toBe(false);
88
+ expect(result.error).toBe('Action is disabled');
89
+ expect(onClick).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it('should skip action when disabled expression evaluates to true', async () => {
93
+ const onClick = vi.fn();
94
+ const result = await runner.execute({
95
+ disabled: '${user.role === "admin"}',
96
+ onClick,
97
+ });
98
+ expect(result.success).toBe(false);
99
+ expect(onClick).not.toHaveBeenCalled();
100
+ });
101
+ });
102
+
103
+ // ==========================================================================
104
+ // Confirmation
105
+ // ==========================================================================
106
+
107
+ describe('confirmation', () => {
108
+ it('should show confirmation and proceed when accepted', async () => {
109
+ const confirmHandler = vi.fn().mockResolvedValue(true);
110
+ runner.setConfirmHandler(confirmHandler);
111
+
112
+ const onClick = vi.fn();
113
+ const result = await runner.execute({
114
+ confirmText: 'Are you sure?',
115
+ onClick,
116
+ });
117
+
118
+ expect(confirmHandler).toHaveBeenCalledWith('Are you sure?', undefined);
119
+ expect(result.success).toBe(true);
120
+ expect(onClick).toHaveBeenCalledOnce();
121
+ });
122
+
123
+ it('should cancel when confirmation is rejected', async () => {
124
+ const confirmHandler = vi.fn().mockResolvedValue(false);
125
+ runner.setConfirmHandler(confirmHandler);
126
+
127
+ const onClick = vi.fn();
128
+ const result = await runner.execute({
129
+ confirmText: 'Are you sure?',
130
+ onClick,
131
+ });
132
+
133
+ expect(result.success).toBe(false);
134
+ expect(result.error).toBe('Action cancelled by user');
135
+ expect(onClick).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it('should support structured confirmation', async () => {
139
+ const confirmHandler = vi.fn().mockResolvedValue(true);
140
+ runner.setConfirmHandler(confirmHandler);
141
+
142
+ await runner.execute({
143
+ confirm: {
144
+ title: 'Delete',
145
+ message: 'Delete this item?',
146
+ confirmText: 'Yes, delete',
147
+ cancelText: 'Cancel',
148
+ },
149
+ onClick: vi.fn(),
150
+ });
151
+
152
+ expect(confirmHandler).toHaveBeenCalledWith('Delete this item?', {
153
+ title: 'Delete',
154
+ confirmText: 'Yes, delete',
155
+ cancelText: 'Cancel',
156
+ });
157
+ });
158
+ });
159
+
160
+ // ==========================================================================
161
+ // Custom handlers
162
+ // ==========================================================================
163
+
164
+ describe('custom handlers', () => {
165
+ it('should dispatch to registered custom handler', async () => {
166
+ const handler = vi.fn().mockResolvedValue({ success: true, data: 42 });
167
+ runner.registerHandler('my-action', handler);
168
+
169
+ const action: ActionDef = { type: 'my-action', params: { foo: 'bar' } };
170
+ const result = await runner.execute(action);
171
+
172
+ expect(handler).toHaveBeenCalledWith(action, context);
173
+ expect(result.success).toBe(true);
174
+ expect(result.data).toBe(42);
175
+ });
176
+
177
+ it('should allow unregistering a handler', async () => {
178
+ const handler = vi.fn().mockResolvedValue({ success: true });
179
+ runner.registerHandler('temp', handler);
180
+ runner.unregisterHandler('temp');
181
+
182
+ const result = await runner.execute({ type: 'temp', onClick: vi.fn() });
183
+ expect(handler).not.toHaveBeenCalled();
184
+ });
185
+ });
186
+
187
+ // ==========================================================================
188
+ // Script action type
189
+ // ==========================================================================
190
+
191
+ describe('script action type', () => {
192
+ it('should evaluate script expression', async () => {
193
+ const result = await runner.execute({
194
+ type: 'script',
195
+ execute: 'record.id + 100',
196
+ });
197
+ expect(result.success).toBe(true);
198
+ expect(result.data).toBe(101);
199
+ });
200
+
201
+ it('should evaluate script with string target fallback', async () => {
202
+ const result = await runner.execute({
203
+ type: 'script',
204
+ target: 'data.name',
205
+ });
206
+ expect(result.success).toBe(true);
207
+ expect(result.data).toBe('Test');
208
+ });
209
+
210
+ it('should fail when no script provided', async () => {
211
+ const result = await runner.execute({ type: 'script' });
212
+ expect(result.success).toBe(false);
213
+ expect(result.error).toContain('No script provided');
214
+ });
215
+
216
+ it('should return data as undefined for expressions referencing missing vars', async () => {
217
+ const result = await runner.execute({
218
+ type: 'script',
219
+ execute: 'data.nonExistent',
220
+ });
221
+ // ExpressionEvaluator returns undefined for missing properties (doesn't throw)
222
+ expect(result.success).toBe(true);
223
+ expect(result.data).toBeUndefined();
224
+ });
225
+ });
226
+
227
+ // ==========================================================================
228
+ // URL action type
229
+ // ==========================================================================
230
+
231
+ describe('url action type', () => {
232
+ it('should return redirect for relative URL', async () => {
233
+ const result = await runner.execute({
234
+ type: 'url',
235
+ target: '/dashboard',
236
+ });
237
+ expect(result.success).toBe(true);
238
+ expect(result.redirect).toBe('/dashboard');
239
+ });
240
+
241
+ it('should use navigation handler when provided', async () => {
242
+ const navHandler = vi.fn();
243
+ runner.setNavigationHandler(navHandler);
244
+
245
+ const result = await runner.execute({
246
+ type: 'url',
247
+ target: '/dashboard',
248
+ });
249
+
250
+ expect(result.success).toBe(true);
251
+ expect(navHandler).toHaveBeenCalledWith('/dashboard', {
252
+ external: false,
253
+ newTab: false,
254
+ });
255
+ });
256
+
257
+ it('should detect external URLs', async () => {
258
+ const navHandler = vi.fn();
259
+ runner.setNavigationHandler(navHandler);
260
+
261
+ await runner.execute({
262
+ type: 'url',
263
+ target: 'https://example.com',
264
+ });
265
+
266
+ expect(navHandler).toHaveBeenCalledWith('https://example.com', {
267
+ external: true,
268
+ newTab: true,
269
+ });
270
+ });
271
+
272
+ it('should reject javascript: URLs', async () => {
273
+ const result = await runner.execute({
274
+ type: 'url',
275
+ target: 'javascript:alert(1)',
276
+ });
277
+ expect(result.success).toBe(false);
278
+ expect(result.error).toContain('Invalid URL');
279
+ });
280
+
281
+ it('should fail when no URL provided', async () => {
282
+ const result = await runner.execute({ type: 'url' });
283
+ expect(result.success).toBe(false);
284
+ expect(result.error).toContain('No URL provided');
285
+ });
286
+ });
287
+
288
+ // ==========================================================================
289
+ // Modal action type
290
+ // ==========================================================================
291
+
292
+ describe('modal action type', () => {
293
+ it('should return modal schema when no handler registered', async () => {
294
+ const modalSchema = { type: 'dialog', title: 'Edit' };
295
+ const result = await runner.execute({
296
+ type: 'modal',
297
+ modal: modalSchema,
298
+ });
299
+ expect(result.success).toBe(true);
300
+ expect(result.modal).toEqual(modalSchema);
301
+ });
302
+
303
+ it('should delegate to modal handler when provided', async () => {
304
+ const modalHandler = vi.fn().mockResolvedValue({ success: true, data: { saved: true } });
305
+ runner.setModalHandler(modalHandler);
306
+
307
+ const modalSchema = { type: 'form', fields: [] };
308
+ const result = await runner.execute({
309
+ type: 'modal',
310
+ modal: modalSchema,
311
+ });
312
+
313
+ expect(modalHandler).toHaveBeenCalledWith(modalSchema, context);
314
+ expect(result.success).toBe(true);
315
+ expect(result.data).toEqual({ saved: true });
316
+ });
317
+
318
+ it('should use target as modal schema fallback', async () => {
319
+ const result = await runner.execute({
320
+ type: 'modal',
321
+ target: 'edit_form',
322
+ });
323
+ expect(result.success).toBe(true);
324
+ expect(result.modal).toBe('edit_form');
325
+ });
326
+
327
+ it('should fail when no modal schema/target provided', async () => {
328
+ const result = await runner.execute({ type: 'modal' });
329
+ expect(result.success).toBe(false);
330
+ expect(result.error).toContain('No modal schema');
331
+ });
332
+ });
333
+
334
+ // ==========================================================================
335
+ // Flow action type
336
+ // ==========================================================================
337
+
338
+ describe('flow action type', () => {
339
+ it('should delegate to registered flow handler', async () => {
340
+ const flowHandler = vi.fn().mockResolvedValue({ success: true, data: 'flow_started' });
341
+ runner.registerHandler('flow', flowHandler);
342
+
343
+ const action: ActionDef = { type: 'flow', target: 'approval_flow' };
344
+ const result = await runner.execute(action);
345
+
346
+ expect(flowHandler).toHaveBeenCalledWith(action, context);
347
+ expect(result.success).toBe(true);
348
+ });
349
+
350
+ it('should fail when no flow handler registered', async () => {
351
+ const result = await runner.execute({ type: 'flow', target: 'my_flow' });
352
+ expect(result.success).toBe(false);
353
+ expect(result.error).toContain('Flow handler not registered');
354
+ });
355
+
356
+ it('should fail when no flow target provided', async () => {
357
+ const result = await runner.execute({ type: 'flow' });
358
+ expect(result.success).toBe(false);
359
+ expect(result.error).toContain('No flow target');
360
+ });
361
+ });
362
+
363
+ // ==========================================================================
364
+ // API action type
365
+ // ==========================================================================
366
+
367
+ describe('api action type', () => {
368
+ it('should call fetch with simple string endpoint', async () => {
369
+ const mockResponse = { ok: true, json: vi.fn().mockResolvedValue({ id: 1 }) };
370
+ global.fetch = vi.fn().mockResolvedValue(mockResponse);
371
+
372
+ const result = await runner.execute({
373
+ type: 'api',
374
+ api: '/api/records',
375
+ method: 'GET',
376
+ });
377
+
378
+ expect(global.fetch).toHaveBeenCalledWith('/api/records', expect.objectContaining({
379
+ method: 'GET',
380
+ }));
381
+ expect(result.success).toBe(true);
382
+ expect(result.data).toEqual({ id: 1 });
383
+ });
384
+
385
+ it('should use endpoint field as alias', async () => {
386
+ const mockResponse = { ok: true, json: vi.fn().mockResolvedValue({ ok: true }) };
387
+ global.fetch = vi.fn().mockResolvedValue(mockResponse);
388
+
389
+ const result = await runner.execute({
390
+ type: 'api',
391
+ endpoint: '/api/v2/records',
392
+ });
393
+
394
+ expect(global.fetch).toHaveBeenCalledWith('/api/v2/records', expect.any(Object));
395
+ expect(result.success).toBe(true);
396
+ });
397
+
398
+ it('should support complex API config', async () => {
399
+ const mockResponse = { ok: true, json: vi.fn().mockResolvedValue({ done: true }) };
400
+ global.fetch = vi.fn().mockResolvedValue(mockResponse);
401
+
402
+ const result = await runner.execute({
403
+ type: 'api',
404
+ api: {
405
+ url: '/api/records',
406
+ method: 'PUT',
407
+ headers: { Authorization: 'Bearer xyz' },
408
+ body: { name: 'Updated' },
409
+ queryParams: { include: 'details' },
410
+ },
411
+ });
412
+
413
+ expect(global.fetch).toHaveBeenCalledWith(
414
+ '/api/records?include=details',
415
+ expect.objectContaining({
416
+ method: 'PUT',
417
+ headers: expect.objectContaining({ Authorization: 'Bearer xyz' }),
418
+ body: JSON.stringify({ name: 'Updated' }),
419
+ }),
420
+ );
421
+ expect(result.success).toBe(true);
422
+ });
423
+
424
+ it('should handle HTTP errors', async () => {
425
+ global.fetch = vi.fn().mockResolvedValue({
426
+ ok: false,
427
+ status: 404,
428
+ statusText: 'Not Found',
429
+ });
430
+
431
+ const result = await runner.execute({
432
+ type: 'api',
433
+ api: '/api/missing',
434
+ });
435
+
436
+ expect(result.success).toBe(false);
437
+ expect(result.error).toContain('404');
438
+ });
439
+
440
+ it('should handle network errors', async () => {
441
+ global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
442
+
443
+ const result = await runner.execute({
444
+ type: 'api',
445
+ api: '/api/records',
446
+ });
447
+
448
+ expect(result.success).toBe(false);
449
+ expect(result.error).toBe('Network error');
450
+ });
451
+
452
+ it('should fail when no endpoint provided', async () => {
453
+ const result = await runner.execute({ type: 'api' });
454
+ expect(result.success).toBe(false);
455
+ expect(result.error).toContain('No API endpoint');
456
+ });
457
+ });
458
+
459
+ // ==========================================================================
460
+ // Navigation action type
461
+ // ==========================================================================
462
+
463
+ describe('navigation action type', () => {
464
+ it('should return redirect for internal navigation', async () => {
465
+ const result = await runner.execute({
466
+ type: 'navigation',
467
+ navigate: { to: '/records/1' },
468
+ });
469
+ expect(result.success).toBe(true);
470
+ expect(result.redirect).toBe('/records/1');
471
+ });
472
+
473
+ it('should use navigation handler when provided', async () => {
474
+ const navHandler = vi.fn();
475
+ runner.setNavigationHandler(navHandler);
476
+
477
+ await runner.execute({
478
+ type: 'navigation',
479
+ navigate: { to: '/records/1', replace: true },
480
+ });
481
+
482
+ expect(navHandler).toHaveBeenCalledWith('/records/1', expect.objectContaining({
483
+ replace: true,
484
+ }));
485
+ });
486
+
487
+ it('should reject invalid URLs', async () => {
488
+ const result = await runner.execute({
489
+ type: 'navigation',
490
+ navigate: { to: 'data:text/html,...' },
491
+ });
492
+ expect(result.success).toBe(false);
493
+ expect(result.error).toContain('Invalid URL');
494
+ });
495
+ });
496
+
497
+ // ==========================================================================
498
+ // Post-execution: toast, refreshAfter
499
+ // ==========================================================================
500
+
501
+ describe('post-execution', () => {
502
+ it('should emit success toast', async () => {
503
+ const toastHandler = vi.fn();
504
+ runner.setToastHandler(toastHandler);
505
+
506
+ await runner.execute({
507
+ onClick: vi.fn(),
508
+ successMessage: 'Saved!',
509
+ });
510
+
511
+ expect(toastHandler).toHaveBeenCalledWith('Saved!', { type: 'success', duration: undefined });
512
+ });
513
+
514
+ it('should emit error toast on failure', async () => {
515
+ const toastHandler = vi.fn();
516
+ runner.setToastHandler(toastHandler);
517
+
518
+ await runner.execute({
519
+ onClick: vi.fn().mockRejectedValue(new Error('fail')),
520
+ errorMessage: 'Custom error',
521
+ });
522
+
523
+ expect(toastHandler).toHaveBeenCalledWith('Custom error', { type: 'error', duration: undefined });
524
+ });
525
+
526
+ it('should suppress toast when showOnSuccess is false', async () => {
527
+ const toastHandler = vi.fn();
528
+ runner.setToastHandler(toastHandler);
529
+
530
+ await runner.execute({
531
+ onClick: vi.fn(),
532
+ toast: { showOnSuccess: false },
533
+ });
534
+
535
+ expect(toastHandler).not.toHaveBeenCalled();
536
+ });
537
+
538
+ it('should set reload when refreshAfter is true', async () => {
539
+ const toastHandler = vi.fn();
540
+ runner.setToastHandler(toastHandler);
541
+
542
+ const result = await runner.execute({
543
+ onClick: vi.fn(),
544
+ refreshAfter: true,
545
+ toast: { showOnSuccess: false },
546
+ });
547
+
548
+ expect(result.reload).toBe(true);
549
+ });
550
+ });
551
+
552
+ // ==========================================================================
553
+ // Action chaining
554
+ // ==========================================================================
555
+
556
+ describe('chaining', () => {
557
+ it('should execute chained actions sequentially', async () => {
558
+ const order: number[] = [];
559
+ const handler1 = vi.fn(async () => { order.push(1); return { success: true }; });
560
+ const handler2 = vi.fn(async () => { order.push(2); return { success: true }; });
561
+ runner.registerHandler('step1', handler1);
562
+ runner.registerHandler('step2', handler2);
563
+
564
+ const result = await runner.execute({
565
+ onClick: vi.fn(),
566
+ chain: [
567
+ { type: 'step1' },
568
+ { type: 'step2' },
569
+ ],
570
+ });
571
+
572
+ expect(result.success).toBe(true);
573
+ expect(order).toEqual([1, 2]);
574
+ });
575
+
576
+ it('should stop sequential chain on failure', async () => {
577
+ const handler1 = vi.fn().mockResolvedValue({ success: false, error: 'step1 fail' });
578
+ const handler2 = vi.fn().mockResolvedValue({ success: true });
579
+ runner.registerHandler('step1', handler1);
580
+ runner.registerHandler('step2', handler2);
581
+
582
+ const result = await runner.execute({
583
+ onClick: vi.fn(),
584
+ chain: [
585
+ { type: 'step1' },
586
+ { type: 'step2' },
587
+ ],
588
+ });
589
+
590
+ expect(result.success).toBe(false);
591
+ expect(result.error).toBe('step1 fail');
592
+ expect(handler2).not.toHaveBeenCalled();
593
+ });
594
+
595
+ it('should execute chained actions in parallel', async () => {
596
+ const handler1 = vi.fn().mockResolvedValue({ success: true });
597
+ const handler2 = vi.fn().mockResolvedValue({ success: true });
598
+ runner.registerHandler('a', handler1);
599
+ runner.registerHandler('b', handler2);
600
+
601
+ const result = await runner.execute({
602
+ onClick: vi.fn(),
603
+ chain: [{ type: 'a' }, { type: 'b' }],
604
+ chainMode: 'parallel',
605
+ });
606
+
607
+ expect(result.success).toBe(true);
608
+ expect(handler1).toHaveBeenCalledOnce();
609
+ expect(handler2).toHaveBeenCalledOnce();
610
+ });
611
+ });
612
+
613
+ // ==========================================================================
614
+ // onSuccess / onFailure callbacks
615
+ // ==========================================================================
616
+
617
+ describe('callbacks', () => {
618
+ it('should execute onSuccess callback after success', async () => {
619
+ const successHandler = vi.fn().mockResolvedValue({ success: true });
620
+ runner.registerHandler('notify', successHandler);
621
+
622
+ await runner.execute({
623
+ onClick: vi.fn(),
624
+ onSuccess: { type: 'notify', params: { msg: 'ok' } },
625
+ toast: { showOnSuccess: false },
626
+ });
627
+
628
+ expect(successHandler).toHaveBeenCalledOnce();
629
+ });
630
+
631
+ it('should execute onFailure callback after failure', async () => {
632
+ const failureHandler = vi.fn().mockResolvedValue({ success: true });
633
+ runner.registerHandler('log-error', failureHandler);
634
+
635
+ await runner.execute({
636
+ onClick: vi.fn().mockRejectedValue(new Error('fail')),
637
+ onFailure: { type: 'log-error' },
638
+ toast: { showOnError: false },
639
+ });
640
+
641
+ expect(failureHandler).toHaveBeenCalledOnce();
642
+ });
643
+
644
+ it('should support array of onSuccess callbacks', async () => {
645
+ const h1 = vi.fn().mockResolvedValue({ success: true });
646
+ const h2 = vi.fn().mockResolvedValue({ success: true });
647
+ runner.registerHandler('cb1', h1);
648
+ runner.registerHandler('cb2', h2);
649
+
650
+ await runner.execute({
651
+ onClick: vi.fn(),
652
+ onSuccess: [{ type: 'cb1' }, { type: 'cb2' }],
653
+ toast: { showOnSuccess: false },
654
+ });
655
+
656
+ expect(h1).toHaveBeenCalledOnce();
657
+ expect(h2).toHaveBeenCalledOnce();
658
+ });
659
+ });
660
+
661
+ // ==========================================================================
662
+ // executeChain
663
+ // ==========================================================================
664
+
665
+ describe('executeChain', () => {
666
+ it('should return success for empty chain', async () => {
667
+ const result = await runner.executeChain([]);
668
+ expect(result.success).toBe(true);
669
+ });
670
+
671
+ it('should execute single action chain', async () => {
672
+ const onClick = vi.fn();
673
+ const result = await runner.executeChain([{ onClick }]);
674
+ expect(result.success).toBe(true);
675
+ expect(onClick).toHaveBeenCalledOnce();
676
+ });
677
+ });
678
+
679
+ // ==========================================================================
680
+ // Context management
681
+ // ==========================================================================
682
+
683
+ describe('context', () => {
684
+ it('should update context', () => {
685
+ runner.updateContext({ record: { id: 2, status: 'closed' } });
686
+ const ctx = runner.getContext();
687
+ expect(ctx.record?.id).toBe(2);
688
+ });
689
+
690
+ it('should expose evaluator', () => {
691
+ const ev = runner.getEvaluator();
692
+ expect(ev).toBeDefined();
693
+ expect(ev.evaluate('${data.name}')).toBe('Test');
694
+ });
695
+ });
696
+
697
+ // ==========================================================================
698
+ // executeAction convenience function
699
+ // ==========================================================================
700
+
701
+ describe('executeAction', () => {
702
+ it('should execute an action with the convenience function', async () => {
703
+ const result = await executeAction(
704
+ { type: 'script', execute: '1 + 2' },
705
+ { data: {} },
706
+ );
707
+ expect(result.success).toBe(true);
708
+ expect(result.data).toBe(3);
709
+ });
710
+ });
711
+ });