elsabro 7.3.0 → 7.3.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.
@@ -0,0 +1,738 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { getExecutor, checkRuntimeStatus, NotImplementedError, DeprecatedNodeError, ExecutorError } = require('../src/executors');
6
+
7
+ /**
8
+ * Tests for the execute.md dispatcher pattern.
9
+ *
10
+ * The dispatcher (section 3 of commands/elsabro/execute.md) determines how to handle
11
+ * each instruction type returned by the CLI step command:
12
+ *
13
+ * Auto-resolved by engine: entry, exit, condition, router
14
+ * Require dispatcher action: agent, parallel, team, interrupt, sequence
15
+ *
16
+ * This test suite validates:
17
+ * - Executor selection via getExecutor
18
+ * - Runtime status validation (not_implemented, deprecated)
19
+ * - Error handling and propagation
20
+ * - Edge cases for each node type
21
+ */
22
+
23
+ // Minimal context factory
24
+ function makeContext(overrides = {}) {
25
+ return {
26
+ inputs: { task: 'test task', profile: 'default', complexity: 'medium', ...overrides.inputs },
27
+ nodes: { ...overrides.nodes },
28
+ steps: { ...overrides.steps },
29
+ state: { ...overrides.state },
30
+ _iterations: {}
31
+ };
32
+ }
33
+
34
+ describe('Execute Dispatcher: Executor Selection', () => {
35
+ it('dispatches to correct executor for each node type', () => {
36
+ const nodeTypes = [
37
+ 'entry', 'exit', 'condition', 'router', 'sequence',
38
+ 'agent', 'parallel', 'interrupt', 'team'
39
+ ];
40
+
41
+ for (const type of nodeTypes) {
42
+ const executor = getExecutor(type);
43
+ assert.equal(typeof executor, 'function', `Missing executor for type "${type}"`);
44
+ assert.ok(executor.name.includes('execute'), `Executor "${executor.name}" should be named execute*`);
45
+ }
46
+ });
47
+
48
+ it('throws ExecutorError for unknown node type', () => {
49
+ assert.throws(
50
+ () => getExecutor('subflow'),
51
+ (err) => {
52
+ assert.equal(err.name, 'ExecutorError');
53
+ assert.ok(err.message.includes('No executor for node type "subflow"'));
54
+ return true;
55
+ }
56
+ );
57
+ });
58
+
59
+ it('throws ExecutorError for undefined node type', () => {
60
+ assert.throws(
61
+ () => getExecutor(undefined),
62
+ /No executor/
63
+ );
64
+ });
65
+
66
+ it('throws ExecutorError for null node type', () => {
67
+ assert.throws(
68
+ () => getExecutor(null),
69
+ /No executor/
70
+ );
71
+ });
72
+ });
73
+
74
+ describe('Execute Dispatcher: Runtime Status Validation', () => {
75
+ it('allows execution of implemented nodes', () => {
76
+ const node = { id: 'test', runtime_status: 'implemented' };
77
+ assert.doesNotThrow(() => checkRuntimeStatus(node));
78
+ });
79
+
80
+ it('allows execution of partial nodes', () => {
81
+ const node = { id: 'test', runtime_status: 'partial' };
82
+ assert.doesNotThrow(() => checkRuntimeStatus(node));
83
+ });
84
+
85
+ it('allows execution when runtime_status is undefined', () => {
86
+ const node = { id: 'test' };
87
+ assert.doesNotThrow(() => checkRuntimeStatus(node));
88
+ });
89
+
90
+ it('throws NotImplementedError for not_implemented nodes', () => {
91
+ const node = {
92
+ id: 'bmad_solution',
93
+ runtime_status: 'not_implemented',
94
+ gaps: ['BMAD solutioning logic not yet implemented', 'Needs ADR generation']
95
+ };
96
+
97
+ assert.throws(
98
+ () => checkRuntimeStatus(node),
99
+ (err) => {
100
+ assert.equal(err.name, 'NotImplementedError');
101
+ assert.equal(err.nodeId, 'bmad_solution');
102
+ assert.ok(err.message.includes('BMAD solutioning logic not yet implemented'));
103
+ assert.ok(err.message.includes('Needs ADR generation'));
104
+ assert.deepEqual(err.gaps, node.gaps);
105
+ return true;
106
+ }
107
+ );
108
+ });
109
+
110
+ it('throws NotImplementedError with empty gaps array', () => {
111
+ const node = {
112
+ id: 'unimplemented',
113
+ runtime_status: 'not_implemented',
114
+ gaps: []
115
+ };
116
+
117
+ assert.throws(
118
+ () => checkRuntimeStatus(node),
119
+ (err) => {
120
+ assert.equal(err.name, 'NotImplementedError');
121
+ assert.equal(err.nodeId, 'unimplemented');
122
+ assert.deepEqual(err.gaps, []);
123
+ return true;
124
+ }
125
+ );
126
+ });
127
+
128
+ it('throws DeprecatedNodeError for deprecated nodes', () => {
129
+ const node = {
130
+ id: 'legacy_team_node',
131
+ runtime_status: 'deprecated',
132
+ deprecated_reason: 'Use parallel nodes with Agent Teams instead (Rule 8)'
133
+ };
134
+
135
+ assert.throws(
136
+ () => checkRuntimeStatus(node),
137
+ (err) => {
138
+ assert.equal(err.name, 'DeprecatedNodeError');
139
+ assert.equal(err.nodeId, 'legacy_team_node');
140
+ assert.ok(err.message.includes('Use parallel nodes with Agent Teams instead'));
141
+ assert.equal(err.reason, node.deprecated_reason);
142
+ return true;
143
+ }
144
+ );
145
+ });
146
+
147
+ it('throws DeprecatedNodeError with default reason when none provided', () => {
148
+ const node = {
149
+ id: 'old_node',
150
+ runtime_status: 'deprecated'
151
+ };
152
+
153
+ assert.throws(
154
+ () => checkRuntimeStatus(node),
155
+ (err) => {
156
+ assert.equal(err.name, 'DeprecatedNodeError');
157
+ assert.ok(err.message.includes('Node is deprecated'));
158
+ return true;
159
+ }
160
+ );
161
+ });
162
+ });
163
+
164
+ describe('Execute Dispatcher: Auto-Resolved Nodes', () => {
165
+ it('entry node: auto-resolved by engine, returns next', async () => {
166
+ const executor = getExecutor('entry');
167
+ const result = await executor(
168
+ { id: 'start', type: 'entry', next: 'skill_discovery' },
169
+ makeContext(),
170
+ {}
171
+ );
172
+ assert.equal(result.next, 'skill_discovery');
173
+ assert.deepEqual(result.outputs, {});
174
+ });
175
+
176
+ it('exit node: auto-resolved by engine, returns null next with outputs', async () => {
177
+ const executor = getExecutor('exit');
178
+ const ctx = makeContext({ inputs: { task: 'my task' } });
179
+ const result = await executor(
180
+ {
181
+ id: 'end_success',
182
+ type: 'exit',
183
+ status: 'success',
184
+ outputs: { success: true, task: '{{inputs.task}}' }
185
+ },
186
+ ctx,
187
+ {}
188
+ );
189
+ assert.equal(result.next, null);
190
+ assert.equal(result.status, 'success');
191
+ assert.equal(result.outputs.success, true);
192
+ assert.equal(result.outputs.task, 'my task');
193
+ });
194
+
195
+ it('condition node: auto-resolved by engine, evaluates and routes', async () => {
196
+ const executor = getExecutor('condition');
197
+ const ctx = makeContext({
198
+ nodes: {
199
+ quality_gate: { outputs: { tests: { exitCode: 0 } } }
200
+ }
201
+ });
202
+ const result = await executor(
203
+ {
204
+ id: 'check_tests',
205
+ type: 'condition',
206
+ condition: '{{nodes.quality_gate.outputs.tests.exitCode === 0}}',
207
+ true: 'review',
208
+ false: 'fix'
209
+ },
210
+ ctx,
211
+ {}
212
+ );
213
+ assert.equal(result.next, 'review');
214
+ assert.equal(result.outputs.conditionResult, true);
215
+ });
216
+
217
+ it('router node: auto-resolved by engine, matches route', async () => {
218
+ const executor = getExecutor('router');
219
+ const ctx = makeContext({ inputs: { profile: 'yolo' } });
220
+ const result = await executor(
221
+ {
222
+ id: 'profile_router',
223
+ type: 'router',
224
+ condition: '{{inputs.profile}}',
225
+ routes: {
226
+ yolo: 'interview_yolo',
227
+ careful: 'interview_careful',
228
+ default: 'interview_default'
229
+ },
230
+ default: 'interview_default'
231
+ },
232
+ ctx,
233
+ {}
234
+ );
235
+ assert.equal(result.next, 'interview_yolo');
236
+ assert.equal(result.outputs.routeKey, 'yolo');
237
+ });
238
+ });
239
+
240
+ describe('Execute Dispatcher: Action Nodes', () => {
241
+ it('agent node: dispatches to Task tool', async () => {
242
+ const executor = getExecutor('agent');
243
+ const callbacks = {
244
+ onAgent: async (params) => {
245
+ assert.equal(params.agent, 'elsabro-architect');
246
+ assert.equal(params.config.style, 'bmad');
247
+ assert.deepEqual(params.inputs, { task: 'test task' });
248
+ return { result: 'agent output' };
249
+ }
250
+ };
251
+ const ctx = makeContext();
252
+ const result = await executor(
253
+ {
254
+ id: 'architect',
255
+ type: 'agent',
256
+ agent: 'elsabro-architect',
257
+ config: { style: 'bmad' },
258
+ inputs: { task: '{{inputs.task}}' }
259
+ },
260
+ ctx,
261
+ callbacks
262
+ );
263
+ assert.equal(result.next, undefined);
264
+ assert.ok(result.outputs.output);
265
+ });
266
+
267
+ it('sequence node: executes steps sequentially', async () => {
268
+ const executor = getExecutor('sequence');
269
+ const callbacks = {
270
+ onBash: async (cmd) => ({ output: 'bash output', exitCode: 0 })
271
+ };
272
+ const ctx = makeContext();
273
+ const result = await executor(
274
+ {
275
+ id: 'seq',
276
+ type: 'sequence',
277
+ steps: [
278
+ { action: 'bash', command: 'echo test', as: 'step1' }
279
+ ],
280
+ outputs: { result: '{{steps.step1.output.output}}' },
281
+ next: 'next_node'
282
+ },
283
+ ctx,
284
+ callbacks
285
+ );
286
+ assert.equal(result.next, 'next_node');
287
+ assert.equal(result.outputs.result, 'bash output');
288
+ assert.ok(ctx.steps.step1);
289
+ });
290
+
291
+ it('parallel node: dispatches branches', async () => {
292
+ const executor = getExecutor('parallel');
293
+ const callbacks = {
294
+ onParallel: async (params) => {
295
+ assert.equal(params.nodeId, 'review');
296
+ assert.equal(params.branches.length, 2);
297
+ return [
298
+ { id: 'security', result: 'secure' },
299
+ { id: 'performance', result: 'fast' }
300
+ ];
301
+ }
302
+ };
303
+ const ctx = makeContext();
304
+ const result = await executor(
305
+ {
306
+ id: 'review',
307
+ type: 'parallel',
308
+ branches: [
309
+ { id: 'security', agent: 'security-reviewer', inputs: {} },
310
+ { id: 'performance', agent: 'perf-reviewer', inputs: {} }
311
+ ],
312
+ next: 'collect'
313
+ },
314
+ ctx,
315
+ callbacks
316
+ );
317
+ assert.equal(result.next, 'collect');
318
+ assert.ok(result.outputs.branches.security);
319
+ assert.ok(result.outputs.branches.performance);
320
+ });
321
+
322
+ it('interrupt node: prompts user with AskUserQuestion', async () => {
323
+ const executor = getExecutor('interrupt');
324
+ const callbacks = {
325
+ onInterrupt: async (params) => {
326
+ assert.equal(params.nodeId, 'user_choice');
327
+ assert.ok(params.display.title);
328
+ return 'continue';
329
+ }
330
+ };
331
+ const ctx = makeContext();
332
+ const result = await executor(
333
+ {
334
+ id: 'user_choice',
335
+ type: 'interrupt',
336
+ reason: 'User approval required',
337
+ display: {
338
+ title: 'Continue execution?',
339
+ options: [{ id: 'continue', label: 'Yes' }, { id: 'abort', label: 'No' }]
340
+ },
341
+ routes: { continue: 'next', abort: 'end' }
342
+ },
343
+ ctx,
344
+ callbacks
345
+ );
346
+ assert.equal(result.next, 'next');
347
+ assert.equal(result.outputs.selection, 'continue');
348
+ });
349
+
350
+ it('team node: deprecated but callable', async () => {
351
+ const executor = getExecutor('team');
352
+ const callbacks = {
353
+ onTeam: async (node) => {
354
+ assert.equal(node.id, 'legacy_team');
355
+ return { teamOutput: 'done' };
356
+ }
357
+ };
358
+ const ctx = makeContext();
359
+ const result = await executor(
360
+ {
361
+ id: 'legacy_team',
362
+ type: 'team',
363
+ next: 'next'
364
+ },
365
+ ctx,
366
+ callbacks
367
+ );
368
+ assert.equal(result.next, 'next');
369
+ assert.ok(result.outputs.teamOutput);
370
+ });
371
+ });
372
+
373
+ describe('Execute Dispatcher: Error Handling', () => {
374
+ it('condition node throws when condition field is missing', async () => {
375
+ const executor = getExecutor('condition');
376
+ await assert.rejects(
377
+ executor({ id: 'bad_cond', type: 'condition', true: 'a', false: 'b' }, makeContext(), {}),
378
+ (err) => {
379
+ assert.equal(err.name, 'ExecutorError');
380
+ assert.ok(err.message.includes('has no "condition" field'));
381
+ return true;
382
+ }
383
+ );
384
+ });
385
+
386
+ it('condition node throws when branch is missing', async () => {
387
+ const executor = getExecutor('condition');
388
+ const ctx = makeContext({ inputs: { val: true } });
389
+ await assert.rejects(
390
+ executor(
391
+ { id: 'cond', type: 'condition', condition: '{{inputs.val}}', true: 'next' },
392
+ ctx,
393
+ {}
394
+ ),
395
+ (err) => {
396
+ assert.equal(err.name, 'ExecutorError');
397
+ assert.ok(err.message.includes('no "false" branch defined'));
398
+ return true;
399
+ }
400
+ );
401
+ });
402
+
403
+ it('router node throws when condition is missing', async () => {
404
+ const executor = getExecutor('router');
405
+ await assert.rejects(
406
+ executor(
407
+ { id: 'router', type: 'router', routes: { a: 'b' } },
408
+ makeContext(),
409
+ {}
410
+ ),
411
+ /has no "condition" field/
412
+ );
413
+ });
414
+
415
+ it('router node throws when no route matches and no default', async () => {
416
+ const executor = getExecutor('router');
417
+ const ctx = makeContext({ inputs: { profile: 'unknown' } });
418
+ await assert.rejects(
419
+ executor(
420
+ {
421
+ id: 'router',
422
+ type: 'router',
423
+ condition: '{{inputs.profile}}',
424
+ routes: { yolo: 'a', careful: 'b' }
425
+ },
426
+ ctx,
427
+ {}
428
+ ),
429
+ /could not match "unknown"/
430
+ );
431
+ });
432
+
433
+ it('sequence node throws on step failure without errorPolicy', async () => {
434
+ const executor = getExecutor('sequence');
435
+ const callbacks = {
436
+ onBash: async () => { throw new Error('command failed'); }
437
+ };
438
+ await assert.rejects(
439
+ executor(
440
+ {
441
+ id: 'seq',
442
+ type: 'sequence',
443
+ steps: [{ action: 'bash', command: 'fail', as: 'step1' }],
444
+ next: 'done'
445
+ },
446
+ makeContext(),
447
+ callbacks
448
+ ),
449
+ /Step "step1" failed/
450
+ );
451
+ });
452
+
453
+ it('interrupt node throws when no route matches selection', async () => {
454
+ const executor = getExecutor('interrupt');
455
+ const callbacks = {
456
+ onInterrupt: async () => 'invalid_option'
457
+ };
458
+ await assert.rejects(
459
+ executor(
460
+ {
461
+ id: 'interrupt',
462
+ type: 'interrupt',
463
+ routes: { continue: 'next', abort: 'end' },
464
+ display: { title: 'Choose', options: [] }
465
+ },
466
+ makeContext(),
467
+ callbacks
468
+ ),
469
+ /no matching route found/
470
+ );
471
+ });
472
+ });
473
+
474
+ describe('Execute Dispatcher: Edge Cases', () => {
475
+ it('handles sequence with no steps', async () => {
476
+ const executor = getExecutor('sequence');
477
+ const ctx = makeContext();
478
+ const result = await executor(
479
+ {
480
+ id: 'empty_seq',
481
+ type: 'sequence',
482
+ steps: [],
483
+ next: 'next'
484
+ },
485
+ ctx,
486
+ {}
487
+ );
488
+ assert.equal(result.next, 'next');
489
+ assert.deepEqual(result.outputs, {});
490
+ });
491
+
492
+ it('handles parallel with no branches', async () => {
493
+ const executor = getExecutor('parallel');
494
+ const callbacks = {
495
+ onParallel: async () => []
496
+ };
497
+ const ctx = makeContext();
498
+ const result = await executor(
499
+ {
500
+ id: 'empty_parallel',
501
+ type: 'parallel',
502
+ branches: [],
503
+ next: 'next'
504
+ },
505
+ ctx,
506
+ callbacks
507
+ );
508
+ assert.equal(result.next, 'next');
509
+ assert.deepEqual(result.outputs.branches, {});
510
+ });
511
+
512
+ it('handles sequence with optional step failure', async () => {
513
+ const executor = getExecutor('sequence');
514
+ const callbacks = {
515
+ onReadFiles: async () => { throw new Error('file not found'); }
516
+ };
517
+ const ctx = makeContext();
518
+ const result = await executor(
519
+ {
520
+ id: 'seq',
521
+ type: 'sequence',
522
+ steps: [
523
+ { action: 'read_files', files: ['missing.md'], optional: true, as: 'files' }
524
+ ],
525
+ next: 'done'
526
+ },
527
+ ctx,
528
+ callbacks
529
+ );
530
+ assert.equal(result.next, 'done');
531
+ assert.ok(ctx.steps.files.error);
532
+ });
533
+
534
+ it('handles sequence with errorPolicy=continue', async () => {
535
+ const executor = getExecutor('sequence');
536
+ const callbacks = {
537
+ onBash: async () => { throw new Error('command failed'); }
538
+ };
539
+ const ctx = makeContext();
540
+ const result = await executor(
541
+ {
542
+ id: 'seq',
543
+ type: 'sequence',
544
+ errorPolicy: 'continue',
545
+ steps: [
546
+ { action: 'bash', command: 'bad', as: 'bad_step' }
547
+ ],
548
+ next: 'done'
549
+ },
550
+ ctx,
551
+ callbacks
552
+ );
553
+ assert.equal(result.next, 'done');
554
+ assert.ok(ctx.steps.bad_step.error);
555
+ });
556
+
557
+ it('handles condition with non-wrapped template', async () => {
558
+ const executor = getExecutor('condition');
559
+ const ctx = makeContext({ inputs: { val: true } });
560
+ const result = await executor(
561
+ {
562
+ id: 'cond',
563
+ type: 'condition',
564
+ condition: 'inputs.val === true',
565
+ true: 'yes',
566
+ false: 'no'
567
+ },
568
+ ctx,
569
+ {}
570
+ );
571
+ assert.equal(result.next, 'yes');
572
+ });
573
+
574
+ it('handles router with string default route reference', async () => {
575
+ const executor = getExecutor('router');
576
+ const ctx = makeContext({ inputs: { profile: 'unknown' } });
577
+ const result = await executor(
578
+ {
579
+ id: 'router',
580
+ type: 'router',
581
+ condition: '{{inputs.profile}}',
582
+ routes: {
583
+ yolo: 'interview_yolo',
584
+ default: 'interview_default'
585
+ },
586
+ default: 'default'
587
+ },
588
+ ctx,
589
+ {}
590
+ );
591
+ assert.equal(result.next, 'interview_default');
592
+ });
593
+
594
+ it('handles exit with no status field', async () => {
595
+ const executor = getExecutor('exit');
596
+ const result = await executor(
597
+ { id: 'end', type: 'exit', outputs: {} },
598
+ makeContext(),
599
+ {}
600
+ );
601
+ assert.equal(result.status, 'completed');
602
+ });
603
+
604
+ it('handles agent without callback (skipped)', async () => {
605
+ const executor = getExecutor('agent');
606
+ const result = await executor(
607
+ {
608
+ id: 'agent',
609
+ type: 'agent',
610
+ agent: 'test-agent',
611
+ inputs: {}
612
+ },
613
+ makeContext(),
614
+ {}
615
+ );
616
+ assert.ok(result.outputs.output.skipped);
617
+ });
618
+
619
+ it('handles parallel without callback (skipped)', async () => {
620
+ const executor = getExecutor('parallel');
621
+ const ctx = makeContext();
622
+ const result = await executor(
623
+ {
624
+ id: 'parallel',
625
+ type: 'parallel',
626
+ branches: [{ id: 'a', agent: 'test', inputs: {} }],
627
+ next: 'next'
628
+ },
629
+ ctx,
630
+ {}
631
+ );
632
+ assert.equal(result.next, 'next');
633
+ assert.ok(result.outputs.branches.a.skipped);
634
+ });
635
+
636
+ it('handles sequence with unrecognized step action', async () => {
637
+ const executor = getExecutor('sequence');
638
+ const ctx = makeContext();
639
+ const result = await executor(
640
+ {
641
+ id: 'seq',
642
+ type: 'sequence',
643
+ steps: [
644
+ { action: 'learn_patterns', as: 'learn' }
645
+ ],
646
+ next: 'done'
647
+ },
648
+ ctx,
649
+ {}
650
+ );
651
+ assert.equal(result.next, 'done');
652
+ assert.ok(ctx.steps.learn.output.skipped);
653
+ });
654
+ });
655
+
656
+ describe('Execute Dispatcher: Integration', () => {
657
+ it('simulates full dispatch flow for auto-resolved nodes', async () => {
658
+ // entry → condition → exit
659
+ const entryExec = getExecutor('entry');
660
+ const condExec = getExecutor('condition');
661
+ const exitExec = getExecutor('exit');
662
+
663
+ const ctx = makeContext({ inputs: { success: true } });
664
+
665
+ const step1 = await entryExec(
666
+ { id: 'start', type: 'entry', next: 'check' },
667
+ ctx,
668
+ {}
669
+ );
670
+ assert.equal(step1.next, 'check');
671
+
672
+ const step2 = await condExec(
673
+ {
674
+ id: 'check',
675
+ type: 'condition',
676
+ condition: '{{inputs.success}}',
677
+ true: 'end_success',
678
+ false: 'end_failure'
679
+ },
680
+ ctx,
681
+ {}
682
+ );
683
+ assert.equal(step2.next, 'end_success');
684
+
685
+ const step3 = await exitExec(
686
+ {
687
+ id: 'end_success',
688
+ type: 'exit',
689
+ status: 'success',
690
+ outputs: { result: 'Task completed' }
691
+ },
692
+ ctx,
693
+ {}
694
+ );
695
+ assert.equal(step3.next, null);
696
+ assert.equal(step3.status, 'success');
697
+ });
698
+
699
+ it('simulates dispatch flow with sequence and agent', async () => {
700
+ const seqExec = getExecutor('sequence');
701
+ const agentExec = getExecutor('agent');
702
+
703
+ const callbacks = {
704
+ onBash: async () => ({ output: 'discovery complete', exitCode: 0 }),
705
+ onAgent: async () => ({ result: 'agent done' })
706
+ };
707
+
708
+ const ctx = makeContext();
709
+
710
+ // Execute sequence
711
+ const step1 = await seqExec(
712
+ {
713
+ id: 'skill_discovery',
714
+ type: 'sequence',
715
+ steps: [{ action: 'bash', command: 'hooks/skill-discovery.sh', as: 'discovery' }],
716
+ next: 'architect'
717
+ },
718
+ ctx,
719
+ callbacks
720
+ );
721
+ assert.equal(step1.next, 'architect');
722
+ assert.ok(ctx.steps.discovery);
723
+
724
+ // Execute agent
725
+ const step2 = await agentExec(
726
+ {
727
+ id: 'architect',
728
+ type: 'agent',
729
+ agent: 'elsabro-architect',
730
+ inputs: { task: '{{inputs.task}}' },
731
+ next: 'end'
732
+ },
733
+ ctx,
734
+ callbacks
735
+ );
736
+ assert.equal(step2.next, 'end');
737
+ });
738
+ });