@mmapp/player-core 0.1.0-alpha.1

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 (63) hide show
  1. package/dist/index.d.mts +1436 -0
  2. package/dist/index.d.ts +1436 -0
  3. package/dist/index.js +4828 -0
  4. package/dist/index.mjs +4762 -0
  5. package/package.json +35 -0
  6. package/package.json.backup +35 -0
  7. package/src/__tests__/actions.test.ts +187 -0
  8. package/src/__tests__/blueprint-e2e.test.ts +706 -0
  9. package/src/__tests__/blueprint-test-runner.test.ts +680 -0
  10. package/src/__tests__/core-functions.test.ts +78 -0
  11. package/src/__tests__/dsl-compiler.test.ts +1382 -0
  12. package/src/__tests__/dsl-grammar.test.ts +1682 -0
  13. package/src/__tests__/events.test.ts +200 -0
  14. package/src/__tests__/expression.test.ts +296 -0
  15. package/src/__tests__/failure-policies.test.ts +110 -0
  16. package/src/__tests__/frontend-context.test.ts +182 -0
  17. package/src/__tests__/integration.test.ts +256 -0
  18. package/src/__tests__/security.test.ts +190 -0
  19. package/src/__tests__/state-machine.test.ts +450 -0
  20. package/src/__tests__/testing-engine.test.ts +671 -0
  21. package/src/actions/dispatcher.ts +80 -0
  22. package/src/actions/index.ts +7 -0
  23. package/src/actions/types.ts +25 -0
  24. package/src/dsl/compiler/component-mapper.ts +289 -0
  25. package/src/dsl/compiler/field-mapper.ts +187 -0
  26. package/src/dsl/compiler/index.ts +82 -0
  27. package/src/dsl/compiler/manifest-compiler.ts +76 -0
  28. package/src/dsl/compiler/symbol-table.ts +214 -0
  29. package/src/dsl/compiler/utils.ts +48 -0
  30. package/src/dsl/compiler/view-compiler.ts +286 -0
  31. package/src/dsl/compiler/workflow-compiler.ts +600 -0
  32. package/src/dsl/index.ts +66 -0
  33. package/src/dsl/ir-migration.ts +221 -0
  34. package/src/dsl/ir-types.ts +416 -0
  35. package/src/dsl/lexer.ts +579 -0
  36. package/src/dsl/parser.ts +115 -0
  37. package/src/dsl/types.ts +256 -0
  38. package/src/events/event-bus.ts +68 -0
  39. package/src/events/index.ts +9 -0
  40. package/src/events/pattern-matcher.ts +61 -0
  41. package/src/events/types.ts +27 -0
  42. package/src/expression/evaluator.ts +676 -0
  43. package/src/expression/functions.ts +214 -0
  44. package/src/expression/index.ts +13 -0
  45. package/src/expression/types.ts +64 -0
  46. package/src/index.ts +61 -0
  47. package/src/state-machine/index.ts +16 -0
  48. package/src/state-machine/interpreter.ts +319 -0
  49. package/src/state-machine/types.ts +89 -0
  50. package/src/testing/action-trace.ts +209 -0
  51. package/src/testing/blueprint-test-runner.ts +214 -0
  52. package/src/testing/graph-walker.ts +249 -0
  53. package/src/testing/index.ts +69 -0
  54. package/src/testing/nrt-comparator.ts +199 -0
  55. package/src/testing/nrt-types.ts +230 -0
  56. package/src/testing/test-actions.ts +645 -0
  57. package/src/testing/test-compiler.ts +278 -0
  58. package/src/testing/test-runner.ts +444 -0
  59. package/src/testing/types.ts +231 -0
  60. package/src/validation/definition-validator.ts +812 -0
  61. package/src/validation/index.ts +13 -0
  62. package/tsconfig.json +26 -0
  63. package/vitest.config.ts +8 -0
@@ -0,0 +1,671 @@
1
+ /**
2
+ * Testing OS — Unit tests for the testing engine.
3
+ *
4
+ * Uses PM Blueprint definitions (PM-Task, PM-Project) as fixtures.
5
+ * Tests graph walker, coverage generation, test runner (single + multi-instance).
6
+ */
7
+
8
+ import { describe, it, expect, vi } from 'vitest';
9
+ import { analyzeDefinition, generateCoverageScenarios } from '../testing/graph-walker';
10
+ import { runTestProgram, runScenario } from '../testing/test-runner';
11
+ import type { PlayerWorkflowDefinition } from '../state-machine/types';
12
+ import type { TestProgram, TestScenario } from '../testing/types';
13
+
14
+ // =============================================================================
15
+ // Fixtures: PM Blueprint definitions (from blueprint-e2e.test.ts)
16
+ // =============================================================================
17
+
18
+ const PM_TASK_DEFINITION: PlayerWorkflowDefinition = {
19
+ id: 'pm-task',
20
+ slug: 'pm-task',
21
+ states: [
22
+ {
23
+ name: 'todo',
24
+ type: 'START',
25
+ on_enter: [
26
+ { type: 'set_field', config: { field: 'status_label', value: 'To Do' } },
27
+ ],
28
+ },
29
+ {
30
+ name: 'in_progress',
31
+ type: 'REGULAR',
32
+ on_enter: [
33
+ { type: 'set_field', config: { field: 'status_label', value: 'In Progress' } },
34
+ ],
35
+ },
36
+ {
37
+ name: 'done',
38
+ type: 'END',
39
+ on_enter: [
40
+ { type: 'set_field', config: { field: 'status_label', value: 'Done' } },
41
+ ],
42
+ },
43
+ {
44
+ name: 'cancelled',
45
+ type: 'CANCELLED',
46
+ on_enter: [
47
+ { type: 'set_field', config: { field: 'status_label', value: 'Cancelled' } },
48
+ ],
49
+ },
50
+ ],
51
+ transitions: [
52
+ { name: 'start', from: ['todo'], to: 'in_progress' },
53
+ { name: 'complete', from: ['in_progress'], to: 'done' },
54
+ { name: 'cancel', from: ['todo', 'in_progress'], to: 'cancelled' },
55
+ { name: 'reopen', from: ['done', 'cancelled'], to: 'todo' },
56
+ ],
57
+ };
58
+
59
+ const PM_PROJECT_DEFINITION: PlayerWorkflowDefinition = {
60
+ id: 'pm-project',
61
+ slug: 'pm-project',
62
+ states: [
63
+ {
64
+ name: 'draft',
65
+ type: 'START',
66
+ on_enter: [
67
+ { type: 'set_field', config: { field: 'total_tasks', value: 0 } },
68
+ { type: 'set_field', config: { field: 'completed_tasks', value: 0 } },
69
+ ],
70
+ },
71
+ {
72
+ name: 'active',
73
+ type: 'REGULAR',
74
+ on_event: [
75
+ {
76
+ match: '*:*:pm-task:transition.completed',
77
+ actions: [
78
+ {
79
+ type: 'set_field',
80
+ config: { field: 'last_task_event', value: 'task_transitioned' },
81
+ },
82
+ ],
83
+ },
84
+ ],
85
+ },
86
+ { name: 'completed', type: 'END' },
87
+ { name: 'cancelled', type: 'CANCELLED' },
88
+ ],
89
+ transitions: [
90
+ { name: 'activate', from: ['draft'], to: 'active' },
91
+ {
92
+ name: 'auto_complete',
93
+ from: ['active'],
94
+ to: 'completed',
95
+ auto: true,
96
+ conditions: ['gt(completed_tasks, 0)', 'eq(completed_tasks, total_tasks)'],
97
+ },
98
+ { name: 'cancel', from: ['draft', 'active'], to: 'cancelled' },
99
+ ],
100
+ };
101
+
102
+ // =============================================================================
103
+ // Graph Walker Tests
104
+ // =============================================================================
105
+
106
+ describe('Graph Walker', () => {
107
+ describe('analyzeDefinition', () => {
108
+ it('correctly analyzes PM-Task (4 states, 4 transitions)', () => {
109
+ const analysis = analyzeDefinition(PM_TASK_DEFINITION);
110
+
111
+ expect(analysis.states).toHaveLength(4);
112
+ expect(analysis.summary.totalStates).toBe(4);
113
+ expect(analysis.summary.reachableStates).toBe(4);
114
+ expect(analysis.unreachableStates).toHaveLength(0);
115
+ expect(analysis.deadEndStates).toHaveLength(0);
116
+
117
+ // All states reachable
118
+ for (const state of analysis.states) {
119
+ expect(state.reachable).toBe(true);
120
+ }
121
+ });
122
+
123
+ it('finds terminal paths in PM-Task', () => {
124
+ const analysis = analyzeDefinition(PM_TASK_DEFINITION);
125
+
126
+ // Paths: todo→in_progress→done, todo→cancelled, todo→in_progress→cancelled
127
+ expect(analysis.terminalPaths.length).toBeGreaterThanOrEqual(3);
128
+
129
+ // Each terminal path should end at 'done' or 'cancelled'
130
+ for (const path of analysis.terminalPaths) {
131
+ const lastState = path.states[path.states.length - 1];
132
+ expect(['done', 'cancelled']).toContain(lastState);
133
+ }
134
+ });
135
+
136
+ it('detects cycles (reopen from done/cancelled)', () => {
137
+ const analysis = analyzeDefinition(PM_TASK_DEFINITION);
138
+
139
+ // reopen creates cycles: done→todo and cancelled→todo
140
+ expect(analysis.cycles.length).toBeGreaterThan(0);
141
+ });
142
+
143
+ it('correctly analyzes PM-Project (auto-transition)', () => {
144
+ const analysis = analyzeDefinition(PM_PROJECT_DEFINITION);
145
+
146
+ expect(analysis.states).toHaveLength(4);
147
+ expect(analysis.summary.reachableStates).toBe(4);
148
+
149
+ // Should have edges for auto_complete
150
+ const autoEdges = analysis.edges.filter(e => e.auto);
151
+ expect(autoEdges.length).toBe(1);
152
+ expect(autoEdges[0].name).toBe('auto_complete');
153
+ });
154
+
155
+ it('identifies unreachable states', () => {
156
+ const isolated: PlayerWorkflowDefinition = {
157
+ id: 'test-isolated',
158
+ slug: 'test-isolated',
159
+ states: [
160
+ { name: 'start', type: 'START' },
161
+ { name: 'middle', type: 'REGULAR' },
162
+ { name: 'end', type: 'END' },
163
+ { name: 'orphan', type: 'REGULAR' }, // unreachable
164
+ ],
165
+ transitions: [
166
+ { name: 'go', from: ['start'], to: 'middle' },
167
+ { name: 'finish', from: ['middle'], to: 'end' },
168
+ ],
169
+ };
170
+
171
+ const analysis = analyzeDefinition(isolated);
172
+ expect(analysis.unreachableStates).toContain('orphan');
173
+ expect(analysis.summary.unreachableStates).toBe(1);
174
+ });
175
+
176
+ it('identifies dead-end states', () => {
177
+ const deadEnd: PlayerWorkflowDefinition = {
178
+ id: 'test-deadend',
179
+ slug: 'test-deadend',
180
+ states: [
181
+ { name: 'start', type: 'START' },
182
+ { name: 'stuck', type: 'REGULAR' }, // no outgoing transitions
183
+ ],
184
+ transitions: [
185
+ { name: 'go', from: ['start'], to: 'stuck' },
186
+ ],
187
+ };
188
+
189
+ const analysis = analyzeDefinition(deadEnd);
190
+ expect(analysis.deadEndStates).toContain('stuck');
191
+ expect(analysis.summary.deadEndStates).toBe(1);
192
+ });
193
+
194
+ it('handles definition with no START state', () => {
195
+ const noStart: PlayerWorkflowDefinition = {
196
+ id: 'no-start',
197
+ slug: 'no-start',
198
+ states: [
199
+ { name: 'a', type: 'REGULAR' },
200
+ { name: 'b', type: 'END' },
201
+ ],
202
+ transitions: [
203
+ { name: 'go', from: ['a'], to: 'b' },
204
+ ],
205
+ };
206
+
207
+ const analysis = analyzeDefinition(noStart);
208
+ expect(analysis.terminalPaths).toHaveLength(0);
209
+ expect(analysis.unreachableStates).toHaveLength(2);
210
+ });
211
+ });
212
+
213
+ describe('generateCoverageScenarios', () => {
214
+ it('generates one scenario per terminal path for PM-Task', () => {
215
+ const analysis = analyzeDefinition(PM_TASK_DEFINITION);
216
+ const scenarios = generateCoverageScenarios(PM_TASK_DEFINITION, analysis);
217
+
218
+ expect(scenarios.length).toBe(analysis.terminalPaths.length);
219
+
220
+ // Each scenario should have the 'auto-generated' tag
221
+ for (const scenario of scenarios) {
222
+ expect(scenario.tags).toContain('auto-generated');
223
+ expect(scenario.steps.length).toBeGreaterThan(0);
224
+ }
225
+ });
226
+
227
+ it('scenario steps assert correct state after each transition', () => {
228
+ const scenarios = generateCoverageScenarios(PM_TASK_DEFINITION);
229
+
230
+ // Find the happy path: todo → in_progress → done
231
+ const happyPath = scenarios.find(s => s.name.includes('done'));
232
+ expect(happyPath).toBeDefined();
233
+
234
+ // First step should be 'start' transition, assert state 'in_progress'
235
+ const firstStep = happyPath!.steps[0];
236
+ expect(firstStep.action).toEqual({ type: 'transition', name: 'start' });
237
+ expect(firstStep.assertions[0]).toMatchObject({
238
+ target: 'state',
239
+ operator: 'eq',
240
+ expected: 'in_progress',
241
+ });
242
+ });
243
+
244
+ it('generates scenarios for PM-Project including auto-transition path', () => {
245
+ const scenarios = generateCoverageScenarios(PM_PROJECT_DEFINITION);
246
+
247
+ // Should have paths through completed and cancelled
248
+ expect(scenarios.length).toBeGreaterThanOrEqual(2);
249
+
250
+ const pathNames = scenarios.map(s => s.name);
251
+ expect(pathNames.some(n => n.includes('completed'))).toBe(true);
252
+ expect(pathNames.some(n => n.includes('cancelled'))).toBe(true);
253
+ });
254
+
255
+ it('terminal steps assert status', () => {
256
+ const scenarios = generateCoverageScenarios(PM_TASK_DEFINITION);
257
+
258
+ const donePath = scenarios.find(s => s.name.includes('done'));
259
+ expect(donePath).toBeDefined();
260
+
261
+ const lastStep = donePath!.steps[donePath!.steps.length - 1];
262
+ const statusAssertion = lastStep.assertions.find(a => a.target === 'status');
263
+ expect(statusAssertion).toBeDefined();
264
+ expect(statusAssertion!.expected).toBe('COMPLETED');
265
+ });
266
+ });
267
+ });
268
+
269
+ // =============================================================================
270
+ // Test Runner Tests
271
+ // =============================================================================
272
+
273
+ describe('Test Runner', () => {
274
+ describe('Single-instance scenarios', () => {
275
+ it('runs PM-Task full lifecycle (todo → in_progress → done)', async () => {
276
+ const program: TestProgram = {
277
+ name: 'PM-Task Lifecycle',
278
+ definitionSlug: 'pm-task',
279
+ definitions: { 'pm-task': PM_TASK_DEFINITION },
280
+ scenarios: [
281
+ {
282
+ name: 'Happy path',
283
+ steps: [
284
+ {
285
+ name: 'Start task',
286
+ action: { type: 'transition', name: 'start' },
287
+ assertions: [
288
+ { target: 'state', operator: 'eq', expected: 'in_progress' },
289
+ { target: 'status', operator: 'eq', expected: 'ACTIVE' },
290
+ ],
291
+ },
292
+ {
293
+ name: 'Complete task',
294
+ action: { type: 'transition', name: 'complete' },
295
+ assertions: [
296
+ { target: 'state', operator: 'eq', expected: 'done' },
297
+ { target: 'status', operator: 'eq', expected: 'COMPLETED' },
298
+ ],
299
+ },
300
+ ],
301
+ },
302
+ ],
303
+ };
304
+
305
+ const result = await runTestProgram(program);
306
+
307
+ expect(result.passed).toBe(true);
308
+ expect(result.scenarioResults).toHaveLength(1);
309
+ expect(result.scenarioResults[0].passed).toBe(true);
310
+ expect(result.scenarioResults[0].stepResults).toHaveLength(2);
311
+ expect(result.durationMs).toBeLessThan(100);
312
+ });
313
+
314
+ it('verifies state_data via set_field action', async () => {
315
+ const program: TestProgram = {
316
+ name: 'State data test',
317
+ definitionSlug: 'pm-task',
318
+ definitions: { 'pm-task': PM_TASK_DEFINITION },
319
+ scenarios: [
320
+ {
321
+ name: 'set_field works',
322
+ steps: [
323
+ {
324
+ name: 'Set priority',
325
+ action: { type: 'set_field', field: 'priority', value: 'high' },
326
+ assertions: [
327
+ { target: 'state_data', path: 'priority', operator: 'eq', expected: 'high' },
328
+ ],
329
+ },
330
+ ],
331
+ },
332
+ ],
333
+ };
334
+
335
+ const result = await runTestProgram(program);
336
+ expect(result.passed).toBe(true);
337
+ });
338
+
339
+ it('reports failing assertion correctly', async () => {
340
+ const program: TestProgram = {
341
+ name: 'Failing test',
342
+ definitionSlug: 'pm-task',
343
+ definitions: { 'pm-task': PM_TASK_DEFINITION },
344
+ scenarios: [
345
+ {
346
+ name: 'Wrong state',
347
+ steps: [
348
+ {
349
+ name: 'Start task',
350
+ action: { type: 'transition', name: 'start' },
351
+ assertions: [
352
+ { target: 'state', operator: 'eq', expected: 'done', label: 'Should be done (will fail)' },
353
+ ],
354
+ },
355
+ ],
356
+ },
357
+ ],
358
+ };
359
+
360
+ const result = await runTestProgram(program);
361
+
362
+ expect(result.passed).toBe(false);
363
+ expect(result.scenarioResults[0].passed).toBe(false);
364
+
365
+ const failedAssertion = result.scenarioResults[0].stepResults[0].assertionResults[0];
366
+ expect(failedAssertion.passed).toBe(false);
367
+ expect(failedAssertion.actual).toBe('in_progress');
368
+ expect(failedAssertion.assertion.expected).toBe('done');
369
+ });
370
+
371
+ it('available_transitions assertion works', async () => {
372
+ const program: TestProgram = {
373
+ name: 'Available transitions',
374
+ definitionSlug: 'pm-task',
375
+ definitions: { 'pm-task': PM_TASK_DEFINITION },
376
+ scenarios: [
377
+ {
378
+ name: 'Check available transitions',
379
+ steps: [
380
+ {
381
+ name: 'In todo state',
382
+ action: { type: 'assert_only' },
383
+ assertions: [
384
+ { target: 'available_transitions', operator: 'contains', expected: 'start' },
385
+ { target: 'available_transitions', operator: 'contains', expected: 'cancel' },
386
+ ],
387
+ },
388
+ ],
389
+ },
390
+ ],
391
+ };
392
+
393
+ const result = await runTestProgram(program);
394
+ expect(result.passed).toBe(true);
395
+ });
396
+
397
+ it('numeric comparison operators work', async () => {
398
+ const program: TestProgram = {
399
+ name: 'Numeric operators',
400
+ definitionSlug: 'pm-task',
401
+ definitions: { 'pm-task': PM_TASK_DEFINITION },
402
+ scenarios: [
403
+ {
404
+ name: 'Numeric assertions',
405
+ steps: [
406
+ {
407
+ name: 'Set count',
408
+ action: { type: 'set_field', field: 'count', value: 5 },
409
+ assertions: [
410
+ { target: 'state_data', path: 'count', operator: 'gt', expected: 3 },
411
+ { target: 'state_data', path: 'count', operator: 'gte', expected: 5 },
412
+ { target: 'state_data', path: 'count', operator: 'lt', expected: 10 },
413
+ { target: 'state_data', path: 'count', operator: 'lte', expected: 5 },
414
+ { target: 'state_data', path: 'count', operator: 'neq', expected: 0 },
415
+ ],
416
+ },
417
+ ],
418
+ },
419
+ ],
420
+ };
421
+
422
+ const result = await runTestProgram(program);
423
+ expect(result.passed).toBe(true);
424
+ });
425
+ });
426
+
427
+ describe('Multi-instance scenarios', () => {
428
+ it('PM-Project + PM-Task connected via EventBus: complete task → verify project state_data', async () => {
429
+ const scenario: TestScenario = {
430
+ name: 'Multi-instance: task completion updates project',
431
+ connectEventBuses: true,
432
+ instances: [
433
+ {
434
+ id: 'project',
435
+ definitionSlug: 'pm-project',
436
+ initialData: { total_tasks: 2, completed_tasks: 0 },
437
+ },
438
+ {
439
+ id: 'task1',
440
+ definitionSlug: 'pm-task',
441
+ },
442
+ ],
443
+ steps: [
444
+ // Activate the project
445
+ {
446
+ name: 'Activate project',
447
+ instanceId: 'project',
448
+ action: { type: 'transition', name: 'activate' },
449
+ assertions: [
450
+ { target: 'state', instanceId: 'project', operator: 'eq', expected: 'active' },
451
+ ],
452
+ },
453
+ // Start task
454
+ {
455
+ name: 'Start task',
456
+ instanceId: 'task1',
457
+ action: { type: 'transition', name: 'start' },
458
+ assertions: [
459
+ { target: 'state', instanceId: 'task1', operator: 'eq', expected: 'in_progress' },
460
+ ],
461
+ },
462
+ // Simulate task completion domain event on the shared event bus
463
+ {
464
+ name: 'Publish task completed event',
465
+ action: {
466
+ type: 'publish_event',
467
+ topic: 'stakeholder:s1:pm-task:transition.completed',
468
+ payload: {
469
+ transition_name: 'complete',
470
+ from_state: 'in_progress',
471
+ to_state: 'done',
472
+ },
473
+ },
474
+ assertions: [
475
+ // Project's on_event should have set last_task_event
476
+ {
477
+ target: 'state_data',
478
+ instanceId: 'project',
479
+ path: 'last_task_event',
480
+ operator: 'eq',
481
+ expected: 'task_transitioned',
482
+ },
483
+ ],
484
+ },
485
+ ],
486
+ };
487
+
488
+ const result = await runScenario(
489
+ scenario,
490
+ { 'pm-project': PM_PROJECT_DEFINITION, 'pm-task': PM_TASK_DEFINITION },
491
+ );
492
+
493
+ expect(result.passed).toBe(true);
494
+ expect(result.stepResults).toHaveLength(3);
495
+ });
496
+ });
497
+
498
+ describe('Auto-generated coverage', () => {
499
+ it('runs auto-generated scenarios from graph walker', async () => {
500
+ const analysis = analyzeDefinition(PM_TASK_DEFINITION);
501
+ const scenarios = generateCoverageScenarios(PM_TASK_DEFINITION, analysis);
502
+
503
+ const program: TestProgram = {
504
+ name: 'Auto-generated PM-Task coverage',
505
+ definitionSlug: 'pm-task',
506
+ definitions: { 'pm-task': PM_TASK_DEFINITION },
507
+ scenarios,
508
+ };
509
+
510
+ const result = await runTestProgram(program);
511
+
512
+ // All auto-generated scenarios should pass
513
+ expect(result.passed).toBe(true);
514
+ expect(result.scenarioResults.length).toBe(scenarios.length);
515
+ expect(result.durationMs).toBeLessThan(100);
516
+ });
517
+ });
518
+
519
+ describe('Callbacks and abort', () => {
520
+ it('calls onStepComplete callback', async () => {
521
+ const onStepComplete = vi.fn();
522
+
523
+ const program: TestProgram = {
524
+ name: 'Callback test',
525
+ definitionSlug: 'pm-task',
526
+ definitions: { 'pm-task': PM_TASK_DEFINITION },
527
+ scenarios: [
528
+ {
529
+ name: 'With callbacks',
530
+ steps: [
531
+ {
532
+ name: 'Start',
533
+ action: { type: 'transition', name: 'start' },
534
+ assertions: [{ target: 'state', operator: 'eq', expected: 'in_progress' }],
535
+ },
536
+ {
537
+ name: 'Complete',
538
+ action: { type: 'transition', name: 'complete' },
539
+ assertions: [{ target: 'state', operator: 'eq', expected: 'done' }],
540
+ },
541
+ ],
542
+ },
543
+ ],
544
+ };
545
+
546
+ await runTestProgram(program, { onStepComplete });
547
+
548
+ expect(onStepComplete).toHaveBeenCalledTimes(2);
549
+ });
550
+
551
+ it('calls onScenarioComplete callback', async () => {
552
+ const onScenarioComplete = vi.fn();
553
+
554
+ const program: TestProgram = {
555
+ name: 'Scenario callback test',
556
+ definitionSlug: 'pm-task',
557
+ definitions: { 'pm-task': PM_TASK_DEFINITION },
558
+ scenarios: [
559
+ {
560
+ name: 'First',
561
+ steps: [
562
+ {
563
+ name: 'Start',
564
+ action: { type: 'transition', name: 'start' },
565
+ assertions: [{ target: 'state', operator: 'eq', expected: 'in_progress' }],
566
+ },
567
+ ],
568
+ },
569
+ {
570
+ name: 'Second',
571
+ steps: [
572
+ {
573
+ name: 'Cancel',
574
+ action: { type: 'transition', name: 'cancel' },
575
+ assertions: [{ target: 'state', operator: 'eq', expected: 'cancelled' }],
576
+ },
577
+ ],
578
+ },
579
+ ],
580
+ };
581
+
582
+ await runTestProgram(program, { onScenarioComplete });
583
+
584
+ expect(onScenarioComplete).toHaveBeenCalledTimes(2);
585
+ });
586
+
587
+ it('abort signal stops execution', async () => {
588
+ const controller = new AbortController();
589
+ const onScenarioComplete = vi.fn();
590
+
591
+ // Abort after first scenario
592
+ onScenarioComplete.mockImplementation(() => {
593
+ controller.abort();
594
+ });
595
+
596
+ const program: TestProgram = {
597
+ name: 'Abort test',
598
+ definitionSlug: 'pm-task',
599
+ definitions: { 'pm-task': PM_TASK_DEFINITION },
600
+ scenarios: [
601
+ {
602
+ name: 'First',
603
+ steps: [
604
+ {
605
+ name: 'Start',
606
+ action: { type: 'transition', name: 'start' },
607
+ assertions: [{ target: 'state', operator: 'eq', expected: 'in_progress' }],
608
+ },
609
+ ],
610
+ },
611
+ {
612
+ name: 'Second (should be skipped)',
613
+ steps: [
614
+ {
615
+ name: 'Complete',
616
+ action: { type: 'transition', name: 'start' },
617
+ assertions: [{ target: 'state', operator: 'eq', expected: 'in_progress' }],
618
+ },
619
+ ],
620
+ },
621
+ ],
622
+ };
623
+
624
+ const result = await runTestProgram(program, {
625
+ abortSignal: controller.signal,
626
+ onScenarioComplete,
627
+ });
628
+
629
+ // Only first scenario should have run
630
+ expect(result.scenarioResults).toHaveLength(1);
631
+ });
632
+ });
633
+
634
+ describe('Error handling', () => {
635
+ it('reports missing definition error', async () => {
636
+ const scenario: TestScenario = {
637
+ name: 'Missing def',
638
+ instances: [{ id: 'x', definitionSlug: 'nonexistent' }],
639
+ steps: [],
640
+ };
641
+
642
+ const result = await runScenario(scenario, {});
643
+ expect(result.passed).toBe(false);
644
+ expect(result.error).toContain('nonexistent');
645
+ });
646
+
647
+ it('reports transition failure as step error', async () => {
648
+ const program: TestProgram = {
649
+ name: 'Bad transition',
650
+ definitionSlug: 'pm-task',
651
+ definitions: { 'pm-task': PM_TASK_DEFINITION },
652
+ scenarios: [
653
+ {
654
+ name: 'Invalid transition',
655
+ steps: [
656
+ {
657
+ name: 'Complete from todo (invalid)',
658
+ action: { type: 'transition', name: 'complete' },
659
+ assertions: [],
660
+ },
661
+ ],
662
+ },
663
+ ],
664
+ };
665
+
666
+ const result = await runTestProgram(program);
667
+ expect(result.passed).toBe(false);
668
+ expect(result.scenarioResults[0].stepResults[0].error).toContain('not valid');
669
+ });
670
+ });
671
+ });