#!/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);