elsabro 6.0.0 → 7.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.
- package/agents/elsabro-orchestrator.md +39 -0
- package/bin/install.js +8 -4
- package/commands/elsabro/add-phase.md +20 -0
- package/commands/elsabro/complete-milestone.md +20 -0
- package/commands/elsabro/design-ui.md +21 -0
- package/commands/elsabro/execute.md +132 -12
- package/commands/elsabro/new-milestone.md +23 -0
- package/commands/elsabro/party.md +80 -23
- package/commands/elsabro/quick.md +19 -2
- package/flow-engine/src/agent-cards.json +74 -0
- package/flow-engine/src/callbacks.js +268 -0
- package/flow-engine/src/cli.js +597 -0
- package/flow-engine/src/executors.js +14 -1
- package/flow-engine/src/index.js +4 -2
- package/flow-engine/src/party.js +414 -0
- package/flow-engine/src/runner.js +5 -5
- package/flow-engine/tests/callbacks.test.js +274 -0
- package/flow-engine/tests/cli.test.js +208 -0
- package/flow-engine/tests/executors-complex.test.js +61 -1
- package/flow-engine/tests/integration.test.js +667 -38
- package/flow-engine/tests/party.test.js +724 -0
- package/flows/development-flow.json +104 -120
- package/package.json +5 -3
- package/references/next-step-engine.md +19 -0
- package/references/state-sync.md +15 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { describe, it } = require('node:test');
|
|
4
4
|
const assert = require('node:assert/strict');
|
|
5
|
-
const { FlowEngine, NotImplementedError } = require('../src/index');
|
|
5
|
+
const { FlowEngine, NotImplementedError, DeprecatedNodeError } = require('../src/index');
|
|
6
6
|
const { resolveTemplate } = require('../src/template');
|
|
7
7
|
|
|
8
8
|
// Load the REAL flow
|
|
@@ -69,30 +69,31 @@ describe('Integration: Graph loading', () => {
|
|
|
69
69
|
engine.loadFlow(flow);
|
|
70
70
|
const meta = engine.getFlowMetadata();
|
|
71
71
|
assert.equal(meta.sync_metadata.audit_result.total_nodes, 42);
|
|
72
|
-
assert.equal(meta.sync_metadata.audit_result.implemented,
|
|
73
|
-
assert.equal(meta.sync_metadata.audit_result.partial,
|
|
74
|
-
assert.equal(meta.sync_metadata.audit_result.not_implemented,
|
|
72
|
+
assert.equal(meta.sync_metadata.audit_result.implemented, 40);
|
|
73
|
+
assert.equal(meta.sync_metadata.audit_result.partial, 0);
|
|
74
|
+
assert.equal(meta.sync_metadata.audit_result.not_implemented, 0);
|
|
75
|
+
assert.equal(meta.sync_metadata.audit_result.deprecated, 2);
|
|
75
76
|
});
|
|
76
77
|
|
|
77
78
|
it('counts implemented nodes correctly', () => {
|
|
78
79
|
const engine = new FlowEngine();
|
|
79
80
|
engine.loadFlow(flow);
|
|
80
81
|
const implemented = engine.getNodesWhere(n => n.runtime_status === 'implemented');
|
|
81
|
-
assert.equal(implemented.length,
|
|
82
|
+
assert.equal(implemented.length, 40);
|
|
82
83
|
});
|
|
83
84
|
|
|
84
85
|
it('counts not_implemented nodes correctly', () => {
|
|
85
86
|
const engine = new FlowEngine();
|
|
86
87
|
engine.loadFlow(flow);
|
|
87
88
|
const notImpl = engine.getNodesWhere(n => n.runtime_status === 'not_implemented');
|
|
88
|
-
assert.equal(notImpl.length,
|
|
89
|
+
assert.equal(notImpl.length, 0);
|
|
89
90
|
});
|
|
90
91
|
|
|
91
92
|
it('counts partial nodes correctly', () => {
|
|
92
93
|
const engine = new FlowEngine();
|
|
93
94
|
engine.loadFlow(flow);
|
|
94
95
|
const partial = engine.getNodesWhere(n => n.runtime_status === 'partial');
|
|
95
|
-
assert.equal(partial.length,
|
|
96
|
+
assert.equal(partial.length, 0);
|
|
96
97
|
});
|
|
97
98
|
|
|
98
99
|
it('validates graph has no orphaned references', () => {
|
|
@@ -147,73 +148,284 @@ describe('Integration: Template resolution with real flow data', () => {
|
|
|
147
148
|
});
|
|
148
149
|
});
|
|
149
150
|
|
|
150
|
-
describe('Integration: Default profile path (
|
|
151
|
-
it('traverses
|
|
151
|
+
describe('Integration: Default profile path (traverses past interview_default)', () => {
|
|
152
|
+
it('traverses interview_default and continues to standard_analyze', async () => {
|
|
152
153
|
const engine = new FlowEngine();
|
|
153
154
|
engine.loadFlow(flow);
|
|
154
|
-
const { log } = makeMockCallbacks();
|
|
155
155
|
const callbacks = makeMockCallbacks();
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
// Default path goes deep: interview_default → standard_analyze → merge_analysis → ...
|
|
158
|
+
// Eventually hits not_implemented nodes downstream or completes
|
|
159
|
+
// We just verify the interview node is traversed successfully
|
|
160
|
+
try {
|
|
161
|
+
await engine.run(
|
|
159
162
|
{ task: 'test integration', profile: 'default', complexity: 'medium' },
|
|
160
163
|
callbacks
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
168
|
-
);
|
|
164
|
+
);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// May stop at a downstream not_implemented node — that's OK
|
|
167
|
+
assert.ok(!err.message.includes('interview_default'), 'Should NOT stop at interview_default');
|
|
168
|
+
}
|
|
169
169
|
|
|
170
|
-
// Verify the traversal path via callbacks log
|
|
171
170
|
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
172
|
-
assert.
|
|
171
|
+
assert.ok(nodeStarts.includes('interview_default'), 'interview_default was visited');
|
|
172
|
+
assert.ok(nodeStarts.includes('standard_analyze'), 'standard_analyze was reached after interview');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('interview_default calls onAgent with correct inputs', async () => {
|
|
176
|
+
const engine = new FlowEngine();
|
|
177
|
+
engine.loadFlow(flow);
|
|
178
|
+
const callbacks = makeMockCallbacks();
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
await engine.run({ task: 'add auth', profile: 'default', complexity: 'medium' }, callbacks);
|
|
182
|
+
} catch { /* may stop downstream */ }
|
|
183
|
+
|
|
184
|
+
const agentCalls = callbacks.log.filter(e => e.type === 'agent' && e.id === 'interview_default');
|
|
185
|
+
assert.equal(agentCalls.length, 1, 'onAgent called once for interview_default');
|
|
186
|
+
assert.equal(agentCalls[0].agent, 'elsabro-analyst');
|
|
173
187
|
});
|
|
174
188
|
});
|
|
175
189
|
|
|
176
|
-
describe('Integration: Yolo profile path (
|
|
177
|
-
it('
|
|
190
|
+
describe('Integration: Yolo profile path (traverses past P2 nodes)', () => {
|
|
191
|
+
it('traverses yolo_execute and quick_verify after interview_yolo', async () => {
|
|
178
192
|
const engine = new FlowEngine();
|
|
179
193
|
engine.loadFlow(flow);
|
|
180
194
|
const callbacks = makeMockCallbacks();
|
|
181
195
|
|
|
182
|
-
|
|
183
|
-
engine.run(
|
|
196
|
+
try {
|
|
197
|
+
await engine.run(
|
|
184
198
|
{ task: 'quick fix', profile: 'yolo', complexity: 'low' },
|
|
185
199
|
callbacks
|
|
186
|
-
)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
200
|
+
);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
// May hit max traversals in review loop or end successfully — OK
|
|
203
|
+
assert.ok(!err.message.includes('yolo_execute'), 'Should NOT stop at yolo_execute');
|
|
204
|
+
assert.ok(!err.message.includes('quick_verify'), 'Should NOT stop at quick_verify');
|
|
205
|
+
}
|
|
192
206
|
|
|
193
207
|
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
194
|
-
assert.
|
|
208
|
+
assert.ok(nodeStarts.includes('interview_yolo'), 'interview_yolo was visited');
|
|
209
|
+
assert.ok(nodeStarts.includes('yolo_execute'), 'yolo_execute was visited');
|
|
210
|
+
assert.ok(nodeStarts.includes('quick_verify'), 'quick_verify was visited');
|
|
211
|
+
assert.ok(nodeStarts.includes('verify_check'), 'verify_check was reached after quick_verify');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('yolo_execute calls onAgent with elsabro-yolo-dev', async () => {
|
|
215
|
+
const engine = new FlowEngine();
|
|
216
|
+
engine.loadFlow(flow);
|
|
217
|
+
const callbacks = makeMockCallbacks();
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await engine.run({ task: 'quick fix', profile: 'yolo', complexity: 'low' }, callbacks);
|
|
221
|
+
} catch { /* may stop downstream */ }
|
|
222
|
+
|
|
223
|
+
const agentCalls = callbacks.log.filter(e => e.type === 'agent' && e.id === 'yolo_execute');
|
|
224
|
+
assert.equal(agentCalls.length, 1, 'onAgent called once for yolo_execute');
|
|
225
|
+
assert.equal(agentCalls[0].agent, 'elsabro-yolo-dev');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('quick_verify calls onAgent with elsabro-verifier', async () => {
|
|
229
|
+
const engine = new FlowEngine();
|
|
230
|
+
engine.loadFlow(flow);
|
|
231
|
+
const callbacks = makeMockCallbacks();
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await engine.run({ task: 'quick fix', profile: 'yolo', complexity: 'low' }, callbacks);
|
|
235
|
+
} catch { /* may stop downstream */ }
|
|
236
|
+
|
|
237
|
+
const agentCalls = callbacks.log.filter(e => e.type === 'agent' && e.id === 'quick_verify');
|
|
238
|
+
assert.equal(agentCalls.length, 1, 'onAgent called once for quick_verify');
|
|
239
|
+
assert.equal(agentCalls[0].agent, 'elsabro-verifier');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('interview_yolo uses haiku model config', () => {
|
|
243
|
+
const engine = new FlowEngine();
|
|
244
|
+
engine.loadFlow(flow);
|
|
245
|
+
const node = engine.getNode('interview_yolo');
|
|
246
|
+
assert.equal(node.config.model, 'haiku');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('Integration: BMAD profile path (traverses past bmad_interview_analysis)', () => {
|
|
251
|
+
it('traverses bmad_analyze sequence with both agent steps (Bug A fix)', async () => {
|
|
252
|
+
const engine = new FlowEngine();
|
|
253
|
+
engine.loadFlow(flow);
|
|
254
|
+
const node = engine.getNode('bmad_analyze');
|
|
255
|
+
assert.equal(node.type, 'sequence');
|
|
256
|
+
assert.equal(node.runtime_status, 'implemented');
|
|
257
|
+
assert.equal(node.steps.length, 2);
|
|
258
|
+
assert.equal(node.steps[0].action, 'agent', 'prd step has action: agent');
|
|
259
|
+
assert.equal(node.steps[1].action, 'agent', 'architecture step has action: agent');
|
|
260
|
+
assert.equal(node.steps[0].as, 'prd');
|
|
261
|
+
assert.equal(node.steps[1].as, 'architecture');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('bmad_interview_analysis uses analysis phase', async () => {
|
|
265
|
+
const engine = new FlowEngine();
|
|
266
|
+
engine.loadFlow(flow);
|
|
267
|
+
const node = engine.getNode('bmad_interview_analysis');
|
|
268
|
+
assert.equal(node.inputs.phase, 'analysis');
|
|
269
|
+
assert.ok(node.inputs.topics.includes('usuarios objetivo'));
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('Integration: Careful profile path (traverses past P2 nodes)', () => {
|
|
274
|
+
it('traverses careful_analyze and interrupt_plan_approval after interview_careful', async () => {
|
|
275
|
+
const engine = new FlowEngine();
|
|
276
|
+
engine.loadFlow(flow);
|
|
277
|
+
const callbacks = makeMockCallbacks();
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
await engine.run(
|
|
281
|
+
{ task: 'secure refactor', profile: 'careful', complexity: 'high' },
|
|
282
|
+
callbacks
|
|
283
|
+
);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// May stop downstream — OK
|
|
286
|
+
assert.ok(!err.message.includes('careful_analyze'), 'Should NOT stop at careful_analyze');
|
|
287
|
+
assert.ok(!err.message.includes('interrupt_plan_approval'), 'Should NOT stop at interrupt_plan_approval');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
291
|
+
assert.ok(nodeStarts.includes('interview_careful'), 'interview_careful was visited');
|
|
292
|
+
assert.ok(nodeStarts.includes('careful_analyze'), 'careful_analyze was visited');
|
|
293
|
+
assert.ok(nodeStarts.includes('interrupt_plan_approval'), 'interrupt_plan_approval was visited');
|
|
294
|
+
assert.ok(nodeStarts.includes('parallel_implementation'), 'parallel_implementation reached after approval');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('careful_analyze steps call onAgent for each step', async () => {
|
|
298
|
+
const engine = new FlowEngine();
|
|
299
|
+
engine.loadFlow(flow);
|
|
300
|
+
const stepAgentCalls = [];
|
|
301
|
+
const callbacks = {
|
|
302
|
+
...makeMockCallbacks(),
|
|
303
|
+
onAgent: async (config) => {
|
|
304
|
+
stepAgentCalls.push({ agent: config.agent, as: config.as, id: config.id });
|
|
305
|
+
return { analysis: 'mock analysis result' };
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
await engine.run({ task: 'secure refactor', profile: 'careful', complexity: 'high' }, callbacks);
|
|
311
|
+
} catch { /* may stop downstream */ }
|
|
312
|
+
|
|
313
|
+
// Sequence executor calls onAgent with { agent, model, inputs, as } (no id field)
|
|
314
|
+
const sequenceSteps = stepAgentCalls.filter(c => c.as && !c.id);
|
|
315
|
+
const stepNames = sequenceSteps.map(c => c.as);
|
|
316
|
+
assert.ok(stepNames.includes('requirements'), 'requirements step was executed');
|
|
317
|
+
assert.ok(stepNames.includes('risks'), 'risks step was executed');
|
|
318
|
+
assert.ok(stepNames.includes('detailed_plan'), 'detailed_plan step was executed');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('interview_careful uses careful topics', () => {
|
|
322
|
+
const engine = new FlowEngine();
|
|
323
|
+
engine.loadFlow(flow);
|
|
324
|
+
const node = engine.getNode('interview_careful');
|
|
325
|
+
assert.ok(node.inputs.topics.includes('requisitos funcionales'));
|
|
326
|
+
assert.ok(node.inputs.topics.includes('acceptance criteria'));
|
|
195
327
|
});
|
|
196
328
|
});
|
|
197
329
|
|
|
198
|
-
describe('Integration:
|
|
199
|
-
it('
|
|
330
|
+
describe('Integration: Teams profile path (traverses past interview_teams)', () => {
|
|
331
|
+
it('traverses interview_teams then stops at teams_spawn (deprecated)', async () => {
|
|
200
332
|
const engine = new FlowEngine();
|
|
201
333
|
engine.loadFlow(flow);
|
|
202
334
|
const callbacks = makeMockCallbacks();
|
|
203
335
|
|
|
204
336
|
await assert.rejects(
|
|
205
337
|
engine.run(
|
|
206
|
-
{ task: 'build
|
|
338
|
+
{ task: 'build dashboard', profile: 'teams', complexity: 'high' },
|
|
207
339
|
callbacks
|
|
208
340
|
),
|
|
209
341
|
(err) => {
|
|
210
|
-
assert.
|
|
342
|
+
assert.equal(err.name, 'DeprecatedNodeError', 'Should throw DeprecatedNodeError');
|
|
343
|
+
assert.ok(err.message.includes('teams_spawn'), 'Should stop at teams_spawn');
|
|
344
|
+
assert.ok(err.message.includes('deprecated'), 'Should mention deprecated');
|
|
211
345
|
return true;
|
|
212
346
|
}
|
|
213
347
|
);
|
|
214
348
|
|
|
215
349
|
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
216
|
-
assert.
|
|
350
|
+
assert.ok(nodeStarts.includes('interview_teams'), 'interview_teams was visited');
|
|
351
|
+
assert.ok(nodeStarts.includes('teams_spawn'), 'teams_spawn was reached after interview');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('interview_teams uses teams topics', async () => {
|
|
355
|
+
const engine = new FlowEngine();
|
|
356
|
+
engine.loadFlow(flow);
|
|
357
|
+
const node = engine.getNode('interview_teams');
|
|
358
|
+
assert.ok(node.inputs.topics.includes('prioridades del equipo'));
|
|
359
|
+
assert.ok(node.inputs.topics.includes('integraciones'));
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('Integration: Interview agent output storage', () => {
|
|
364
|
+
it('interview node output is stored at context.nodes[id].outputs.output', async () => {
|
|
365
|
+
const engine = new FlowEngine();
|
|
366
|
+
engine.loadFlow(flow);
|
|
367
|
+
const mockInterviewResult = { summary: 'User wants auth with JWT', decisions: ['use bcrypt'] };
|
|
368
|
+
let capturedContext = null;
|
|
369
|
+
|
|
370
|
+
const callbacks = {
|
|
371
|
+
...makeMockCallbacks(),
|
|
372
|
+
onAgent: async (config) => {
|
|
373
|
+
if (config.id === 'interview_yolo') {
|
|
374
|
+
return mockInterviewResult;
|
|
375
|
+
}
|
|
376
|
+
return { result: 'other agent' };
|
|
377
|
+
},
|
|
378
|
+
onCheckpoint: async (data) => {
|
|
379
|
+
// Capture context after interview_yolo completes
|
|
380
|
+
if (data.currentNode === 'interview_yolo') {
|
|
381
|
+
capturedContext = JSON.parse(JSON.stringify(data.context));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
await engine.run({ task: 'test output', profile: 'yolo', complexity: 'low' }, callbacks);
|
|
388
|
+
} catch { /* may stop at downstream not_implemented node */ }
|
|
389
|
+
|
|
390
|
+
assert.ok(capturedContext, 'Checkpoint captured after interview_yolo');
|
|
391
|
+
assert.deepEqual(
|
|
392
|
+
capturedContext.nodes.interview_yolo.outputs.output,
|
|
393
|
+
mockInterviewResult,
|
|
394
|
+
'Interview result stored at outputs.output'
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('all 5 interview nodes are marked as implemented', () => {
|
|
399
|
+
const engine = new FlowEngine();
|
|
400
|
+
engine.loadFlow(flow);
|
|
401
|
+
const interviewNodes = ['interview_default', 'interview_yolo', 'interview_careful', 'interview_teams', 'bmad_interview_analysis'];
|
|
402
|
+
for (const id of interviewNodes) {
|
|
403
|
+
const node = engine.getNode(id);
|
|
404
|
+
assert.equal(node.runtime_status, 'implemented', `${id} should be implemented`);
|
|
405
|
+
assert.equal(node.implemented_in, 'agents/elsabro-analyst.md#[INTERVIEW]', `${id} should reference analyst [INTERVIEW]`);
|
|
406
|
+
assert.deepEqual(node.gaps, [], `${id} should have no gaps`);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('all 5 interview nodes use elsabro-analyst agent', () => {
|
|
411
|
+
const engine = new FlowEngine();
|
|
412
|
+
engine.loadFlow(flow);
|
|
413
|
+
const interviewNodes = ['interview_default', 'interview_yolo', 'interview_careful', 'interview_teams', 'bmad_interview_analysis'];
|
|
414
|
+
for (const id of interviewNodes) {
|
|
415
|
+
const node = engine.getNode(id);
|
|
416
|
+
assert.equal(node.agent, 'elsabro-analyst', `${id} agent should be elsabro-analyst`);
|
|
417
|
+
assert.equal(node.inputs.command, '[INTERVIEW]', `${id} command should be [INTERVIEW]`);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('interview nodes do not have custom outputs (use executor default)', () => {
|
|
422
|
+
const engine = new FlowEngine();
|
|
423
|
+
engine.loadFlow(flow);
|
|
424
|
+
const interviewNodes = ['interview_default', 'interview_yolo', 'interview_careful', 'interview_teams', 'bmad_interview_analysis'];
|
|
425
|
+
for (const id of interviewNodes) {
|
|
426
|
+
const node = engine.getNode(id);
|
|
427
|
+
assert.equal(node.outputs, undefined, `${id} should not have custom outputs field`);
|
|
428
|
+
}
|
|
217
429
|
});
|
|
218
430
|
});
|
|
219
431
|
|
|
@@ -249,3 +461,420 @@ describe('Integration: Node type coverage', () => {
|
|
|
249
461
|
assert.equal(types.size, 9);
|
|
250
462
|
});
|
|
251
463
|
});
|
|
464
|
+
|
|
465
|
+
describe('Integration: careful_analyze output mapping (Bug 2 fix)', () => {
|
|
466
|
+
it('careful_analyze step outputs are accessible at node outputs via outputs field', async () => {
|
|
467
|
+
const engine = new FlowEngine();
|
|
468
|
+
engine.loadFlow(flow);
|
|
469
|
+
let capturedContext = null;
|
|
470
|
+
|
|
471
|
+
const callbacks = {
|
|
472
|
+
...makeMockCallbacks(),
|
|
473
|
+
onAgent: async (config) => {
|
|
474
|
+
if (config.as === 'requirements') return { detail: 'requirements analysis' };
|
|
475
|
+
if (config.as === 'risks') return { detail: 'risk analysis' };
|
|
476
|
+
if (config.as === 'detailed_plan') return { detail: 'implementation plan' };
|
|
477
|
+
return { result: 'other agent' };
|
|
478
|
+
},
|
|
479
|
+
onCheckpoint: async (data) => {
|
|
480
|
+
if (data.currentNode === 'careful_analyze') {
|
|
481
|
+
capturedContext = JSON.parse(JSON.stringify(data.context));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
await engine.run({ task: 'test outputs', profile: 'careful', complexity: 'high' }, callbacks);
|
|
488
|
+
} catch { /* may stop downstream */ }
|
|
489
|
+
|
|
490
|
+
assert.ok(capturedContext, 'Checkpoint captured after careful_analyze');
|
|
491
|
+
assert.ok(capturedContext.nodes.careful_analyze.outputs.requirements, 'requirements output exists');
|
|
492
|
+
assert.ok(capturedContext.nodes.careful_analyze.outputs.risks, 'risks output exists');
|
|
493
|
+
assert.ok(capturedContext.nodes.careful_analyze.outputs.detailed_plan, 'detailed_plan output exists');
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
describe('Integration: interrupt_plan_approval routing', () => {
|
|
498
|
+
it('routes to parallel_implementation on approve', async () => {
|
|
499
|
+
const engine = new FlowEngine();
|
|
500
|
+
engine.loadFlow(flow);
|
|
501
|
+
const callbacks = {
|
|
502
|
+
...makeMockCallbacks(),
|
|
503
|
+
onInterrupt: async (display) => {
|
|
504
|
+
if (display.nodeId === 'interrupt_plan_approval') return 'approve';
|
|
505
|
+
const options = display.display.options || [];
|
|
506
|
+
return options.length > 0 ? options[0].id : 'approve';
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
await engine.run({ task: 'test approval', profile: 'careful', complexity: 'high' }, callbacks);
|
|
512
|
+
} catch { /* may stop downstream */ }
|
|
513
|
+
|
|
514
|
+
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
515
|
+
assert.ok(nodeStarts.includes('parallel_implementation'), 'approve routes to parallel_implementation');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('routes to careful_analyze on modify (loop)', async () => {
|
|
519
|
+
const engine = new FlowEngine();
|
|
520
|
+
engine.loadFlow(flow);
|
|
521
|
+
let interruptCount = 0;
|
|
522
|
+
const callbacks = {
|
|
523
|
+
...makeMockCallbacks(),
|
|
524
|
+
onInterrupt: async (display) => {
|
|
525
|
+
if (display.nodeId === 'interrupt_plan_approval') {
|
|
526
|
+
interruptCount++;
|
|
527
|
+
return interruptCount === 1 ? 'modify' : 'approve';
|
|
528
|
+
}
|
|
529
|
+
const options = display.display.options || [];
|
|
530
|
+
return options.length > 0 ? options[0].id : 'approve';
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
await engine.run({ task: 'test modify loop', profile: 'careful', complexity: 'high' }, callbacks);
|
|
536
|
+
} catch { /* may stop downstream */ }
|
|
537
|
+
|
|
538
|
+
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
539
|
+
const carefulCount = nodeStarts.filter(id => id === 'careful_analyze').length;
|
|
540
|
+
assert.ok(carefulCount >= 2, `careful_analyze visited ${carefulCount} times (expected >= 2)`);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('routes to end_cancelled on reject', async () => {
|
|
544
|
+
const engine = new FlowEngine();
|
|
545
|
+
engine.loadFlow(flow);
|
|
546
|
+
const callbacks = {
|
|
547
|
+
...makeMockCallbacks(),
|
|
548
|
+
onInterrupt: async (display) => {
|
|
549
|
+
if (display.nodeId === 'interrupt_plan_approval') return 'reject';
|
|
550
|
+
const options = display.display.options || [];
|
|
551
|
+
return options.length > 0 ? options[0].id : 'approve';
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const result = await engine.run(
|
|
556
|
+
{ task: 'test reject', profile: 'careful', complexity: 'high' },
|
|
557
|
+
callbacks
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
561
|
+
assert.ok(nodeStarts.includes('end_cancelled'), 'reject routes to end_cancelled');
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe('Integration: Partial nodes upgraded to implemented (P2)', () => {
|
|
566
|
+
it('profile_router is now implemented', () => {
|
|
567
|
+
const engine = new FlowEngine();
|
|
568
|
+
engine.loadFlow(flow);
|
|
569
|
+
const node = engine.getNode('profile_router');
|
|
570
|
+
assert.equal(node.runtime_status, 'implemented');
|
|
571
|
+
assert.deepEqual(node.gaps, []);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('interrupt_manual_fix is now implemented', () => {
|
|
575
|
+
const engine = new FlowEngine();
|
|
576
|
+
engine.loadFlow(flow);
|
|
577
|
+
const node = engine.getNode('interrupt_manual_fix');
|
|
578
|
+
assert.equal(node.runtime_status, 'implemented');
|
|
579
|
+
assert.deepEqual(node.gaps, []);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('wait_manual_fix is now implemented', () => {
|
|
583
|
+
const engine = new FlowEngine();
|
|
584
|
+
engine.loadFlow(flow);
|
|
585
|
+
const node = engine.getNode('wait_manual_fix');
|
|
586
|
+
assert.equal(node.runtime_status, 'implemented');
|
|
587
|
+
assert.deepEqual(node.gaps, []);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('no partial nodes remain in the flow', () => {
|
|
591
|
+
const engine = new FlowEngine();
|
|
592
|
+
engine.loadFlow(flow);
|
|
593
|
+
const partial = engine.getNodesWhere(n => n.runtime_status === 'partial');
|
|
594
|
+
assert.equal(partial.length, 0, 'All partial nodes should be upgraded');
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
describe('Integration: P2 node metadata', () => {
|
|
599
|
+
it('all 4 new implemented nodes have correct metadata', () => {
|
|
600
|
+
const engine = new FlowEngine();
|
|
601
|
+
engine.loadFlow(flow);
|
|
602
|
+
const p2Nodes = ['yolo_execute', 'quick_verify', 'careful_analyze', 'interrupt_plan_approval'];
|
|
603
|
+
for (const id of p2Nodes) {
|
|
604
|
+
const node = engine.getNode(id);
|
|
605
|
+
assert.equal(node.runtime_status, 'implemented', `${id} should be implemented`);
|
|
606
|
+
assert.ok(node.implemented_in, `${id} should have implemented_in`);
|
|
607
|
+
assert.deepEqual(node.gaps, [], `${id} should have no gaps`);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('careful_analyze steps all have action: agent (Bug 1 fix)', () => {
|
|
612
|
+
const engine = new FlowEngine();
|
|
613
|
+
engine.loadFlow(flow);
|
|
614
|
+
const node = engine.getNode('careful_analyze');
|
|
615
|
+
for (const step of node.steps) {
|
|
616
|
+
assert.equal(step.action, 'agent', `Step "${step.as}" should have action: "agent"`);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('careful_analyze has outputs mapping (Bug 2 fix)', () => {
|
|
621
|
+
const engine = new FlowEngine();
|
|
622
|
+
engine.loadFlow(flow);
|
|
623
|
+
const node = engine.getNode('careful_analyze');
|
|
624
|
+
assert.ok(node.outputs, 'careful_analyze should have outputs field');
|
|
625
|
+
assert.ok(node.outputs.requirements, 'should have requirements output mapping');
|
|
626
|
+
assert.ok(node.outputs.risks, 'should have risks output mapping');
|
|
627
|
+
assert.ok(node.outputs.detailed_plan, 'should have detailed_plan output mapping');
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('verify_check condition uses .output.passed path (Bug 3 fix)', () => {
|
|
631
|
+
const engine = new FlowEngine();
|
|
632
|
+
engine.loadFlow(flow);
|
|
633
|
+
const node = engine.getNode('verify_check');
|
|
634
|
+
assert.ok(node.condition.includes('.output.passed'), 'condition should use .output.passed path');
|
|
635
|
+
assert.ok(!node.condition.match(/outputs\.passed[^.]/), 'condition should NOT use .outputs.passed (without .output)');
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// ==========================================================
|
|
640
|
+
// P4: BMAD Full Path Integration Tests
|
|
641
|
+
// ==========================================================
|
|
642
|
+
|
|
643
|
+
describe('Integration: BMAD Full Path (P4 — 8 nodes implemented)', () => {
|
|
644
|
+
it('all 8 BMAD nodes are marked implemented', () => {
|
|
645
|
+
const engine = new FlowEngine();
|
|
646
|
+
engine.loadFlow(flow);
|
|
647
|
+
const bmadNodes = [
|
|
648
|
+
'bmad_analyze', 'interrupt_prd_approval', 'bmad_interview_planning',
|
|
649
|
+
'bmad_plan', 'interrupt_manifest', 'bmad_interview_solution',
|
|
650
|
+
'bmad_solution', 'interrupt_solution_approval'
|
|
651
|
+
];
|
|
652
|
+
for (const id of bmadNodes) {
|
|
653
|
+
const node = engine.getNode(id);
|
|
654
|
+
assert.equal(node.runtime_status, 'implemented', `${id} should be implemented`);
|
|
655
|
+
assert.deepStrictEqual(node.gaps, [], `${id} should have no gaps`);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('bmad_interview_planning has no custom outputs field (Bug B fix)', () => {
|
|
660
|
+
const engine = new FlowEngine();
|
|
661
|
+
engine.loadFlow(flow);
|
|
662
|
+
const node = engine.getNode('bmad_interview_planning');
|
|
663
|
+
assert.equal(node.outputs, undefined, 'should NOT have custom outputs field');
|
|
664
|
+
assert.equal(node.type, 'agent');
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('bmad_interview_solution has no custom outputs field (Bug D fix)', () => {
|
|
668
|
+
const engine = new FlowEngine();
|
|
669
|
+
engine.loadFlow(flow);
|
|
670
|
+
const node = engine.getNode('bmad_interview_solution');
|
|
671
|
+
assert.equal(node.outputs, undefined, 'should NOT have custom outputs field');
|
|
672
|
+
assert.equal(node.type, 'agent');
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('bmad_solution has no custom outputs field (Bug E fix)', () => {
|
|
676
|
+
const engine = new FlowEngine();
|
|
677
|
+
engine.loadFlow(flow);
|
|
678
|
+
const node = engine.getNode('bmad_solution');
|
|
679
|
+
assert.equal(node.outputs, undefined, 'should NOT have custom outputs field');
|
|
680
|
+
assert.equal(node.type, 'agent');
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('bmad_plan step has action: agent (Bug C fix)', () => {
|
|
684
|
+
const engine = new FlowEngine();
|
|
685
|
+
engine.loadFlow(flow);
|
|
686
|
+
const node = engine.getNode('bmad_plan');
|
|
687
|
+
assert.equal(node.steps[0].action, 'agent', 'stories step should have action: agent');
|
|
688
|
+
assert.equal(node.steps[0].as, 'stories');
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('bmad_plan references .outputs.output for interview context (F1 fix)', () => {
|
|
692
|
+
const engine = new FlowEngine();
|
|
693
|
+
engine.loadFlow(flow);
|
|
694
|
+
const node = engine.getNode('bmad_plan');
|
|
695
|
+
const interviewRef = node.steps[0].inputs.interview_context;
|
|
696
|
+
assert.ok(interviewRef.includes('.outputs.output'), 'should reference .outputs.output');
|
|
697
|
+
assert.ok(!interviewRef.includes('.outputs.interview_summary'), 'should NOT reference .outputs.interview_summary');
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('bmad_solution references .outputs.output for interview context (F2 fix)', () => {
|
|
701
|
+
const engine = new FlowEngine();
|
|
702
|
+
engine.loadFlow(flow);
|
|
703
|
+
const node = engine.getNode('bmad_solution');
|
|
704
|
+
const interviewRef = node.inputs.interview_context;
|
|
705
|
+
assert.ok(interviewRef.includes('.outputs.output'), 'should reference .outputs.output');
|
|
706
|
+
assert.ok(!interviewRef.includes('.outputs.interview_summary'), 'should NOT reference .outputs.interview_summary');
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('parallel_implementation references .outputs.output for bmad_solution (F3/F4 fix)', () => {
|
|
710
|
+
const engine = new FlowEngine();
|
|
711
|
+
engine.loadFlow(flow);
|
|
712
|
+
const node = engine.getNode('parallel_implementation');
|
|
713
|
+
const branchInputs = JSON.stringify(node.branches);
|
|
714
|
+
assert.ok(branchInputs.includes('bmad_solution.outputs.output'), 'should reference .outputs.output');
|
|
715
|
+
assert.ok(!branchInputs.includes('bmad_solution.outputs.solution'), 'should NOT reference .outputs.solution');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('interrupt_prd_approval routes correctly: approve/modify/reject', () => {
|
|
719
|
+
const engine = new FlowEngine();
|
|
720
|
+
engine.loadFlow(flow);
|
|
721
|
+
const node = engine.getNode('interrupt_prd_approval');
|
|
722
|
+
assert.equal(node.routes.approve, 'bmad_interview_planning');
|
|
723
|
+
assert.equal(node.routes.modify, 'bmad_analyze');
|
|
724
|
+
assert.equal(node.routes.reject, 'end_cancelled');
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it('interrupt_solution_approval routes correctly: approve/modify/reject', () => {
|
|
728
|
+
const engine = new FlowEngine();
|
|
729
|
+
engine.loadFlow(flow);
|
|
730
|
+
const node = engine.getNode('interrupt_solution_approval');
|
|
731
|
+
assert.equal(node.routes.approve, 'parallel_implementation');
|
|
732
|
+
assert.equal(node.routes.modify, 'bmad_solution');
|
|
733
|
+
assert.equal(node.routes.reject, 'end_cancelled');
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('BMAD path traverses all 8 nodes and reaches shared pipeline', async () => {
|
|
737
|
+
const engine = new FlowEngine();
|
|
738
|
+
engine.loadFlow(flow);
|
|
739
|
+
const callbacks = makeMockCallbacks();
|
|
740
|
+
|
|
741
|
+
// BMAD flow will loop in review cycle (mock callbacks don't satisfy conditions)
|
|
742
|
+
// or succeed through post_mortem → end_success. We verify BMAD nodes were visited.
|
|
743
|
+
try {
|
|
744
|
+
await engine.run(
|
|
745
|
+
{ task: 'build saas app', profile: 'bmad', complexity: 'high' },
|
|
746
|
+
callbacks
|
|
747
|
+
);
|
|
748
|
+
} catch (err) {
|
|
749
|
+
// Expected: max traversals in review loop (mock callbacks cause infinite loop)
|
|
750
|
+
assert.ok(
|
|
751
|
+
err.message.includes('maximum node traversals'),
|
|
752
|
+
`Expected max traversals, got: ${err.message}`
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
757
|
+
|
|
758
|
+
// Verify all 8 BMAD nodes were traversed
|
|
759
|
+
const bmadNodes = [
|
|
760
|
+
'bmad_interview_analysis', 'bmad_analyze', 'interrupt_prd_approval',
|
|
761
|
+
'bmad_interview_planning', 'bmad_plan', 'interrupt_manifest',
|
|
762
|
+
'bmad_interview_solution', 'bmad_solution', 'interrupt_solution_approval'
|
|
763
|
+
];
|
|
764
|
+
for (const id of bmadNodes) {
|
|
765
|
+
assert.ok(nodeStarts.includes(id), `visited ${id}`);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Verify it joined the shared pipeline
|
|
769
|
+
assert.ok(nodeStarts.includes('parallel_implementation'), 'reached parallel_implementation');
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('only 3 nodes remain not_implemented after P4 (now 0 after P5)', () => {
|
|
773
|
+
const engine = new FlowEngine();
|
|
774
|
+
engine.loadFlow(flow);
|
|
775
|
+
const notImpl = engine.getNodesWhere(n => n.runtime_status === 'not_implemented');
|
|
776
|
+
assert.equal(notImpl.length, 0, 'P5 resolved all not_implemented nodes');
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// ============================================================
|
|
781
|
+
// P5: Cleanup & Deprecation (3 nodes: deprecate 2, implement 1)
|
|
782
|
+
// ============================================================
|
|
783
|
+
|
|
784
|
+
describe('Integration: P5 Cleanup & Deprecation', () => {
|
|
785
|
+
it('teams_spawn has runtime_status: deprecated', () => {
|
|
786
|
+
const engine = new FlowEngine();
|
|
787
|
+
engine.loadFlow(flow);
|
|
788
|
+
const node = engine.getNode('teams_spawn');
|
|
789
|
+
assert.equal(node.runtime_status, 'deprecated');
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('teams_spawn has deprecated_reason field', () => {
|
|
793
|
+
const engine = new FlowEngine();
|
|
794
|
+
engine.loadFlow(flow);
|
|
795
|
+
const node = engine.getNode('teams_spawn');
|
|
796
|
+
assert.ok(node.deprecated_reason, 'should have deprecated_reason');
|
|
797
|
+
assert.ok(node.deprecated_reason.includes('IMPERATIVO_AGENT_TEAMS'), 'should mention replacement');
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('interrupt_teams_failed has runtime_status: deprecated', () => {
|
|
801
|
+
const engine = new FlowEngine();
|
|
802
|
+
engine.loadFlow(flow);
|
|
803
|
+
const node = engine.getNode('interrupt_teams_failed');
|
|
804
|
+
assert.equal(node.runtime_status, 'deprecated');
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('interrupt_teams_failed has deprecated_reason field', () => {
|
|
808
|
+
const engine = new FlowEngine();
|
|
809
|
+
engine.loadFlow(flow);
|
|
810
|
+
const node = engine.getNode('interrupt_teams_failed');
|
|
811
|
+
assert.ok(node.deprecated_reason, 'should have deprecated_reason');
|
|
812
|
+
assert.ok(node.deprecated_reason.includes('teams_spawn'), 'should reference teams_spawn');
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('post_mortem has runtime_status: implemented', () => {
|
|
816
|
+
const engine = new FlowEngine();
|
|
817
|
+
engine.loadFlow(flow);
|
|
818
|
+
const node = engine.getNode('post_mortem');
|
|
819
|
+
assert.equal(node.runtime_status, 'implemented');
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('post_mortem step uses agent action with elsabro-tech-writer', () => {
|
|
823
|
+
const engine = new FlowEngine();
|
|
824
|
+
engine.loadFlow(flow);
|
|
825
|
+
const node = engine.getNode('post_mortem');
|
|
826
|
+
assert.equal(node.steps.length, 1, 'should have 1 step');
|
|
827
|
+
assert.equal(node.steps[0].action, 'agent', 'step action should be agent');
|
|
828
|
+
assert.equal(node.steps[0].agent, 'elsabro-tech-writer', 'agent should be tech-writer');
|
|
829
|
+
assert.equal(node.steps[0].as, 'learnings', 'step should store as learnings');
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('post_mortem routes to end_success', () => {
|
|
833
|
+
const engine = new FlowEngine();
|
|
834
|
+
engine.loadFlow(flow);
|
|
835
|
+
const node = engine.getNode('post_mortem');
|
|
836
|
+
assert.equal(node.next, 'end_success');
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it('DeprecatedNodeError is thrown for deprecated nodes', async () => {
|
|
840
|
+
const engine = new FlowEngine();
|
|
841
|
+
engine.loadFlow(flow);
|
|
842
|
+
const callbacks = makeMockCallbacks();
|
|
843
|
+
|
|
844
|
+
await assert.rejects(
|
|
845
|
+
engine.run(
|
|
846
|
+
{ task: 'build dashboard', profile: 'teams', complexity: 'high' },
|
|
847
|
+
callbacks
|
|
848
|
+
),
|
|
849
|
+
(err) => {
|
|
850
|
+
assert.equal(err.name, 'DeprecatedNodeError');
|
|
851
|
+
assert.equal(err.nodeId, 'teams_spawn');
|
|
852
|
+
assert.ok(err.reason.includes('IMPERATIVO_AGENT_TEAMS'));
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('0 not_implemented nodes remain after P5', () => {
|
|
859
|
+
const engine = new FlowEngine();
|
|
860
|
+
engine.loadFlow(flow);
|
|
861
|
+
const notImpl = engine.getNodesWhere(n => n.runtime_status === 'not_implemented');
|
|
862
|
+
assert.equal(notImpl.length, 0);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it('exactly 2 deprecated nodes exist', () => {
|
|
866
|
+
const engine = new FlowEngine();
|
|
867
|
+
engine.loadFlow(flow);
|
|
868
|
+
const deprecated = engine.getNodesWhere(n => n.runtime_status === 'deprecated');
|
|
869
|
+
const ids = deprecated.map(n => n.id).sort();
|
|
870
|
+
assert.equal(deprecated.length, 2);
|
|
871
|
+
assert.deepStrictEqual(ids, ['interrupt_teams_failed', 'teams_spawn']);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('40 implemented nodes exist', () => {
|
|
875
|
+
const engine = new FlowEngine();
|
|
876
|
+
engine.loadFlow(flow);
|
|
877
|
+
const implemented = engine.getNodesWhere(n => n.runtime_status === 'implemented');
|
|
878
|
+
assert.equal(implemented.length, 40);
|
|
879
|
+
});
|
|
880
|
+
});
|