#!/usr/bin/env node /** * Phase 2 Stress Tests — Parallel Branch & Loop Resume * Tests edge cases and complex scenarios */ const { Engine, ExecutionContext } = require('../index'); let passed = 0, failed = 0; function test(name, fn) { try { const result = fn(); if (result && typeof result.then === 'function') { return result.then(() => { console.log(` ✓ ${name}`); passed++; }).catch(e => { console.log(` ✗ ${name}: ${e.message}`); failed++; }); } 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}`); } async function runAll() { // ═══════════════════════════════════════════════════════════════ console.log('\n── Parallel Branch Resume: Edge Cases ──'); await test('3 branches, 0 completed — runs all 3', async () => { const events = []; 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' } ] }, { onEvent: e => events.push(e) }); const ctx = await engine.executeFrom({ currentStepID: 'Noop_Fork', variables: { '$a': null, '$b': null, '$c': null }, completedBranches: {} // nothing completed }); assertEqual(ctx.variables['$a'], 10); assertEqual(ctx.variables['$b'], 20); assertEqual(ctx.variables['$c'], 30); }); await test('3 branches, ALL completed — skips all, moves to next', async () => { const stepStarts = []; 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: 'Set_Final' }, { id: 'Set_A', target: '$a', value: '=99' }, { id: 'Set_B', target: '$b', value: '=99' }, { id: 'Set_C', target: '$c', value: '=99' }, { id: 'Set_Final', target: '$a', value: '=777', next: 'Stop_End' }, { id: 'Stop_End' } ] }, { onEvent: e => { if (e.type === 'step_start') stepStarts.push(e.stepID); } }); const ctx = await engine.executeFrom({ currentStepID: 'Noop_Fork', variables: { '$a': 10, '$b': 20, '$c': 30 }, completedBranches: { 'Noop_Fork': ['Set_A', 'Set_B', 'Set_C'] } }); // All branches skipped, values unchanged from checkpoint (except Set_Final runs) assertEqual(ctx.variables['$a'], 777, '$a should be set by Set_Final'); assertEqual(ctx.variables['$b'], 20, '$b should be unchanged'); assertEqual(ctx.variables['$c'], 30, '$c should be unchanged'); // Set_A, Set_B, Set_C should NOT appear in step_starts assert(!stepStarts.includes('Set_A'), 'Set_A should not have started'); assert(!stepStarts.includes('Set_B'), 'Set_B should not have started'); assert(!stepStarts.includes('Set_C'), 'Set_C should not have started'); assert(stepStarts.includes('Set_Final'), 'Set_Final should have started'); }); await test('single child branch — also tracks completion', async () => { const checkpoints = []; const engine = new Engine({ version: '3.15', name: 'test', registry: { params: [], vars: ['$x(INT)'] }, steps: [ { id: 'Noop_Single', children: ['Set_X'], next: 'Stop_End' }, { id: 'Set_X', target: '$x', value: '=42' }, { id: 'Stop_End' } ] }, { onCheckpoint: cp => checkpoints.push(cp) }); await engine.execute({}); const lastCp = checkpoints[checkpoints.length - 1]; assert(lastCp.completedBranches['Noop_Single'], 'should track single child'); assert(lastCp.completedBranches['Noop_Single'].includes('Set_X'), 'Set_X should be completed'); }); await test('branch with chain (Set_A → Set_A2), partial completion re-runs chain', async () => { // Set_A's chain: Set_A → Set_A2 (via next) // If Set_A branch is NOT in completedBranches, the whole chain re-runs const stepStarts = []; const engine = new Engine({ version: '3.15', name: 'test', registry: { params: [], vars: ['$a(INT)', '$a2(INT)', '$b(INT)'] }, steps: [ { id: 'Noop_Fork', children: ['Set_A', 'Set_B'], next: 'Stop_End' }, { id: 'Set_A', target: '$a', value: '=1', next: 'Set_A2' }, { id: 'Set_A2', target: '$a2', value: '=2' }, // end of chain (RETURN) { id: 'Set_B', target: '$b', value: '=3' }, { id: 'Stop_End' } ] }, { onEvent: e => { if (e.type === 'step_start') stepStarts.push(e.stepID); } }); // Only Set_B completed, Set_A chain did NOT complete const ctx = await engine.executeFrom({ currentStepID: 'Noop_Fork', variables: { '$a': null, '$a2': null, '$b': 3 }, completedBranches: { 'Noop_Fork': ['Set_B'] } }); // Set_A chain should have re-run assertEqual(ctx.variables['$a'], 1, 'Set_A should have run'); assertEqual(ctx.variables['$a2'], 2, 'Set_A2 should have run'); assertEqual(ctx.variables['$b'], 3, 'Set_B should NOT have re-run'); assert(stepStarts.includes('Set_A'), 'Set_A should have started'); assert(stepStarts.includes('Set_A2'), 'Set_A2 should have started'); assert(!stepStarts.includes('Set_B'), 'Set_B should NOT have started'); }); // ═══════════════════════════════════════════════════════════════ console.log('\n── Loop Resume: Edge Cases ──'); await test('loop with 0 progress — runs all iterations', async () => { const engine = new Engine({ version: '3.15', name: 'test', registry: { params: [], vars: ['$items([INT])', '$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' } ] }); const ctx = await engine.executeFrom({ currentStepID: 'Loop_001', variables: { '$items': [1, 2, 3, 4, 5], '$total': 0 }, loopProgress: {} // no progress }); assertEqual(ctx.variables['$total'], 5); }); await test('loop fully completed — runs 0 iterations', async () => { const stepStarts = []; const engine = new Engine({ version: '3.15', name: 'test', registry: { params: [], vars: ['$items([INT])', '$total(INT)'] }, steps: [ { id: 'Loop_001', source: '$items', mode: 'serial', children: ['Set_Inc'], next: 'Set_Done' }, { id: 'Set_Inc', target: '$total', value: '=$total + 1' }, { id: 'Set_Done', target: '$total', value: '=$total + 100', next: 'Stop_End' }, { id: 'Stop_End' } ] }, { onEvent: e => { if (e.type === 'step_start') stepStarts.push(e.stepID); } }); const ctx = await engine.executeFrom({ currentStepID: 'Loop_001', variables: { '$items': [1, 2, 3], '$total': 3 }, loopProgress: { 'Loop_001': 3 } // all 3 done }); // Loop body should NOT execute, but next (Set_Done) should assertEqual(ctx.variables['$total'], 103, 'should skip loop, run Set_Done'); assert(!stepStarts.includes('Set_Inc'), 'Set_Inc should not have started'); assert(stepStarts.includes('Set_Done'), 'Set_Done should have started'); }); await test('loop resume: iteration index is correct (_item and _index)', async () => { const items = []; const indices = []; const engine = new Engine({ version: '3.15', name: 'test', registry: { params: [], vars: ['$data([STRING])', '$log([STRING])'] }, steps: [ { id: 'Loop_Log', source: '$data', mode: 'serial', children: ['Set_Collect'], next: 'Stop_End' }, { id: 'Set_Collect', target: '$log', value: '=$log' }, // dummy { id: 'Stop_End' } ] }, { customHandlers: { Set: (engine, ctx, step) => { // Intercept to capture _item and _index items.push(ctx.getVariable('_item')); indices.push(ctx.getVariable('_index')); const { ExpressionEvaluator } = require('../lib/expression'); const ev = new ExpressionEvaluator(ctx); ev.setVariable(step.target, ev.evaluateValue(step.value)); } } }); const ctx = await engine.executeFrom({ currentStepID: 'Loop_Log', variables: { '$data': ['a', 'b', 'c', 'd', 'e'], '$log': [] }, loopProgress: { 'Loop_Log': 2 } // skip 'a' and 'b' }); // Should have processed items at index 2,3,4 assertEqual(items.length, 3, 'should process 3 items'); assertEqual(items[0], 'c'); assertEqual(items[1], 'd'); assertEqual(items[2], 'e'); assertEqual(indices[0], 2); assertEqual(indices[1], 3); assertEqual(indices[2], 4); }); await test('serial loop: checkpoint per iteration', async () => { const checkpoints = []; const engine = new Engine({ version: '3.15', 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_Sum' }, { id: 'Loop_Sum', source: '$items', mode: 'serial', children: ['Set_Add'], next: 'Stop_End' }, { id: 'Set_Add', target: '$total', value: '=$total + _item' }, { id: 'Stop_End' } ] }, { onCheckpoint: cp => checkpoints.push(cp) }); await engine.execute({}); // Filter checkpoints that have loop progress for Loop_Sum const loopCps = checkpoints.filter(cp => cp.loopProgress['Loop_Sum'] > 0); // Should have at least 5 checkpoints from loop iterations assert(loopCps.length >= 5, `should have >=5 loop checkpoints, got ${loopCps.length}`); // Verify progressive loop progress const progresses = loopCps.map(cp => cp.loopProgress['Loop_Sum']); for (let i = 1; i < progresses.length; i++) { assert(progresses[i] >= progresses[i-1], 'loop progress should be non-decreasing'); } assertEqual(progresses[progresses.length - 1], 5, 'final progress should be 5'); }); await test('parallel loop resume from mid-point — correct items', async () => { // Use a deterministic approach: parallel loop with Set that adds _item to $sum const engine = new Engine({ version: '3.15', name: 'test', registry: { params: [], vars: ['$nums([INT])', '$sum(INT)'] }, steps: [ { id: 'Loop_PSum', source: '$nums', mode: 'parallel', children: ['Set_Sum'], next: 'Stop_End' }, { id: 'Set_Sum', target: '$sum', value: '=$sum + _item' }, { id: 'Stop_End' } ] }); // Items: [10, 20, 30, 40, 50]. First 2 completed (sum=30). Resume from index 2. const ctx = await engine.executeFrom({ currentStepID: 'Loop_PSum', variables: { '$nums': [10, 20, 30, 40, 50], '$sum': 30 }, loopProgress: { 'Loop_PSum': 2 } }); // Should add 30+40+50 = 120 to existing 30 assertEqual(ctx.variables['$sum'], 150); }); // ═══════════════════════════════════════════════════════════════ console.log('\n── Combined Scenarios ──'); await test('full workflow: linear → fork → loop → stop, checkpoint round-trip', async () => { const checkpoints = []; const engine = new Engine({ version: '3.15', name: 'test', registry: { params: [], vars: ['$a(INT)', '$b(INT)', '$items([INT])', '$total(INT)'] }, steps: [ { id: 'Set_Init', target: '$items', value: '=[1,2,3]', next: 'Noop_Fork' }, { id: 'Noop_Fork', children: ['Set_A', 'Set_B'], next: 'Loop_Sum' }, { id: 'Set_A', target: '$a', value: '=10' }, { id: 'Set_B', target: '$b', value: '=20' }, { id: 'Loop_Sum', source: '$items', mode: 'serial', children: ['Set_Add'], next: 'Stop_End' }, { id: 'Set_Add', target: '$total', value: '=($total || 0) + _item' }, { id: 'Stop_End' } ] }, { onCheckpoint: cp => checkpoints.push(cp) }); const ctx = await engine.execute({}); assertEqual(ctx.variables['$a'], 10); assertEqual(ctx.variables['$b'], 20); assertEqual(ctx.variables['$total'], 6); // 1+2+3 // Verify checkpoint has all state const lastCp = checkpoints[checkpoints.length - 1]; assert(lastCp.completedBranches['Noop_Fork'], 'fork branches tracked'); assertEqual(lastCp.loopProgress['Loop_Sum'], 3, 'loop fully tracked'); // Round-trip: resume from Loop_Sum with partial progress const resumeCp = JSON.parse(JSON.stringify(lastCp)); resumeCp.currentStepID = 'Loop_Sum'; resumeCp.variables['$total'] = 1; // pretend only iteration 0 ran resumeCp.loopProgress['Loop_Sum'] = 1; // completed 1 iteration const ctx2 = await engine.executeFrom(resumeCp); // Should run iterations 1,2 (values 2,3) adding 5 to existing 1 assertEqual(ctx2.variables['$total'], 6); // 1 + 2 + 3 }); await test('resume from Noop_Fork with no completedBranches field at all', async () => { // Edge case: old v1 checkpoint without completedBranches 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' } ] }); // No completedBranches at all — should run everything const ctx = await engine.executeFrom({ currentStepID: 'Noop_Fork', variables: { '$a': null, '$b': null } // no completedBranches field }); assertEqual(ctx.variables['$a'], 1); assertEqual(ctx.variables['$b'], 2); }); await test('resume from Loop with no loopProgress field at all', async () => { const engine = new Engine({ version: '3.15', name: 'test', registry: { params: [], vars: ['$items([INT])', '$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' } ] }); const ctx = await engine.executeFrom({ currentStepID: 'Loop_001', variables: { '$items': [1, 2, 3], '$total': 0 } // no loopProgress field }); assertEqual(ctx.variables['$total'], 3); }); // ═══════════════════════════════════════════════════════════════ console.log('\n── Results ──'); console.log(`\n ${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0); } runAll();