run.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878
  1. #!/usr/bin/env node
  2. /**
  3. * VL Workflow Engine — Test Suite
  4. */
  5. const { Engine, ExpressionEvaluator, ExecutionContext, Registry,
  6. parseParamDeclaration, parseVariableDeclaration, parseServiceSignature } = require('../index');
  7. const fs = require('fs');
  8. const path = require('path');
  9. let passed = 0, failed = 0;
  10. function test(name, fn) {
  11. try {
  12. fn();
  13. console.log(` ✓ ${name}`);
  14. passed++;
  15. } catch (e) {
  16. console.log(` ✗ ${name}: ${e.message}`);
  17. failed++;
  18. }
  19. }
  20. function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
  21. function assertEqual(a, b, msg) { if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`); }
  22. // ═══════════════════════════════════════════════════════════════
  23. console.log('\n── Expression Evaluator ──');
  24. test('literal string', () => {
  25. const ctx = new ExecutionContext({ workflowID: 'test', params: {}, variables: {} });
  26. const ev = new ExpressionEvaluator(ctx);
  27. assertEqual(ev.evaluateValue('hello'), 'hello');
  28. });
  29. test('escape ==', () => {
  30. const ctx = new ExecutionContext({ workflowID: 'test' });
  31. const ev = new ExpressionEvaluator(ctx);
  32. assertEqual(ev.evaluateValue('==foo'), '=foo');
  33. });
  34. test('variable reference', () => {
  35. const ctx = new ExecutionContext({ workflowID: 'test', params: { name: 'Dragon' } });
  36. const ev = new ExpressionEvaluator(ctx);
  37. assertEqual(ev.evaluateValue('=name'), 'Dragon');
  38. });
  39. test('$var reference', () => {
  40. const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$count': 42 } });
  41. const ev = new ExpressionEvaluator(ctx);
  42. assertEqual(ev.evaluateValue('=$count'), 42);
  43. });
  44. test('nested property', () => {
  45. const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$obj': { a: { b: 3 } } } });
  46. const ev = new ExpressionEvaluator(ctx);
  47. assertEqual(ev.evaluateValue('=$obj.a.b'), 3);
  48. });
  49. test('array index', () => {
  50. const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$arr': [10, 20, 30] } });
  51. const ev = new ExpressionEvaluator(ctx);
  52. assertEqual(ev.evaluateValue('=$arr[1]'), 20);
  53. });
  54. test('.length', () => {
  55. const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$arr': [1, 2, 3] } });
  56. const ev = new ExpressionEvaluator(ctx);
  57. assertEqual(ev.evaluateValue('=$arr.length'), 3);
  58. });
  59. test('arithmetic', () => {
  60. const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 10 } });
  61. const ev = new ExpressionEvaluator(ctx);
  62. assertEqual(ev.evaluateValue('=x + 5'), 15);
  63. });
  64. test('string concatenation', () => {
  65. const ctx = new ExecutionContext({ workflowID: 'test', params: { name: 'VL' } });
  66. const ev = new ExpressionEvaluator(ctx);
  67. assertEqual(ev.evaluateValue('="Hello " + name'), 'Hello VL');
  68. });
  69. test('comparison ==', () => {
  70. const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 5 } });
  71. const ev = new ExpressionEvaluator(ctx);
  72. assertEqual(ev.evaluateValue('=x == 5'), true);
  73. });
  74. test('ternary', () => {
  75. const ctx = new ExecutionContext({ workflowID: 'test', params: { x: true } });
  76. const ev = new ExpressionEvaluator(ctx);
  77. assertEqual(ev.evaluateValue('=x ? "yes" : "no"'), 'yes');
  78. });
  79. test('logical AND/OR', () => {
  80. const ctx = new ExecutionContext({ workflowID: 'test', params: { a: true, b: false } });
  81. const ev = new ExpressionEvaluator(ctx);
  82. assertEqual(ev.evaluateValue('=a && b'), false);
  83. assertEqual(ev.evaluateValue('=a || b'), true);
  84. });
  85. test('NOT', () => {
  86. const ctx = new ExecutionContext({ workflowID: 'test', params: { a: false } });
  87. const ev = new ExpressionEvaluator(ctx);
  88. assertEqual(ev.evaluateValue('=!a'), true);
  89. });
  90. test('setVariable deep path', () => {
  91. const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$data': {} } });
  92. const ev = new ExpressionEvaluator(ctx);
  93. ev.setVariable('$data.name', 'test');
  94. assertEqual(ctx.variables['$data'].name, 'test');
  95. });
  96. test('evaluateDeep', () => {
  97. const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 10 } });
  98. const ev = new ExpressionEvaluator(ctx);
  99. const result = ev.evaluateDeep({ a: '=x', b: 'literal', c: [1, '=x'] });
  100. assertEqual(result.a, 10);
  101. assertEqual(result.b, 'literal');
  102. assertEqual(result.c[1], 10);
  103. });
  104. // ═══════════════════════════════════════════════════════════════
  105. console.log('\n── Registry ──');
  106. test('parseParamDeclaration', () => {
  107. const d = parseParamDeclaration('userId(STRING)');
  108. assertEqual(d.name, 'userId');
  109. assertEqual(d.type, 'STRING');
  110. });
  111. test('parseParamDeclaration with default', () => {
  112. const d = parseParamDeclaration('maxRetries(INT) = 3');
  113. assertEqual(d.name, 'maxRetries');
  114. assertEqual(d.default, 3);
  115. });
  116. test('parseVariableDeclaration', () => {
  117. const d = parseVariableDeclaration('$items([OBJECT])');
  118. assertEqual(d.name, '$items');
  119. assertEqual(d.type, '[OBJECT]');
  120. });
  121. test('parseServiceSignature', () => {
  122. const s = parseServiceSignature('PlannerService(prd(STRING)) RETURN plan(OBJECT)');
  123. assertEqual(s.name, 'PlannerService');
  124. assertEqual(s.parameters.length, 1);
  125. assertEqual(s.returns.length, 1);
  126. });
  127. test('Registry lookup', () => {
  128. const reg = new Registry({
  129. docs: { '1': 'VL Syntax' },
  130. apis: [{ id: 'api1', method: 'GET', url: '/test' }],
  131. schemas: { 'S1': { type: 'object' } }
  132. });
  133. assert(reg.hasDoc('1'));
  134. assert(reg.hasAPI('api1'));
  135. assert(reg.hasSchema('S1'));
  136. assertEqual(reg.getDocDescription('1'), 'VL Syntax');
  137. });
  138. // ═══════════════════════════════════════════════════════════════
  139. console.log('\n── Engine ──');
  140. test('validate meta-direct workflow', () => {
  141. const wfPath = path.join(__dirname, '../../VLClaw/workflows/meta-direct.json');
  142. if (!fs.existsSync(wfPath)) { console.log(' (skipped: workflow file not found)'); return; }
  143. const wf = JSON.parse(fs.readFileSync(wfPath, 'utf8'));
  144. const engine = new Engine(wf);
  145. const errors = engine.validate();
  146. assert(errors.length === 0, `Validation errors: ${errors.join('; ')}`);
  147. });
  148. test('validate 6file-codegen workflow', () => {
  149. const wfPath = path.join(__dirname, '../../VLClaw/workflows/6file-codegen.json');
  150. if (!fs.existsSync(wfPath)) { console.log(' (skipped: workflow file not found)'); return; }
  151. const wf = JSON.parse(fs.readFileSync(wfPath, 'utf8'));
  152. const engine = new Engine(wf);
  153. const errors = engine.validate();
  154. assert(errors.length === 0, `Validation errors: ${errors.join('; ')}`);
  155. });
  156. test('find entry nodes', () => {
  157. const engine = new Engine({
  158. version: '3.15', name: 'test',
  159. registry: { params: [], vars: [] },
  160. steps: [
  161. { id: 'Set_001', target: '$x', value: '=1', next: 'Set_002' },
  162. { id: 'Set_002', target: '$y', value: '=2', next: 'Stop_End' },
  163. { id: 'Stop_End' }
  164. ]
  165. });
  166. const entries = engine._findEntryNodeIDs();
  167. assertEqual(entries.length, 1);
  168. assertEqual(entries[0], 'Set_001');
  169. });
  170. test('execute simple Set → Stop workflow', async () => {
  171. const engine = new Engine({
  172. version: '3.15', name: 'test',
  173. registry: { params: ['x(INT)'], vars: ['$result(INT)'] },
  174. steps: [
  175. { id: 'Set_001', target: '$result', value: '=x + 10', next: 'Stop_End' },
  176. { id: 'Stop_End' }
  177. ]
  178. });
  179. const ctx = await engine.execute({ x: 5 });
  180. assertEqual(ctx.variables['$result'], 15);
  181. });
  182. test('execute Branch workflow', async () => {
  183. const engine = new Engine({
  184. version: '3.15', name: 'test',
  185. registry: { params: ['mode(STRING)'], vars: ['$out(STRING)'] },
  186. steps: [
  187. { id: 'Branch_001', cases: [['=mode == "a"', 'Set_A'], ['ELSE', 'Set_B']], next: 'Stop_End' },
  188. { id: 'Set_A', target: '$out', value: '="chose A"', next: 'RETURN' },
  189. { id: 'Set_B', target: '$out', value: '="chose B"', next: 'RETURN' },
  190. { id: 'Stop_End' }
  191. ]
  192. });
  193. const ctx = await engine.execute({ mode: 'a' });
  194. assertEqual(ctx.variables['$out'], 'chose A');
  195. });
  196. test('execute Check_* (condition/if_true/if_false)', async () => {
  197. const engine = new Engine({
  198. version: '3.15', name: 'test',
  199. registry: { params: ['hasErrors(BOOL)'], vars: ['$out(STRING)'] },
  200. steps: [
  201. { id: 'Check_NeedRepair', condition: '=hasErrors', if_true: 'Set_Repair', if_false: 'Set_OK', next: 'Stop_End' },
  202. { id: 'Set_Repair', target: '$out', value: '="repair"', next: 'RETURN' },
  203. { id: 'Set_OK', target: '$out', value: '="ok"', next: 'RETURN' },
  204. { id: 'Stop_End' }
  205. ]
  206. });
  207. const ctx1 = await engine.execute({ hasErrors: true });
  208. assertEqual(ctx1.variables['$out'], 'repair');
  209. const ctx2 = await engine.execute({ hasErrors: false });
  210. assertEqual(ctx2.variables['$out'], 'ok');
  211. });
  212. test('execute Loop workflow (serial)', async () => {
  213. const engine = new Engine({
  214. version: '3.15', name: 'test',
  215. registry: { params: [], vars: ['$items([STRING])', '$total(INT)'] },
  216. steps: [
  217. { id: 'Set_Init', target: '$items', value: '=["a","b","c"]', next: 'Set_Total' },
  218. { id: 'Set_Total', target: '$total', value: '=0', next: 'Loop_001' },
  219. { id: 'Loop_001', source: '$items', mode: 'serial', children: ['Set_Inc'], next: 'Stop_End' },
  220. { id: 'Set_Inc', target: '$total', value: '=$total + 1' },
  221. { id: 'Stop_End' }
  222. ]
  223. });
  224. const ctx = await engine.execute({});
  225. assertEqual(ctx.variables['$total'], 3);
  226. });
  227. test('execute parallel children', async () => {
  228. const engine = new Engine({
  229. version: '3.15', name: 'test',
  230. registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
  231. steps: [
  232. { id: 'Noop_Fork', children: ['Set_A', 'Set_B'], next: 'Stop_End' },
  233. { id: 'Set_A', target: '$a', value: '=1' },
  234. { id: 'Set_B', target: '$b', value: '=2' },
  235. { id: 'Stop_End' }
  236. ]
  237. });
  238. const ctx = await engine.execute({});
  239. assertEqual(ctx.variables['$a'], 1);
  240. assertEqual(ctx.variables['$b'], 2);
  241. });
  242. test('event emission', async () => {
  243. const events = [];
  244. const engine = new Engine({
  245. version: '3.15', name: 'test',
  246. registry: { params: [], vars: ['$x(INT)'] },
  247. steps: [
  248. { id: 'Set_001', target: '$x', value: '=42', next: 'Stop_End' },
  249. { id: 'Stop_End' }
  250. ]
  251. });
  252. // Patch to capture events
  253. const origExecute = engine.execute.bind(engine);
  254. const ctx = await engine.execute({});
  255. // Events were emitted on ctx
  256. // Let's test with listener
  257. const events2 = [];
  258. const engine2 = new Engine({
  259. version: '3.15', name: 'test',
  260. registry: { params: [], vars: ['$x(INT)'] },
  261. steps: [
  262. { id: 'Set_001', target: '$x', value: '=42', next: 'Stop_End' },
  263. { id: 'Stop_End' }
  264. ]
  265. });
  266. // We need to hook before execute... redesign needed
  267. // For now just verify it doesn't crash
  268. assert(ctx.status === 'stopped');
  269. });
  270. test('conditional skip (step.if)', async () => {
  271. const engine = new Engine({
  272. version: '3.15', name: 'test',
  273. registry: { params: ['skip(BOOL)'], vars: ['$x(INT)'] },
  274. steps: [
  275. { id: 'Set_001', if: '=!skip', target: '$x', value: '=99', next: 'Stop_End' },
  276. { id: 'Stop_End' }
  277. ]
  278. });
  279. const ctx = await engine.execute({ skip: true });
  280. assertEqual(ctx.variables['$x'], null); // Skipped, stays null
  281. });
  282. // ═══════════════════════════════════════════════════════════════
  283. console.log('\n── Checkpoint & ExecuteFrom ──');
  284. test('checkpoint produces serializable JSON', async () => {
  285. const engine = new Engine({
  286. version: '3.15', name: 'test',
  287. registry: { params: ['x(INT)'], vars: ['$result(INT)'] },
  288. steps: [
  289. { id: 'Set_001', target: '$result', value: '=x + 10', next: 'Stop_End' },
  290. { id: 'Stop_End' }
  291. ]
  292. });
  293. const ctx = await engine.execute({ x: 5 });
  294. const cp = ctx.checkpoint();
  295. assert(cp._type === 'vl_workflow_checkpoint', 'checkpoint type');
  296. assert(cp._version === 1, 'checkpoint version');
  297. assertEqual(cp.params.x, 5);
  298. assertEqual(cp.variables['$result'], 15);
  299. assert(cp.completedSteps.includes('Set_001'), 'Set_001 in completedSteps');
  300. // Should survive JSON round-trip
  301. const cp2 = JSON.parse(JSON.stringify(cp));
  302. assertEqual(cp2.variables['$result'], 15);
  303. });
  304. test('onCheckpoint callback fires after each step', async () => {
  305. const checkpoints = [];
  306. const engine = new Engine({
  307. version: '3.15', name: 'test',
  308. registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
  309. steps: [
  310. { id: 'Set_A', target: '$a', value: '=1', next: 'Set_B' },
  311. { id: 'Set_B', target: '$b', value: '=2', next: 'Stop_End' },
  312. { id: 'Stop_End' }
  313. ]
  314. }, { onCheckpoint: cp => checkpoints.push(cp) });
  315. await engine.execute({});
  316. assertEqual(checkpoints.length, 2); // Set_A and Set_B (Stop doesn't emit)
  317. assertEqual(checkpoints[0].completedSteps.length, 1);
  318. assertEqual(checkpoints[1].completedSteps.length, 2);
  319. });
  320. test('executeFrom resumes from a specific step', async () => {
  321. const engine = new Engine({
  322. version: '3.15', name: 'test',
  323. registry: { params: ['x(INT)'], vars: ['$a(INT)', '$b(INT)'] },
  324. steps: [
  325. { id: 'Set_A', target: '$a', value: '=x + 1', next: 'Set_B' },
  326. { id: 'Set_B', target: '$b', value: '=$a + 10', next: 'Stop_End' },
  327. { id: 'Stop_End' }
  328. ]
  329. });
  330. // Simulate: Set_A already ran, $a = 6, now resume from Set_B
  331. const ctx = await engine.executeFrom({
  332. currentStepID: 'Set_B',
  333. params: { x: 5 },
  334. variables: { '$a': 6, '$b': null }
  335. });
  336. assertEqual(ctx.variables['$b'], 16); // $a(6) + 10
  337. assertEqual(ctx.variables['$a'], 6); // Unchanged — Set_A was not re-run
  338. });
  339. test('executeFrom with variable overrides', async () => {
  340. const engine = new Engine({
  341. version: '3.15', name: 'test',
  342. registry: { params: ['x(INT)'], vars: ['$a(INT)', '$b(INT)'] },
  343. steps: [
  344. { id: 'Set_A', target: '$a', value: '=x + 1', next: 'Set_B' },
  345. { id: 'Set_B', target: '$b', value: '=$a + 10', next: 'Stop_End' },
  346. { id: 'Stop_End' }
  347. ]
  348. });
  349. // Resume from Set_B but override $a to 100
  350. const ctx = await engine.executeFrom(
  351. { currentStepID: 'Set_B', params: { x: 5 }, variables: { '$a': 6, '$b': null } },
  352. {},
  353. { '$a': 100 } // override
  354. );
  355. assertEqual(ctx.variables['$b'], 110); // overridden $a(100) + 10
  356. });
  357. test('executeFrom with checkpoint round-trip', async () => {
  358. const checkpoints = [];
  359. const engine = new Engine({
  360. version: '3.15', name: 'test',
  361. registry: { params: ['x(INT)'], vars: ['$a(INT)', '$b(INT)'] },
  362. steps: [
  363. { id: 'Set_A', target: '$a', value: '=x + 1', next: 'Set_B' },
  364. { id: 'Set_B', target: '$b', value: '=$a + 10', next: 'Stop_End' },
  365. { id: 'Stop_End' }
  366. ]
  367. }, { onCheckpoint: cp => checkpoints.push(cp) });
  368. await engine.execute({ x: 5 });
  369. // checkpoint after Set_A: $a=6, $b=null, currentStepID should point to next
  370. const cpAfterA = JSON.parse(JSON.stringify(checkpoints[0]));
  371. // Modify: set currentStepID to Set_B to resume from there
  372. cpAfterA.currentStepID = 'Set_B';
  373. const ctx2 = await engine.executeFrom(cpAfterA);
  374. assertEqual(ctx2.variables['$b'], 16);
  375. });
  376. // ═══════════════════════════════════════════════════════════════
  377. console.log('\n── Phase 2: Parallel & Loop Resume ──');
  378. test('parallel branches: checkpoint tracks completed branches', async () => {
  379. const checkpoints = [];
  380. const engine = new Engine({
  381. version: '3.15', name: 'test',
  382. registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
  383. steps: [
  384. { id: 'Noop_Fork', children: ['Set_A', 'Set_B'], next: 'Stop_End' },
  385. { id: 'Set_A', target: '$a', value: '=1' },
  386. { id: 'Set_B', target: '$b', value: '=2' },
  387. { id: 'Stop_End' }
  388. ]
  389. }, { onCheckpoint: cp => checkpoints.push(cp) });
  390. await engine.execute({});
  391. // Should have checkpoints from both branches
  392. const lastCp = checkpoints[checkpoints.length - 1];
  393. assert(lastCp.completedBranches['Noop_Fork'], 'should track Fork branches');
  394. const forkBranches = lastCp.completedBranches['Noop_Fork'];
  395. assert(forkBranches.includes('Set_A'), 'Set_A should be completed');
  396. assert(forkBranches.includes('Set_B'), 'Set_B should be completed');
  397. });
  398. test('parallel branches: resume skips completed branches', async () => {
  399. const engine = new Engine({
  400. version: '3.15', name: 'test',
  401. registry: { params: [], vars: ['$a(INT)', '$b(INT)', '$c(INT)'] },
  402. steps: [
  403. { id: 'Noop_Fork', children: ['Set_A', 'Set_B', 'Set_C'], next: 'Stop_End' },
  404. { id: 'Set_A', target: '$a', value: '=10' },
  405. { id: 'Set_B', target: '$b', value: '=20' },
  406. { id: 'Set_C', target: '$c', value: '=30' },
  407. { id: 'Stop_End' }
  408. ]
  409. });
  410. // Simulate: branches A and B completed, C did not
  411. const ctx = await engine.executeFrom({
  412. currentStepID: 'Noop_Fork',
  413. variables: { '$a': 10, '$b': 20, '$c': null },
  414. completedSteps: ['Set_A', 'Set_B'],
  415. completedBranches: { 'Noop_Fork': ['Set_A', 'Set_B'] }
  416. });
  417. // Set_A and Set_B should not be re-run (values should stay)
  418. // Set_C should have been executed
  419. assertEqual(ctx.variables['$c'], 30);
  420. // $a and $b should be their original checkpoint values (not re-run)
  421. assertEqual(ctx.variables['$a'], 10);
  422. assertEqual(ctx.variables['$b'], 20);
  423. });
  424. test('loop: checkpoint tracks iteration progress', async () => {
  425. const checkpoints = [];
  426. const engine = new Engine({
  427. version: '3.15', name: 'test',
  428. registry: { params: [], vars: ['$items([STRING])', '$total(INT)'] },
  429. steps: [
  430. { id: 'Set_Init', target: '$items', value: '=["a","b","c","d","e"]', next: 'Set_Total' },
  431. { id: 'Set_Total', target: '$total', value: '=0', next: 'Loop_001' },
  432. { id: 'Loop_001', source: '$items', mode: 'serial', children: ['Set_Inc'], next: 'Stop_End' },
  433. { id: 'Set_Inc', target: '$total', value: '=$total + 1' },
  434. { id: 'Stop_End' }
  435. ]
  436. }, { onCheckpoint: cp => checkpoints.push(cp) });
  437. await engine.execute({});
  438. // Loop should have recorded progress
  439. const lastCp = checkpoints[checkpoints.length - 1];
  440. assertEqual(lastCp.loopProgress['Loop_001'], 5);
  441. });
  442. test('loop: resume from mid-iteration', async () => {
  443. const engine = new Engine({
  444. version: '3.15', name: 'test',
  445. registry: { params: [], vars: ['$items([STRING])', '$total(INT)'] },
  446. steps: [
  447. { id: 'Loop_001', source: '$items', mode: 'serial', children: ['Set_Inc'], next: 'Stop_End' },
  448. { id: 'Set_Inc', target: '$total', value: '=$total + 1' },
  449. { id: 'Stop_End' }
  450. ]
  451. });
  452. // Simulate: loop ran 3 out of 5 iterations, crashed
  453. const ctx = await engine.executeFrom({
  454. currentStepID: 'Loop_001',
  455. variables: { '$items': ['a', 'b', 'c', 'd', 'e'], '$total': 3 },
  456. loopProgress: { 'Loop_001': 3 } // completed 0,1,2
  457. });
  458. // Should only run iterations 3 and 4 (adding 2 more)
  459. assertEqual(ctx.variables['$total'], 5); // 3 + 2 = 5
  460. });
  461. test('loop: resume parallel loop from mid-point', async () => {
  462. const engine = new Engine({
  463. version: '3.15', name: 'test',
  464. registry: { params: [], vars: ['$items([INT])', '$sum(INT)'] },
  465. steps: [
  466. { id: 'Loop_P', source: '$items', mode: 'parallel', children: ['Set_Add'], next: 'Stop_End' },
  467. { id: 'Set_Add', target: '$sum', value: '=$sum + _item' },
  468. { id: 'Stop_End' }
  469. ]
  470. });
  471. // Simulate: parallel loop processed first 3 items (sum=1+2+3=6), crashed
  472. const ctx = await engine.executeFrom({
  473. currentStepID: 'Loop_P',
  474. variables: { '$items': [1, 2, 3, 4, 5], '$sum': 6 },
  475. loopProgress: { 'Loop_P': 3 }
  476. });
  477. // Should only run items at index 3,4 (values 4,5), adding 9 to existing 6
  478. assertEqual(ctx.variables['$sum'], 15); // 6 + 4 + 5 = 15
  479. });
  480. test('checkpoint v2 round-trip with parallel and loop state', async () => {
  481. const checkpoints = [];
  482. const engine = new Engine({
  483. version: '3.15', name: 'test',
  484. registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
  485. steps: [
  486. { id: 'Noop_Fork', children: ['Set_A', 'Set_B'], next: 'Stop_End' },
  487. { id: 'Set_A', target: '$a', value: '=1' },
  488. { id: 'Set_B', target: '$b', value: '=2' },
  489. { id: 'Stop_End' }
  490. ]
  491. }, { onCheckpoint: cp => checkpoints.push(cp) });
  492. await engine.execute({});
  493. const lastCp = checkpoints[checkpoints.length - 1];
  494. // Verify v2 format
  495. assertEqual(lastCp._version, 2);
  496. // JSON round-trip should preserve completedBranches and loopProgress
  497. const cp2 = JSON.parse(JSON.stringify(lastCp));
  498. assert(Array.isArray(cp2.completedBranches['Noop_Fork']), 'completedBranches survives JSON');
  499. assert(typeof cp2.loopProgress === 'object', 'loopProgress survives JSON');
  500. });
  501. // ═══════════════════════════════════════════════════════════════
  502. console.log('\n── v3.16 Features ──');
  503. test('validate: Loop while/source mutual exclusion', () => {
  504. const engine = new Engine({
  505. version: '3.16', name: 'test',
  506. registry: { params: [], vars: ['$x(INT)'] },
  507. steps: [
  508. { id: 'Loop_Bad', while: '=$x < 10', source: '$items', children: ['Set_Inc'], next: 'Stop_End' },
  509. { id: 'Set_Inc', target: '$x', value: '=$x + 1' },
  510. { id: 'Stop_End' }
  511. ]
  512. });
  513. const errors = engine.validate();
  514. assert(errors.some(e => e.includes('mutually exclusive')), 'should reject while + source');
  515. });
  516. test('validate: Loop without while or source is allowed (legacy compat)', () => {
  517. const engine = new Engine({
  518. version: '3.16', name: 'test',
  519. registry: { params: [], vars: [] },
  520. steps: [
  521. { id: 'Loop_Empty', children: ['Noop_Child'], next: 'Stop_End' },
  522. { id: 'Noop_Child' },
  523. { id: 'Stop_End' }
  524. ]
  525. });
  526. const errors = engine.validate();
  527. assert(!errors.some(e => e.includes('Loop_Empty')), 'Loop without while/source should be allowed');
  528. });
  529. test('validate: BREAK only inside Loop children', () => {
  530. const engine = new Engine({
  531. version: '3.16', name: 'test',
  532. registry: { params: [], vars: ['$x(INT)'] },
  533. steps: [
  534. { id: 'Set_001', target: '$x', value: '=1', next: 'BREAK' },
  535. { id: 'Stop_End' }
  536. ]
  537. });
  538. const errors = engine.validate();
  539. assert(errors.some(e => e.includes('BREAK is only valid inside Loop')), 'should reject BREAK outside loop');
  540. });
  541. test('validate: BREAK inside Loop children is OK', () => {
  542. const engine = new Engine({
  543. version: '3.16', name: 'test',
  544. registry: { params: [], vars: ['$items([INT])', '$x(INT)'] },
  545. steps: [
  546. { id: 'Loop_001', source: '$items', children: ['Set_Inc'], next: 'Stop_End' },
  547. { id: 'Set_Inc', target: '$x', value: '=$x + 1', next: 'BREAK' },
  548. { id: 'Stop_End' }
  549. ]
  550. });
  551. const errors = engine.validate();
  552. assert(!errors.some(e => e.includes('BREAK')), 'BREAK inside Loop children should be valid');
  553. });
  554. test('execute Loop while mode', async () => {
  555. const engine = new Engine({
  556. version: '3.16', name: 'test',
  557. registry: { params: [], vars: ['$x(INT)'] },
  558. steps: [
  559. { id: 'Set_Init', target: '$x', value: '=0', next: 'Loop_While' },
  560. { id: 'Loop_While', while: '=$x < 5', children: ['Set_Inc'], next: 'Stop_End' },
  561. { id: 'Set_Inc', target: '$x', value: '=$x + 1' },
  562. { id: 'Stop_End' }
  563. ]
  564. });
  565. const ctx = await engine.execute({});
  566. assertEqual(ctx.variables['$x'], 5);
  567. });
  568. test('execute Loop while with maxIterations', async () => {
  569. const engine = new Engine({
  570. version: '3.16', name: 'test',
  571. registry: { params: [], vars: ['$x(INT)'] },
  572. steps: [
  573. { id: 'Set_Init', target: '$x', value: '=0', next: 'Loop_While' },
  574. { id: 'Loop_While', while: '=$x < 100', maxIterations: 3, children: ['Set_Inc'], next: 'Stop_End' },
  575. { id: 'Set_Inc', target: '$x', value: '=$x + 1' },
  576. { id: 'Stop_End' }
  577. ]
  578. });
  579. const ctx = await engine.execute({});
  580. assertEqual(ctx.variables['$x'], 3); // capped at 3
  581. });
  582. test('execute BREAK exits loop early (serial)', async () => {
  583. const engine = new Engine({
  584. version: '3.16', name: 'test',
  585. registry: { params: [], vars: ['$items([INT])', '$sum(INT)'] },
  586. steps: [
  587. { id: 'Set_Init', target: '$items', value: '=[1,2,3,4,5]', next: 'Set_Sum' },
  588. { id: 'Set_Sum', target: '$sum', value: '=0', next: 'Loop_001' },
  589. { id: 'Loop_001', source: '$items', mode: 'serial', children: ['Check_Break'], next: 'Stop_End' },
  590. { id: 'Check_Break', condition: '=_item >= 4', if_true: 'Noop_Break', if_false: 'Set_Add' },
  591. { id: 'Noop_Break', next: 'BREAK' },
  592. { id: 'Set_Add', target: '$sum', value: '=$sum + _item', next: 'RETURN' },
  593. { id: 'Stop_End' }
  594. ]
  595. });
  596. const ctx = await engine.execute({});
  597. assertEqual(ctx.variables['$sum'], 6); // 1+2+3 = 6, item 4 triggers BREAK
  598. });
  599. test('execute BREAK in while loop', async () => {
  600. const engine = new Engine({
  601. version: '3.16', name: 'test',
  602. registry: { params: [], vars: ['$x(INT)'] },
  603. steps: [
  604. { id: 'Set_Init', target: '$x', value: '=0', next: 'Loop_While' },
  605. { id: 'Loop_While', while: '=true', maxIterations: 100, children: ['Set_Inc', 'Check_Break'], next: 'Stop_End' },
  606. { id: 'Set_Inc', target: '$x', value: '=$x + 1', next: 'Check_Break' },
  607. { id: 'Check_Break', condition: '=$x >= 3', if_true: 'Noop_Break', if_false: 'RETURN' },
  608. { id: 'Noop_Break', next: 'BREAK' },
  609. { id: 'Stop_End' }
  610. ]
  611. });
  612. const ctx = await engine.execute({});
  613. assertEqual(ctx.variables['$x'], 3); // BREAK at x=3
  614. });
  615. test('execute Loop source with maxIterations cap', async () => {
  616. const engine = new Engine({
  617. version: '3.16', name: 'test',
  618. registry: { params: [], vars: ['$items([INT])', '$total(INT)'] },
  619. steps: [
  620. { id: 'Set_Init', target: '$items', value: '=[1,2,3,4,5]', next: 'Set_Total' },
  621. { id: 'Set_Total', target: '$total', value: '=0', next: 'Loop_001' },
  622. { id: 'Loop_001', source: '$items', mode: 'serial', maxIterations: 3, children: ['Set_Inc'], next: 'Stop_End' },
  623. { id: 'Set_Inc', target: '$total', value: '=$total + 1' },
  624. { id: 'Stop_End' }
  625. ]
  626. });
  627. const ctx = await engine.execute({});
  628. assertEqual(ctx.variables['$total'], 3); // only 3 of 5 items
  629. });
  630. test('LLM model field: provider/modelId parsing', async () => {
  631. let capturedParams = null;
  632. const engine = new Engine({
  633. version: '3.16', name: 'test',
  634. registry: { params: [], vars: ['$out(STRING)'] },
  635. steps: [
  636. { id: 'LLM_Test', model: 'anthropic/claude-sonnet-4-20250514', in: { messages: [{ role: 'user', content: 'hi' }] }, out: '$out', next: 'Stop_End' },
  637. { id: 'Stop_End' }
  638. ]
  639. });
  640. const mockLLM = {
  641. call: async (params) => {
  642. capturedParams = { ...params };
  643. return { content: 'hello', model: 'claude-sonnet-4-20250514', usage: {} };
  644. }
  645. };
  646. await engine.execute({}, { llm: mockLLM });
  647. assertEqual(capturedParams._provider, 'anthropic');
  648. assertEqual(capturedParams.model, 'claude-sonnet-4-20250514');
  649. });
  650. test('LLM model field: provider-only parsing', async () => {
  651. let capturedParams = null;
  652. const engine = new Engine({
  653. version: '3.16', name: 'test',
  654. registry: { params: [], vars: ['$out(STRING)'] },
  655. steps: [
  656. { id: 'LLM_Test', model: 'openai', in: { messages: [{ role: 'user', content: 'hi' }] }, out: '$out', next: 'Stop_End' },
  657. { id: 'Stop_End' }
  658. ]
  659. });
  660. const mockLLM = {
  661. call: async (params) => {
  662. capturedParams = { ...params };
  663. return { content: 'hello', model: 'gpt-4o', usage: {} };
  664. }
  665. };
  666. await engine.execute({}, { llm: mockLLM });
  667. assertEqual(capturedParams._provider, 'openai');
  668. assert(!capturedParams.model, 'model should not be set for provider-only');
  669. });
  670. test('BREAK in _findEntryNodeIDs does not add as reference', () => {
  671. const engine = new Engine({
  672. version: '3.16', name: 'test',
  673. registry: { params: [], vars: ['$items([INT])'] },
  674. steps: [
  675. { id: 'Loop_001', source: '$items', children: ['Set_Inc'], next: 'Stop_End' },
  676. { id: 'Set_Inc', target: '$x', value: '=1', next: 'BREAK' },
  677. { id: 'Stop_End' }
  678. ]
  679. });
  680. const entries = engine._findEntryNodeIDs();
  681. assertEqual(entries.length, 1);
  682. assertEqual(entries[0], 'Loop_001');
  683. });
  684. // ═══════════════════════════════════════════════════════════════
  685. console.log('\n── User-Initiated Pause ──');
  686. test('ctx.pause() stops execution after current step', async () => {
  687. const events = [];
  688. const engine = new Engine({
  689. version: '3.16', name: 'pause-test',
  690. registry: { params: [], vars: ['$a(STRING)', '$b(STRING)'] },
  691. steps: [
  692. { id: 'Set_A', target: '$a', value: 'hello', next: 'Set_B' },
  693. { id: 'Set_B', target: '$b', value: 'world', next: 'Stop_End' },
  694. { id: 'Stop_End' }
  695. ]
  696. }, {
  697. onEvent: (e) => events.push(e),
  698. onCheckpoint: () => {}
  699. });
  700. // Hook: pause after Set_A step_done, before Set_B begins
  701. engine._onEvent = (e) => {
  702. events.push(e);
  703. };
  704. // Intercept: when engine tries to execute Set_B, call ctx.pause() first
  705. const origExec = engine.executeStep.bind(engine);
  706. let ctx_ref = null;
  707. engine.executeStep = async function(ctx, step) {
  708. ctx_ref = ctx;
  709. if (step.id === 'Set_B') ctx.pause();
  710. return origExec(ctx, step);
  711. };
  712. const ctx = await engine.execute();
  713. assertEqual(ctx.status, 'paused', 'Status should be paused');
  714. assertEqual(ctx.variables['$a'], 'hello', '$a should be set');
  715. assertEqual(ctx.variables['$b'], null, '$b should NOT be set (paused before)');
  716. // workflow_paused event should be emitted
  717. const pauseEvt = events.find(e => e.type === 'workflow_paused');
  718. assert(pauseEvt, 'workflow_paused event should be emitted');
  719. });
  720. test('pause → checkpoint → resume via executeFrom', async () => {
  721. const engine = new Engine({
  722. version: '3.16', name: 'pause-resume',
  723. registry: { params: [], vars: ['$a(STRING)', '$b(STRING)'] },
  724. steps: [
  725. { id: 'Set_A', target: '$a', value: 'hello', next: 'Set_B' },
  726. { id: 'Set_B', target: '$b', value: 'world', next: 'Stop_End' },
  727. { id: 'Stop_End' }
  728. ]
  729. });
  730. // Pause before Set_B
  731. const origExec = engine.executeStep.bind(engine);
  732. engine.executeStep = async function(ctx, step) {
  733. if (step.id === 'Set_B') ctx.pause();
  734. return origExec(ctx, step);
  735. };
  736. const ctx = await engine.execute();
  737. assertEqual(ctx.status, 'paused');
  738. // Take checkpoint and resume from Set_B
  739. const cp = ctx.checkpoint();
  740. cp.currentStepID = 'Set_B';
  741. // Fresh engine (no hook) to resume
  742. const engine2 = new Engine({
  743. version: '3.16', name: 'pause-resume',
  744. registry: { params: [], vars: ['$a(STRING)', '$b(STRING)'] },
  745. steps: [
  746. { id: 'Set_A', target: '$a', value: 'hello', next: 'Set_B' },
  747. { id: 'Set_B', target: '$b', value: 'world', next: 'Stop_End' },
  748. { id: 'Stop_End' }
  749. ]
  750. });
  751. const ctx2 = await engine2.executeFrom(cp, {});
  752. assertEqual(ctx2.variables['$a'], 'hello', '$a preserved from checkpoint');
  753. assertEqual(ctx2.variables['$b'], 'world', '$b set after resume');
  754. });
  755. test('pause → edit overrides → resume with modified input', async () => {
  756. const engine = new Engine({
  757. version: '3.16', name: 'pause-edit-resume',
  758. registry: { params: [], vars: ['$msg(STRING)', '$result(STRING)'] },
  759. steps: [
  760. { id: 'Set_Msg', target: '$msg', value: 'original', next: 'Set_Result' },
  761. { id: 'Set_Result', target: '$result', value: '=$msg', next: 'Stop_End' },
  762. { id: 'Stop_End' }
  763. ]
  764. });
  765. // Pause before Set_Result
  766. const origExec = engine.executeStep.bind(engine);
  767. engine.executeStep = async function(ctx, step) {
  768. if (step.id === 'Set_Result') ctx.pause();
  769. return origExec(ctx, step);
  770. };
  771. const ctx = await engine.execute();
  772. assertEqual(ctx.status, 'paused');
  773. assertEqual(ctx.variables['$msg'], 'original');
  774. // User edits $msg before resuming
  775. const cp = ctx.checkpoint();
  776. cp.currentStepID = 'Set_Result';
  777. const engine2 = new Engine({
  778. version: '3.16', name: 'pause-edit-resume',
  779. registry: { params: [], vars: ['$msg(STRING)', '$result(STRING)'] },
  780. steps: [
  781. { id: 'Set_Msg', target: '$msg', value: 'original', next: 'Set_Result' },
  782. { id: 'Set_Result', target: '$result', value: '=$msg', next: 'Stop_End' },
  783. { id: 'Stop_End' }
  784. ]
  785. });
  786. const ctx2 = await engine2.executeFrom(cp, {}, { '$msg': 'user-edited' });
  787. assertEqual(ctx2.variables['$msg'], 'user-edited', '$msg should be overridden');
  788. assertEqual(ctx2.variables['$result'], 'user-edited', '$result should use edited $msg');
  789. });
  790. // ═══════════════════════════════════════════════════════════════
  791. console.log('\n── Results ──');
  792. console.log(`\n ${passed} passed, ${failed} failed\n`);
  793. process.exit(failed > 0 ? 1 : 0);