| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878 |
- #!/usr/bin/env node
- /**
- * VL Workflow Engine — Test Suite
- */
- const { Engine, ExpressionEvaluator, ExecutionContext, Registry,
- parseParamDeclaration, parseVariableDeclaration, parseServiceSignature } = require('../index');
- const fs = require('fs');
- const path = require('path');
- let passed = 0, failed = 0;
- function test(name, fn) {
- try {
- fn();
- console.log(` ✓ ${name}`);
- passed++;
- } catch (e) {
- console.log(` ✗ ${name}: ${e.message}`);
- failed++;
- }
- }
- function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
- function assertEqual(a, b, msg) { if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`); }
- // ═══════════════════════════════════════════════════════════════
- console.log('\n── Expression Evaluator ──');
- test('literal string', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', params: {}, variables: {} });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('hello'), 'hello');
- });
- test('escape ==', () => {
- const ctx = new ExecutionContext({ workflowID: 'test' });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('==foo'), '=foo');
- });
- test('variable reference', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', params: { name: 'Dragon' } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('=name'), 'Dragon');
- });
- test('$var reference', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$count': 42 } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('=$count'), 42);
- });
- test('nested property', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$obj': { a: { b: 3 } } } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('=$obj.a.b'), 3);
- });
- test('array index', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$arr': [10, 20, 30] } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('=$arr[1]'), 20);
- });
- test('.length', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$arr': [1, 2, 3] } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('=$arr.length'), 3);
- });
- test('arithmetic', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 10 } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('=x + 5'), 15);
- });
- test('string concatenation', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', params: { name: 'VL' } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('="Hello " + name'), 'Hello VL');
- });
- test('comparison ==', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 5 } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('=x == 5'), true);
- });
- test('ternary', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', params: { x: true } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('=x ? "yes" : "no"'), 'yes');
- });
- test('logical AND/OR', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', params: { a: true, b: false } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('=a && b'), false);
- assertEqual(ev.evaluateValue('=a || b'), true);
- });
- test('NOT', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', params: { a: false } });
- const ev = new ExpressionEvaluator(ctx);
- assertEqual(ev.evaluateValue('=!a'), true);
- });
- test('setVariable deep path', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$data': {} } });
- const ev = new ExpressionEvaluator(ctx);
- ev.setVariable('$data.name', 'test');
- assertEqual(ctx.variables['$data'].name, 'test');
- });
- test('evaluateDeep', () => {
- const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 10 } });
- const ev = new ExpressionEvaluator(ctx);
- const result = ev.evaluateDeep({ a: '=x', b: 'literal', c: [1, '=x'] });
- assertEqual(result.a, 10);
- assertEqual(result.b, 'literal');
- assertEqual(result.c[1], 10);
- });
- // ═══════════════════════════════════════════════════════════════
- console.log('\n── Registry ──');
- test('parseParamDeclaration', () => {
- const d = parseParamDeclaration('userId(STRING)');
- assertEqual(d.name, 'userId');
- assertEqual(d.type, 'STRING');
- });
- test('parseParamDeclaration with default', () => {
- const d = parseParamDeclaration('maxRetries(INT) = 3');
- assertEqual(d.name, 'maxRetries');
- assertEqual(d.default, 3);
- });
- test('parseVariableDeclaration', () => {
- const d = parseVariableDeclaration('$items([OBJECT])');
- assertEqual(d.name, '$items');
- assertEqual(d.type, '[OBJECT]');
- });
- test('parseServiceSignature', () => {
- const s = parseServiceSignature('PlannerService(prd(STRING)) RETURN plan(OBJECT)');
- assertEqual(s.name, 'PlannerService');
- assertEqual(s.parameters.length, 1);
- assertEqual(s.returns.length, 1);
- });
- test('Registry lookup', () => {
- const reg = new Registry({
- docs: { '1': 'VL Syntax' },
- apis: [{ id: 'api1', method: 'GET', url: '/test' }],
- schemas: { 'S1': { type: 'object' } }
- });
- assert(reg.hasDoc('1'));
- assert(reg.hasAPI('api1'));
- assert(reg.hasSchema('S1'));
- assertEqual(reg.getDocDescription('1'), 'VL Syntax');
- });
- // ═══════════════════════════════════════════════════════════════
- console.log('\n── Engine ──');
- test('validate meta-direct workflow', () => {
- const wfPath = path.join(__dirname, '../../VLClaw/workflows/meta-direct.json');
- if (!fs.existsSync(wfPath)) { console.log(' (skipped: workflow file not found)'); return; }
- const wf = JSON.parse(fs.readFileSync(wfPath, 'utf8'));
- const engine = new Engine(wf);
- const errors = engine.validate();
- assert(errors.length === 0, `Validation errors: ${errors.join('; ')}`);
- });
- test('validate 6file-codegen workflow', () => {
- const wfPath = path.join(__dirname, '../../VLClaw/workflows/6file-codegen.json');
- if (!fs.existsSync(wfPath)) { console.log(' (skipped: workflow file not found)'); return; }
- const wf = JSON.parse(fs.readFileSync(wfPath, 'utf8'));
- const engine = new Engine(wf);
- const errors = engine.validate();
- assert(errors.length === 0, `Validation errors: ${errors.join('; ')}`);
- });
- test('find entry nodes', () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: [] },
- steps: [
- { id: 'Set_001', target: '$x', value: '=1', next: 'Set_002' },
- { id: 'Set_002', target: '$y', value: '=2', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- const entries = engine._findEntryNodeIDs();
- assertEqual(entries.length, 1);
- assertEqual(entries[0], 'Set_001');
- });
- test('execute simple Set → Stop workflow', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: ['x(INT)'], vars: ['$result(INT)'] },
- steps: [
- { id: 'Set_001', target: '$result', value: '=x + 10', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({ x: 5 });
- assertEqual(ctx.variables['$result'], 15);
- });
- test('execute Branch workflow', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: ['mode(STRING)'], vars: ['$out(STRING)'] },
- steps: [
- { id: 'Branch_001', cases: [['=mode == "a"', 'Set_A'], ['ELSE', 'Set_B']], next: 'Stop_End' },
- { id: 'Set_A', target: '$out', value: '="chose A"', next: 'RETURN' },
- { id: 'Set_B', target: '$out', value: '="chose B"', next: 'RETURN' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({ mode: 'a' });
- assertEqual(ctx.variables['$out'], 'chose A');
- });
- test('execute Check_* (condition/if_true/if_false)', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: ['hasErrors(BOOL)'], vars: ['$out(STRING)'] },
- steps: [
- { id: 'Check_NeedRepair', condition: '=hasErrors', if_true: 'Set_Repair', if_false: 'Set_OK', next: 'Stop_End' },
- { id: 'Set_Repair', target: '$out', value: '="repair"', next: 'RETURN' },
- { id: 'Set_OK', target: '$out', value: '="ok"', next: 'RETURN' },
- { id: 'Stop_End' }
- ]
- });
- const ctx1 = await engine.execute({ hasErrors: true });
- assertEqual(ctx1.variables['$out'], 'repair');
- const ctx2 = await engine.execute({ hasErrors: false });
- assertEqual(ctx2.variables['$out'], 'ok');
- });
- test('execute Loop workflow (serial)', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$items([STRING])', '$total(INT)'] },
- steps: [
- { id: 'Set_Init', target: '$items', value: '=["a","b","c"]', next: 'Set_Total' },
- { id: 'Set_Total', target: '$total', value: '=0', next: 'Loop_001' },
- { id: 'Loop_001', source: '$items', mode: 'serial', children: ['Set_Inc'], next: 'Stop_End' },
- { id: 'Set_Inc', target: '$total', value: '=$total + 1' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({});
- assertEqual(ctx.variables['$total'], 3);
- });
- test('execute parallel children', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
- steps: [
- { id: 'Noop_Fork', children: ['Set_A', 'Set_B'], next: 'Stop_End' },
- { id: 'Set_A', target: '$a', value: '=1' },
- { id: 'Set_B', target: '$b', value: '=2' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({});
- assertEqual(ctx.variables['$a'], 1);
- assertEqual(ctx.variables['$b'], 2);
- });
- test('event emission', async () => {
- const events = [];
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$x(INT)'] },
- steps: [
- { id: 'Set_001', target: '$x', value: '=42', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- // Patch to capture events
- const origExecute = engine.execute.bind(engine);
- const ctx = await engine.execute({});
- // Events were emitted on ctx
- // Let's test with listener
- const events2 = [];
- const engine2 = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$x(INT)'] },
- steps: [
- { id: 'Set_001', target: '$x', value: '=42', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- // We need to hook before execute... redesign needed
- // For now just verify it doesn't crash
- assert(ctx.status === 'stopped');
- });
- test('conditional skip (step.if)', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: ['skip(BOOL)'], vars: ['$x(INT)'] },
- steps: [
- { id: 'Set_001', if: '=!skip', target: '$x', value: '=99', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({ skip: true });
- assertEqual(ctx.variables['$x'], null); // Skipped, stays null
- });
- // ═══════════════════════════════════════════════════════════════
- console.log('\n── Checkpoint & ExecuteFrom ──');
- test('checkpoint produces serializable JSON', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: ['x(INT)'], vars: ['$result(INT)'] },
- steps: [
- { id: 'Set_001', target: '$result', value: '=x + 10', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({ x: 5 });
- const cp = ctx.checkpoint();
- assert(cp._type === 'vl_workflow_checkpoint', 'checkpoint type');
- assert(cp._version === 1, 'checkpoint version');
- assertEqual(cp.params.x, 5);
- assertEqual(cp.variables['$result'], 15);
- assert(cp.completedSteps.includes('Set_001'), 'Set_001 in completedSteps');
- // Should survive JSON round-trip
- const cp2 = JSON.parse(JSON.stringify(cp));
- assertEqual(cp2.variables['$result'], 15);
- });
- test('onCheckpoint callback fires after each step', async () => {
- const checkpoints = [];
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
- steps: [
- { id: 'Set_A', target: '$a', value: '=1', next: 'Set_B' },
- { id: 'Set_B', target: '$b', value: '=2', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- }, { onCheckpoint: cp => checkpoints.push(cp) });
- await engine.execute({});
- assertEqual(checkpoints.length, 2); // Set_A and Set_B (Stop doesn't emit)
- assertEqual(checkpoints[0].completedSteps.length, 1);
- assertEqual(checkpoints[1].completedSteps.length, 2);
- });
- test('executeFrom resumes from a specific step', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: ['x(INT)'], vars: ['$a(INT)', '$b(INT)'] },
- steps: [
- { id: 'Set_A', target: '$a', value: '=x + 1', next: 'Set_B' },
- { id: 'Set_B', target: '$b', value: '=$a + 10', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- // Simulate: Set_A already ran, $a = 6, now resume from Set_B
- const ctx = await engine.executeFrom({
- currentStepID: 'Set_B',
- params: { x: 5 },
- variables: { '$a': 6, '$b': null }
- });
- assertEqual(ctx.variables['$b'], 16); // $a(6) + 10
- assertEqual(ctx.variables['$a'], 6); // Unchanged — Set_A was not re-run
- });
- test('executeFrom with variable overrides', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: ['x(INT)'], vars: ['$a(INT)', '$b(INT)'] },
- steps: [
- { id: 'Set_A', target: '$a', value: '=x + 1', next: 'Set_B' },
- { id: 'Set_B', target: '$b', value: '=$a + 10', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- // Resume from Set_B but override $a to 100
- const ctx = await engine.executeFrom(
- { currentStepID: 'Set_B', params: { x: 5 }, variables: { '$a': 6, '$b': null } },
- {},
- { '$a': 100 } // override
- );
- assertEqual(ctx.variables['$b'], 110); // overridden $a(100) + 10
- });
- test('executeFrom with checkpoint round-trip', async () => {
- const checkpoints = [];
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: ['x(INT)'], vars: ['$a(INT)', '$b(INT)'] },
- steps: [
- { id: 'Set_A', target: '$a', value: '=x + 1', next: 'Set_B' },
- { id: 'Set_B', target: '$b', value: '=$a + 10', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- }, { onCheckpoint: cp => checkpoints.push(cp) });
- await engine.execute({ x: 5 });
- // checkpoint after Set_A: $a=6, $b=null, currentStepID should point to next
- const cpAfterA = JSON.parse(JSON.stringify(checkpoints[0]));
- // Modify: set currentStepID to Set_B to resume from there
- cpAfterA.currentStepID = 'Set_B';
- const ctx2 = await engine.executeFrom(cpAfterA);
- assertEqual(ctx2.variables['$b'], 16);
- });
- // ═══════════════════════════════════════════════════════════════
- console.log('\n── Phase 2: Parallel & Loop Resume ──');
- test('parallel branches: checkpoint tracks completed branches', async () => {
- const checkpoints = [];
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
- steps: [
- { id: 'Noop_Fork', children: ['Set_A', 'Set_B'], next: 'Stop_End' },
- { id: 'Set_A', target: '$a', value: '=1' },
- { id: 'Set_B', target: '$b', value: '=2' },
- { id: 'Stop_End' }
- ]
- }, { onCheckpoint: cp => checkpoints.push(cp) });
- await engine.execute({});
- // Should have checkpoints from both branches
- const lastCp = checkpoints[checkpoints.length - 1];
- assert(lastCp.completedBranches['Noop_Fork'], 'should track Fork branches');
- const forkBranches = lastCp.completedBranches['Noop_Fork'];
- assert(forkBranches.includes('Set_A'), 'Set_A should be completed');
- assert(forkBranches.includes('Set_B'), 'Set_B should be completed');
- });
- test('parallel branches: resume skips completed branches', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$a(INT)', '$b(INT)', '$c(INT)'] },
- steps: [
- { id: 'Noop_Fork', children: ['Set_A', 'Set_B', 'Set_C'], next: 'Stop_End' },
- { id: 'Set_A', target: '$a', value: '=10' },
- { id: 'Set_B', target: '$b', value: '=20' },
- { id: 'Set_C', target: '$c', value: '=30' },
- { id: 'Stop_End' }
- ]
- });
- // Simulate: branches A and B completed, C did not
- const ctx = await engine.executeFrom({
- currentStepID: 'Noop_Fork',
- variables: { '$a': 10, '$b': 20, '$c': null },
- completedSteps: ['Set_A', 'Set_B'],
- completedBranches: { 'Noop_Fork': ['Set_A', 'Set_B'] }
- });
- // Set_A and Set_B should not be re-run (values should stay)
- // Set_C should have been executed
- assertEqual(ctx.variables['$c'], 30);
- // $a and $b should be their original checkpoint values (not re-run)
- assertEqual(ctx.variables['$a'], 10);
- assertEqual(ctx.variables['$b'], 20);
- });
- test('loop: checkpoint tracks iteration progress', async () => {
- const checkpoints = [];
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$items([STRING])', '$total(INT)'] },
- steps: [
- { id: 'Set_Init', target: '$items', value: '=["a","b","c","d","e"]', next: 'Set_Total' },
- { id: 'Set_Total', target: '$total', value: '=0', next: 'Loop_001' },
- { id: 'Loop_001', source: '$items', mode: 'serial', children: ['Set_Inc'], next: 'Stop_End' },
- { id: 'Set_Inc', target: '$total', value: '=$total + 1' },
- { id: 'Stop_End' }
- ]
- }, { onCheckpoint: cp => checkpoints.push(cp) });
- await engine.execute({});
- // Loop should have recorded progress
- const lastCp = checkpoints[checkpoints.length - 1];
- assertEqual(lastCp.loopProgress['Loop_001'], 5);
- });
- test('loop: resume from mid-iteration', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$items([STRING])', '$total(INT)'] },
- steps: [
- { id: 'Loop_001', source: '$items', mode: 'serial', children: ['Set_Inc'], next: 'Stop_End' },
- { id: 'Set_Inc', target: '$total', value: '=$total + 1' },
- { id: 'Stop_End' }
- ]
- });
- // Simulate: loop ran 3 out of 5 iterations, crashed
- const ctx = await engine.executeFrom({
- currentStepID: 'Loop_001',
- variables: { '$items': ['a', 'b', 'c', 'd', 'e'], '$total': 3 },
- loopProgress: { 'Loop_001': 3 } // completed 0,1,2
- });
- // Should only run iterations 3 and 4 (adding 2 more)
- assertEqual(ctx.variables['$total'], 5); // 3 + 2 = 5
- });
- test('loop: resume parallel loop from mid-point', async () => {
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$items([INT])', '$sum(INT)'] },
- steps: [
- { id: 'Loop_P', source: '$items', mode: 'parallel', children: ['Set_Add'], next: 'Stop_End' },
- { id: 'Set_Add', target: '$sum', value: '=$sum + _item' },
- { id: 'Stop_End' }
- ]
- });
- // Simulate: parallel loop processed first 3 items (sum=1+2+3=6), crashed
- const ctx = await engine.executeFrom({
- currentStepID: 'Loop_P',
- variables: { '$items': [1, 2, 3, 4, 5], '$sum': 6 },
- loopProgress: { 'Loop_P': 3 }
- });
- // Should only run items at index 3,4 (values 4,5), adding 9 to existing 6
- assertEqual(ctx.variables['$sum'], 15); // 6 + 4 + 5 = 15
- });
- test('checkpoint v2 round-trip with parallel and loop state', async () => {
- const checkpoints = [];
- const engine = new Engine({
- version: '3.15', name: 'test',
- registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
- steps: [
- { id: 'Noop_Fork', children: ['Set_A', 'Set_B'], next: 'Stop_End' },
- { id: 'Set_A', target: '$a', value: '=1' },
- { id: 'Set_B', target: '$b', value: '=2' },
- { id: 'Stop_End' }
- ]
- }, { onCheckpoint: cp => checkpoints.push(cp) });
- await engine.execute({});
- const lastCp = checkpoints[checkpoints.length - 1];
- // Verify v2 format
- assertEqual(lastCp._version, 2);
- // JSON round-trip should preserve completedBranches and loopProgress
- const cp2 = JSON.parse(JSON.stringify(lastCp));
- assert(Array.isArray(cp2.completedBranches['Noop_Fork']), 'completedBranches survives JSON');
- assert(typeof cp2.loopProgress === 'object', 'loopProgress survives JSON');
- });
- // ═══════════════════════════════════════════════════════════════
- console.log('\n── v3.16 Features ──');
- test('validate: Loop while/source mutual exclusion', () => {
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$x(INT)'] },
- steps: [
- { id: 'Loop_Bad', while: '=$x < 10', source: '$items', children: ['Set_Inc'], next: 'Stop_End' },
- { id: 'Set_Inc', target: '$x', value: '=$x + 1' },
- { id: 'Stop_End' }
- ]
- });
- const errors = engine.validate();
- assert(errors.some(e => e.includes('mutually exclusive')), 'should reject while + source');
- });
- test('validate: Loop without while or source is allowed (legacy compat)', () => {
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: [] },
- steps: [
- { id: 'Loop_Empty', children: ['Noop_Child'], next: 'Stop_End' },
- { id: 'Noop_Child' },
- { id: 'Stop_End' }
- ]
- });
- const errors = engine.validate();
- assert(!errors.some(e => e.includes('Loop_Empty')), 'Loop without while/source should be allowed');
- });
- test('validate: BREAK only inside Loop children', () => {
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$x(INT)'] },
- steps: [
- { id: 'Set_001', target: '$x', value: '=1', next: 'BREAK' },
- { id: 'Stop_End' }
- ]
- });
- const errors = engine.validate();
- assert(errors.some(e => e.includes('BREAK is only valid inside Loop')), 'should reject BREAK outside loop');
- });
- test('validate: BREAK inside Loop children is OK', () => {
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$items([INT])', '$x(INT)'] },
- steps: [
- { id: 'Loop_001', source: '$items', children: ['Set_Inc'], next: 'Stop_End' },
- { id: 'Set_Inc', target: '$x', value: '=$x + 1', next: 'BREAK' },
- { id: 'Stop_End' }
- ]
- });
- const errors = engine.validate();
- assert(!errors.some(e => e.includes('BREAK')), 'BREAK inside Loop children should be valid');
- });
- test('execute Loop while mode', async () => {
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$x(INT)'] },
- steps: [
- { id: 'Set_Init', target: '$x', value: '=0', next: 'Loop_While' },
- { id: 'Loop_While', while: '=$x < 5', children: ['Set_Inc'], next: 'Stop_End' },
- { id: 'Set_Inc', target: '$x', value: '=$x + 1' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({});
- assertEqual(ctx.variables['$x'], 5);
- });
- test('execute Loop while with maxIterations', async () => {
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$x(INT)'] },
- steps: [
- { id: 'Set_Init', target: '$x', value: '=0', next: 'Loop_While' },
- { id: 'Loop_While', while: '=$x < 100', maxIterations: 3, children: ['Set_Inc'], next: 'Stop_End' },
- { id: 'Set_Inc', target: '$x', value: '=$x + 1' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({});
- assertEqual(ctx.variables['$x'], 3); // capped at 3
- });
- test('execute BREAK exits loop early (serial)', async () => {
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$items([INT])', '$sum(INT)'] },
- steps: [
- { id: 'Set_Init', target: '$items', value: '=[1,2,3,4,5]', next: 'Set_Sum' },
- { id: 'Set_Sum', target: '$sum', value: '=0', next: 'Loop_001' },
- { id: 'Loop_001', source: '$items', mode: 'serial', children: ['Check_Break'], next: 'Stop_End' },
- { id: 'Check_Break', condition: '=_item >= 4', if_true: 'Noop_Break', if_false: 'Set_Add' },
- { id: 'Noop_Break', next: 'BREAK' },
- { id: 'Set_Add', target: '$sum', value: '=$sum + _item', next: 'RETURN' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({});
- assertEqual(ctx.variables['$sum'], 6); // 1+2+3 = 6, item 4 triggers BREAK
- });
- test('execute BREAK in while loop', async () => {
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$x(INT)'] },
- steps: [
- { id: 'Set_Init', target: '$x', value: '=0', next: 'Loop_While' },
- { id: 'Loop_While', while: '=true', maxIterations: 100, children: ['Set_Inc', 'Check_Break'], next: 'Stop_End' },
- { id: 'Set_Inc', target: '$x', value: '=$x + 1', next: 'Check_Break' },
- { id: 'Check_Break', condition: '=$x >= 3', if_true: 'Noop_Break', if_false: 'RETURN' },
- { id: 'Noop_Break', next: 'BREAK' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({});
- assertEqual(ctx.variables['$x'], 3); // BREAK at x=3
- });
- test('execute Loop source with maxIterations cap', async () => {
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$items([INT])', '$total(INT)'] },
- steps: [
- { id: 'Set_Init', target: '$items', value: '=[1,2,3,4,5]', next: 'Set_Total' },
- { id: 'Set_Total', target: '$total', value: '=0', next: 'Loop_001' },
- { id: 'Loop_001', source: '$items', mode: 'serial', maxIterations: 3, children: ['Set_Inc'], next: 'Stop_End' },
- { id: 'Set_Inc', target: '$total', value: '=$total + 1' },
- { id: 'Stop_End' }
- ]
- });
- const ctx = await engine.execute({});
- assertEqual(ctx.variables['$total'], 3); // only 3 of 5 items
- });
- test('LLM model field: provider/modelId parsing', async () => {
- let capturedParams = null;
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$out(STRING)'] },
- steps: [
- { id: 'LLM_Test', model: 'anthropic/claude-sonnet-4-20250514', in: { messages: [{ role: 'user', content: 'hi' }] }, out: '$out', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- const mockLLM = {
- call: async (params) => {
- capturedParams = { ...params };
- return { content: 'hello', model: 'claude-sonnet-4-20250514', usage: {} };
- }
- };
- await engine.execute({}, { llm: mockLLM });
- assertEqual(capturedParams._provider, 'anthropic');
- assertEqual(capturedParams.model, 'claude-sonnet-4-20250514');
- });
- test('LLM model field: provider-only parsing', async () => {
- let capturedParams = null;
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$out(STRING)'] },
- steps: [
- { id: 'LLM_Test', model: 'openai', in: { messages: [{ role: 'user', content: 'hi' }] }, out: '$out', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- const mockLLM = {
- call: async (params) => {
- capturedParams = { ...params };
- return { content: 'hello', model: 'gpt-4o', usage: {} };
- }
- };
- await engine.execute({}, { llm: mockLLM });
- assertEqual(capturedParams._provider, 'openai');
- assert(!capturedParams.model, 'model should not be set for provider-only');
- });
- test('BREAK in _findEntryNodeIDs does not add as reference', () => {
- const engine = new Engine({
- version: '3.16', name: 'test',
- registry: { params: [], vars: ['$items([INT])'] },
- steps: [
- { id: 'Loop_001', source: '$items', children: ['Set_Inc'], next: 'Stop_End' },
- { id: 'Set_Inc', target: '$x', value: '=1', next: 'BREAK' },
- { id: 'Stop_End' }
- ]
- });
- const entries = engine._findEntryNodeIDs();
- assertEqual(entries.length, 1);
- assertEqual(entries[0], 'Loop_001');
- });
- // ═══════════════════════════════════════════════════════════════
- console.log('\n── User-Initiated Pause ──');
- test('ctx.pause() stops execution after current step', async () => {
- const events = [];
- const engine = new Engine({
- version: '3.16', name: 'pause-test',
- registry: { params: [], vars: ['$a(STRING)', '$b(STRING)'] },
- steps: [
- { id: 'Set_A', target: '$a', value: 'hello', next: 'Set_B' },
- { id: 'Set_B', target: '$b', value: 'world', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- }, {
- onEvent: (e) => events.push(e),
- onCheckpoint: () => {}
- });
- // Hook: pause after Set_A step_done, before Set_B begins
- engine._onEvent = (e) => {
- events.push(e);
- };
- // Intercept: when engine tries to execute Set_B, call ctx.pause() first
- const origExec = engine.executeStep.bind(engine);
- let ctx_ref = null;
- engine.executeStep = async function(ctx, step) {
- ctx_ref = ctx;
- if (step.id === 'Set_B') ctx.pause();
- return origExec(ctx, step);
- };
- const ctx = await engine.execute();
- assertEqual(ctx.status, 'paused', 'Status should be paused');
- assertEqual(ctx.variables['$a'], 'hello', '$a should be set');
- assertEqual(ctx.variables['$b'], null, '$b should NOT be set (paused before)');
- // workflow_paused event should be emitted
- const pauseEvt = events.find(e => e.type === 'workflow_paused');
- assert(pauseEvt, 'workflow_paused event should be emitted');
- });
- test('pause → checkpoint → resume via executeFrom', async () => {
- const engine = new Engine({
- version: '3.16', name: 'pause-resume',
- registry: { params: [], vars: ['$a(STRING)', '$b(STRING)'] },
- steps: [
- { id: 'Set_A', target: '$a', value: 'hello', next: 'Set_B' },
- { id: 'Set_B', target: '$b', value: 'world', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- // Pause before Set_B
- const origExec = engine.executeStep.bind(engine);
- engine.executeStep = async function(ctx, step) {
- if (step.id === 'Set_B') ctx.pause();
- return origExec(ctx, step);
- };
- const ctx = await engine.execute();
- assertEqual(ctx.status, 'paused');
- // Take checkpoint and resume from Set_B
- const cp = ctx.checkpoint();
- cp.currentStepID = 'Set_B';
- // Fresh engine (no hook) to resume
- const engine2 = new Engine({
- version: '3.16', name: 'pause-resume',
- registry: { params: [], vars: ['$a(STRING)', '$b(STRING)'] },
- steps: [
- { id: 'Set_A', target: '$a', value: 'hello', next: 'Set_B' },
- { id: 'Set_B', target: '$b', value: 'world', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- const ctx2 = await engine2.executeFrom(cp, {});
- assertEqual(ctx2.variables['$a'], 'hello', '$a preserved from checkpoint');
- assertEqual(ctx2.variables['$b'], 'world', '$b set after resume');
- });
- test('pause → edit overrides → resume with modified input', async () => {
- const engine = new Engine({
- version: '3.16', name: 'pause-edit-resume',
- registry: { params: [], vars: ['$msg(STRING)', '$result(STRING)'] },
- steps: [
- { id: 'Set_Msg', target: '$msg', value: 'original', next: 'Set_Result' },
- { id: 'Set_Result', target: '$result', value: '=$msg', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- // Pause before Set_Result
- const origExec = engine.executeStep.bind(engine);
- engine.executeStep = async function(ctx, step) {
- if (step.id === 'Set_Result') ctx.pause();
- return origExec(ctx, step);
- };
- const ctx = await engine.execute();
- assertEqual(ctx.status, 'paused');
- assertEqual(ctx.variables['$msg'], 'original');
- // User edits $msg before resuming
- const cp = ctx.checkpoint();
- cp.currentStepID = 'Set_Result';
- const engine2 = new Engine({
- version: '3.16', name: 'pause-edit-resume',
- registry: { params: [], vars: ['$msg(STRING)', '$result(STRING)'] },
- steps: [
- { id: 'Set_Msg', target: '$msg', value: 'original', next: 'Set_Result' },
- { id: 'Set_Result', target: '$result', value: '=$msg', next: 'Stop_End' },
- { id: 'Stop_End' }
- ]
- });
- const ctx2 = await engine2.executeFrom(cp, {}, { '$msg': 'user-edited' });
- assertEqual(ctx2.variables['$msg'], 'user-edited', '$msg should be overridden');
- assertEqual(ctx2.variables['$result'], 'user-edited', '$result should use edited $msg');
- });
- // ═══════════════════════════════════════════════════════════════
- console.log('\n── Results ──');
- console.log(`\n ${passed} passed, ${failed} failed\n`);
- process.exit(failed > 0 ? 1 : 0);
|