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