comprehensive.js 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570
  1. #!/usr/bin/env node
  2. /**
  3. * VL Workflow Engine — Comprehensive Test Suite
  4. * Tests all node types, complex workflows, edge cases per Spec v3.15
  5. */
  6. const { Engine, ExpressionEvaluator, ExecutionContext, ChildExecutionContext,
  7. Registry, parseParamDeclaration, parseVariableDeclaration, parseServiceSignature,
  8. RunEventType, ExecutionStatus, ParallelErrorStrategy,
  9. toBool, toFloat, isV310OrLater, buildErrorMap, LLMError,
  10. applyOutputMapping, emitEvent } = require('../index');
  11. let passed = 0, failed = 0, skipped = 0;
  12. const failures = [];
  13. function test(name, fn) {
  14. try {
  15. const result = fn();
  16. if (result && typeof result.then === 'function') {
  17. return result.then(() => {
  18. console.log(` ✓ ${name}`);
  19. passed++;
  20. }).catch(e => {
  21. console.log(` ✗ ${name}: ${e.message}`);
  22. failures.push({ name, error: e.message });
  23. failed++;
  24. });
  25. }
  26. console.log(` ✓ ${name}`);
  27. passed++;
  28. } catch (e) {
  29. console.log(` ✗ ${name}: ${e.message}`);
  30. failures.push({ name, error: e.message });
  31. failed++;
  32. }
  33. }
  34. function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
  35. function assertEqual(a, b, msg) {
  36. if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
  37. }
  38. function assertDeepEqual(a, b, msg) {
  39. const as = JSON.stringify(a), bs = JSON.stringify(b);
  40. if (as !== bs) throw new Error(msg || `Expected ${bs}, got ${as}`);
  41. }
  42. // Helper: create a simple mock adapter
  43. function mockAdapters(overrides = {}) {
  44. return {
  45. service: { call: async (name, params) => overrides.serviceResult || { ok: true } },
  46. api: { call: async (apiDef, params) => overrides.apiResult || { status: 'ok' } },
  47. component: { call: async (id, params) => overrides.componentResult || {} },
  48. llm: { call: async (params, onToken, callbacks) => overrides.llmResult || { content: 'hello', usage: { input_tokens: 10, output_tokens: 5 }, model: 'claude-opus-4-6' } },
  49. file: {
  50. write: async (path, content, mode) => { (overrides.writtenFiles || []).push({ path, content: content.toString(), mode }); },
  51. read: async (path) => Buffer.from(overrides.fileContent || ''),
  52. unzip: async (data) => overrides.unzipEntries || []
  53. },
  54. doc: { get: async (id) => overrides.docs?.[id] || `Doc ${id} content` },
  55. ...overrides.adapterOverrides
  56. };
  57. }
  58. // Helper: collect events
  59. function collectEvents(ctx) {
  60. const events = [];
  61. ctx.onEvent(e => events.push(e));
  62. return events;
  63. }
  64. async function runTests() {
  65. // ═══════════════════════════════════════════════════════════════
  66. console.log('\n══ Expression Evaluator — Advanced ══');
  67. await test('multiplication and division', () => {
  68. const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 12 } });
  69. const ev = new ExpressionEvaluator(ctx);
  70. assertEqual(ev.evaluateValue('=x * 3'), 36);
  71. assertEqual(ev.evaluateValue('=x / 4'), 3);
  72. });
  73. await test('operator precedence: * before +', () => {
  74. const ctx = new ExecutionContext({ workflowID: 'test', params: {} });
  75. const ev = new ExpressionEvaluator(ctx);
  76. assertEqual(ev.evaluateValue('=2 + 3 * 4'), 14);
  77. });
  78. await test('parenthesized expressions', () => {
  79. const ctx = new ExecutionContext({ workflowID: 'test', params: {} });
  80. const ev = new ExpressionEvaluator(ctx);
  81. assertEqual(ev.evaluateValue('=(2 + 3) * 4'), 20);
  82. });
  83. await test('nested ternary', () => {
  84. const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 2 } });
  85. const ev = new ExpressionEvaluator(ctx);
  86. assertEqual(ev.evaluateValue('=x == 1 ? "one" : x == 2 ? "two" : "other"'), 'two');
  87. });
  88. await test('comparison operators: >, <, >=, <=', () => {
  89. const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 10 } });
  90. const ev = new ExpressionEvaluator(ctx);
  91. assertEqual(ev.evaluateValue('=x > 5'), true);
  92. assertEqual(ev.evaluateValue('=x < 5'), false);
  93. assertEqual(ev.evaluateValue('=x >= 10'), true);
  94. assertEqual(ev.evaluateValue('=x <= 9'), false);
  95. });
  96. await test('strict equality ===', () => {
  97. const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 5 } });
  98. const ev = new ExpressionEvaluator(ctx);
  99. assertEqual(ev.evaluateValue('=x === 5'), true);
  100. // "5" == 5 is true, but "5" === 5 is false
  101. const ctx2 = new ExecutionContext({ workflowID: 'test', params: { x: '5' } });
  102. const ev2 = new ExpressionEvaluator(ctx2);
  103. assertEqual(ev2.evaluateValue('=x === 5'), false);
  104. assertEqual(ev2.evaluateValue('=x == 5'), true);
  105. });
  106. await test('not equal != and !==', () => {
  107. const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 5 } });
  108. const ev = new ExpressionEvaluator(ctx);
  109. assertEqual(ev.evaluateValue('=x != 3'), true);
  110. assertEqual(ev.evaluateValue('=x !== 5'), false);
  111. });
  112. await test('null/nil literal', () => {
  113. const ctx = new ExecutionContext({ workflowID: 'test' });
  114. const ev = new ExpressionEvaluator(ctx);
  115. assertEqual(ev.evaluateValue('=null'), null);
  116. assertEqual(ev.evaluateValue('=nil'), null);
  117. });
  118. await test('JSON object literal', () => {
  119. const ctx = new ExecutionContext({ workflowID: 'test' });
  120. const ev = new ExpressionEvaluator(ctx);
  121. const result = ev.evaluateValue('={"a":1,"b":"hello"}');
  122. assertEqual(result.a, 1);
  123. assertEqual(result.b, 'hello');
  124. });
  125. await test('JSON array literal', () => {
  126. const ctx = new ExecutionContext({ workflowID: 'test' });
  127. const ev = new ExpressionEvaluator(ctx);
  128. const result = ev.evaluateValue('=[1,2,3]');
  129. assertEqual(result.length, 3);
  130. assertEqual(result[1], 2);
  131. });
  132. await test('SYSVAR reference', () => {
  133. const ctx = new ExecutionContext({ workflowID: 'test', systemVars: { apiKey: 'secret123' } });
  134. const ev = new ExpressionEvaluator(ctx);
  135. assertEqual(ev.evaluateValue('=SYSVAR.apiKey'), 'secret123');
  136. });
  137. await test('_local variables (_item, _index)', () => {
  138. const ctx = new ExecutionContext({ workflowID: 'test' });
  139. ctx.setVariable('_item', { name: 'test', value: 42 });
  140. ctx.setVariable('_index', 3);
  141. const ev = new ExpressionEvaluator(ctx);
  142. assertEqual(ev.evaluateValue('=_item.name'), 'test');
  143. assertEqual(ev.evaluateValue('=_item.value'), 42);
  144. assertEqual(ev.evaluateValue('=_index'), 3);
  145. });
  146. await test('string concatenation with variables', () => {
  147. const ctx = new ExecutionContext({ workflowID: 'test', params: { name: 'World' } });
  148. const ev = new ExpressionEvaluator(ctx);
  149. assertEqual(ev.evaluateValue('="Hello, " + name + "!"'), 'Hello, World!');
  150. });
  151. await test('complex expression: logical + comparison', () => {
  152. const ctx = new ExecutionContext({ workflowID: 'test', params: { age: 25, hasLicense: true } });
  153. const ev = new ExpressionEvaluator(ctx);
  154. assertEqual(ev.evaluateValue('=age >= 18 && hasLicense'), true);
  155. assertEqual(ev.evaluateValue('=age < 18 || !hasLicense'), false);
  156. });
  157. await test('setVariable with array index via expression', () => {
  158. const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$arr': [null, null, null] } });
  159. ctx.setVariable('_index', 1);
  160. const ev = new ExpressionEvaluator(ctx);
  161. ev.setVariable('$arr[_index]', 'value_at_1');
  162. assertEqual(ctx.variables['$arr'][1], 'value_at_1');
  163. });
  164. await test('setVariable auto-creates intermediate objects', () => {
  165. const ctx = new ExecutionContext({ workflowID: 'test', variables: {} });
  166. const ev = new ExpressionEvaluator(ctx);
  167. ev.setVariable('$data', {});
  168. ev.setVariable('$data.nested.deep', 42);
  169. assertEqual(ctx.variables['$data'].nested.deep, 42);
  170. });
  171. await test('setVariable auto-creates array with numeric index', () => {
  172. const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$list': null } });
  173. const ev = new ExpressionEvaluator(ctx);
  174. ev.setVariable('$list[0]', 'first');
  175. ev.setVariable('$list[1]', 'second');
  176. assert(Array.isArray(ctx.variables['$list']), '$list should be array');
  177. assertEqual(ctx.variables['$list'][0], 'first');
  178. assertEqual(ctx.variables['$list'][1], 'second');
  179. });
  180. await test('type conversion: OBJECT var from string', () => {
  181. const ctx = new ExecutionContext({
  182. workflowID: 'test',
  183. variables: { '$obj': null },
  184. varTypes: { '$obj': 'OBJECT' }
  185. });
  186. const ev = new ExpressionEvaluator(ctx);
  187. ev.setVariable('$obj', '{"a":1}');
  188. assertDeepEqual(ctx.variables['$obj'], { a: 1 });
  189. });
  190. await test('type conversion: STRING var from object', () => {
  191. const ctx = new ExecutionContext({
  192. workflowID: 'test',
  193. variables: { '$str': null },
  194. varTypes: { '$str': 'STRING' }
  195. });
  196. const ev = new ExpressionEvaluator(ctx);
  197. ev.setVariable('$str', { a: 1 });
  198. assertEqual(ctx.variables['$str'], '{"a":1}');
  199. });
  200. await test('toBool edge cases', () => {
  201. assertEqual(toBool(null), false);
  202. assertEqual(toBool(undefined), false);
  203. assertEqual(toBool(0), false);
  204. assertEqual(toBool(''), false);
  205. assertEqual(toBool(false), false);
  206. assertEqual(toBool(1), true);
  207. assertEqual(toBool('hello'), true);
  208. assertEqual(toBool([]), true); // empty array is truthy
  209. assertEqual(toBool({}), true); // empty object is truthy
  210. });
  211. await test('toFloat edge cases', () => {
  212. assertEqual(toFloat(42), 42);
  213. assertEqual(toFloat('3.14'), 3.14);
  214. assertEqual(toFloat('abc'), 0);
  215. assertEqual(toFloat(true), 1);
  216. assertEqual(toFloat(false), 0);
  217. assertEqual(toFloat(null), 0);
  218. assertEqual(toFloat(undefined), 0);
  219. });
  220. // ═══════════════════════════════════════════════════════════════
  221. console.log('\n══ Registry — Advanced ══');
  222. await test('parseServiceSignature with multiple params and returns', () => {
  223. const s = parseServiceSignature('ApprovalService(form(OBJECT), policy(STRING)) RETURN decision(STRING), comment(STRING)');
  224. assertEqual(s.name, 'ApprovalService');
  225. assertEqual(s.parameters.length, 2);
  226. assertEqual(s.returns.length, 2);
  227. assertEqual(s.parameters[1].name, 'policy');
  228. assertEqual(s.returns[0].name, 'decision');
  229. });
  230. await test('Registry validates duplicate services', () => {
  231. const { validateRegistry } = require('../lib/registry');
  232. const errors = validateRegistry({
  233. services: ['Svc1(a(STRING)) RETURN ok(BOOL)', 'Svc1(b(INT)) RETURN ok(BOOL)'],
  234. params: [], vars: []
  235. });
  236. assert(errors.some(e => e.includes('Duplicate')), 'Should detect duplicate services');
  237. });
  238. await test('Registry validates API methods', () => {
  239. const { validateRegistry } = require('../lib/registry');
  240. const errors = validateRegistry({
  241. apis: [{ id: 'bad', method: 'INVALID', url: '/test' }],
  242. params: [], vars: [], services: []
  243. });
  244. assert(errors.some(e => e.includes('method')), 'Should detect invalid API method');
  245. });
  246. await test('Registry schema lookup', () => {
  247. const reg = new Registry({
  248. schemas: {
  249. 'PlanSchema': { type: 'object', properties: { title: { type: 'string' } } }
  250. }
  251. });
  252. assert(reg.hasSchema('PlanSchema'));
  253. assert(!reg.hasSchema('NonExistent'));
  254. const schema = reg.getSchema('PlanSchema');
  255. assertEqual(schema.type, 'object');
  256. });
  257. await test('Registry file path validation', () => {
  258. const reg = new Registry({
  259. files: {
  260. inputs: ['Process/PRD.json', 'Process/Rules/*'],
  261. artifacts: ['Process/Artifacts/*']
  262. }
  263. });
  264. assert(reg.isInputPathAllowed('Process/PRD.json'));
  265. assert(reg.isInputPathAllowed('Process/Rules/rule1.txt'));
  266. assert(!reg.isInputPathAllowed('Process/Other/file.txt'));
  267. assert(reg.isArtifactPathAllowed('Process/Artifacts/out.json'));
  268. });
  269. // ═══════════════════════════════════════════════════════════════
  270. console.log('\n══ Engine Validation ══');
  271. await test('validate: unsupported version', () => {
  272. const engine = new Engine({ version: '2.0', name: 'test', registry: { params: [], vars: [] }, steps: [{ id: 'Stop_End' }] });
  273. const errors = engine.validate();
  274. assert(errors.some(e => e.includes('Unsupported version')));
  275. });
  276. await test('validate: missing name', () => {
  277. const engine = new Engine({ version: '3.15', registry: { params: [], vars: [] }, steps: [{ id: 'Stop_End' }] });
  278. const errors = engine.validate();
  279. assert(errors.some(e => e.includes('name')));
  280. });
  281. await test('validate: empty steps', () => {
  282. const engine = new Engine({ version: '3.15', name: 'test', registry: { params: [], vars: [] }, steps: [] });
  283. const errors = engine.validate();
  284. assert(errors.some(e => e.includes('step')));
  285. });
  286. await test('validate: duplicate step IDs', () => {
  287. const engine = new Engine({
  288. version: '3.15', name: 'test',
  289. registry: { params: [], vars: [] },
  290. steps: [{ id: 'Set_001', target: '$x', value: '=1' }, { id: 'Set_001', target: '$y', value: '=2' }]
  291. });
  292. const errors = engine.validate();
  293. assert(errors.some(e => e.includes('Duplicate')));
  294. });
  295. await test('validate: Stop_* with next should error', () => {
  296. const engine = new Engine({
  297. version: '3.15', name: 'test',
  298. registry: { params: [], vars: [] },
  299. steps: [{ id: 'Stop_End', next: 'Set_001' }, { id: 'Set_001', target: '$x', value: '=1' }]
  300. });
  301. const errors = engine.validate();
  302. assert(errors.some(e => e.includes('Stop_*')));
  303. });
  304. await test('validate: unknown step reference in next', () => {
  305. const engine = new Engine({
  306. version: '3.15', name: 'test',
  307. registry: { params: [], vars: [] },
  308. steps: [{ id: 'Set_001', target: '$x', value: '=1', next: 'NonExistent' }]
  309. });
  310. const errors = engine.validate();
  311. assert(errors.some(e => e.includes('NonExistent')));
  312. });
  313. await test('validate: RETURN is valid next', () => {
  314. const engine = new Engine({
  315. version: '3.15', name: 'test',
  316. registry: { params: [], vars: [] },
  317. steps: [{ id: 'Set_001', target: '$x', value: '=1', next: 'RETURN' }]
  318. });
  319. const errors = engine.validate();
  320. assert(!errors.some(e => e.includes('RETURN')));
  321. });
  322. await test('validate: custom handler bypasses type check', () => {
  323. const engine = new Engine({
  324. version: '3.15', name: 'test',
  325. registry: { params: [], vars: [] },
  326. steps: [{ id: 'Custom_DoSomething', next: 'Stop_End' }, { id: 'Stop_End' }]
  327. }, { customHandlers: { Custom: async () => {} } });
  328. const errors = engine.validate();
  329. assert(!errors.some(e => e.includes('Unknown step type')));
  330. });
  331. // ═══════════════════════════════════════════════════════════════
  332. console.log('\n══ Set_* Step — Advanced ══');
  333. await test('Set_* with expression computation', async () => {
  334. const engine = new Engine({
  335. version: '3.15', name: 'test',
  336. registry: { params: ['a(INT)', 'b(INT)'], vars: ['$sum(INT)', '$product(INT)'] },
  337. steps: [
  338. { id: 'Set_Sum', target: '$sum', value: '=a + b', next: 'Set_Product' },
  339. { id: 'Set_Product', target: '$product', value: '=a * b', next: 'Stop_End' },
  340. { id: 'Stop_End' }
  341. ]
  342. });
  343. const ctx = await engine.execute({ a: 7, b: 3 });
  344. assertEqual(ctx.variables['$sum'], 10);
  345. assertEqual(ctx.variables['$product'], 21);
  346. });
  347. await test('Set_* deep path write', async () => {
  348. const engine = new Engine({
  349. version: '3.15', name: 'test',
  350. registry: { params: [], vars: ['$config(OBJECT)'] },
  351. steps: [
  352. { id: 'Set_Init', target: '$config', value: '={}', next: 'Set_Nested' },
  353. { id: 'Set_Nested', target: '$config.db.host', value: '="localhost"', next: 'Set_Port' },
  354. { id: 'Set_Port', target: '$config.db.port', value: '=5432', next: 'Stop_End' },
  355. { id: 'Stop_End' }
  356. ]
  357. });
  358. const ctx = await engine.execute({});
  359. assertEqual(ctx.variables['$config'].db.host, 'localhost');
  360. assertEqual(ctx.variables['$config'].db.port, 5432);
  361. });
  362. // ═══════════════════════════════════════════════════════════════
  363. console.log('\n══ Branch_* — Advanced ══');
  364. await test('Branch_* with multiple cases', async () => {
  365. const engine = new Engine({
  366. version: '3.15', name: 'test',
  367. registry: { params: ['level(STRING)'], vars: ['$out(STRING)'] },
  368. steps: [
  369. { id: 'Branch_Level', cases: [
  370. ['=level == "admin"', 'Set_Admin'],
  371. ['=level == "mod"', 'Set_Mod'],
  372. ['ELSE', 'Set_User']
  373. ], next: 'Stop_End' },
  374. { id: 'Set_Admin', target: '$out', value: '="full access"', next: 'RETURN' },
  375. { id: 'Set_Mod', target: '$out', value: '="moderate access"', next: 'RETURN' },
  376. { id: 'Set_User', target: '$out', value: '="limited access"', next: 'RETURN' },
  377. { id: 'Stop_End' }
  378. ]
  379. });
  380. const ctx1 = await engine.execute({ level: 'admin' });
  381. assertEqual(ctx1.variables['$out'], 'full access');
  382. const ctx2 = await engine.execute({ level: 'mod' });
  383. assertEqual(ctx2.variables['$out'], 'moderate access');
  384. const ctx3 = await engine.execute({ level: 'guest' });
  385. assertEqual(ctx3.variables['$out'], 'limited access');
  386. });
  387. await test('Branch_* with branch chain (multi-step branch)', async () => {
  388. const engine = new Engine({
  389. version: '3.15', name: 'test',
  390. registry: { params: ['amount(INT)'], vars: ['$approval(STRING)', '$level(INT)'] },
  391. steps: [
  392. { id: 'Branch_Amount', cases: [
  393. ['=amount < 100', 'Set_AutoApprove'],
  394. ['ELSE', 'Set_NeedReview']
  395. ], next: 'Stop_End' },
  396. { id: 'Set_AutoApprove', target: '$approval', value: '="auto"', next: 'Set_Level1' },
  397. { id: 'Set_Level1', target: '$level', value: '=1', next: 'RETURN' },
  398. { id: 'Set_NeedReview', target: '$approval', value: '="manual"', next: 'Set_Level2' },
  399. { id: 'Set_Level2', target: '$level', value: '=2', next: 'RETURN' },
  400. { id: 'Stop_End' }
  401. ]
  402. });
  403. const ctx1 = await engine.execute({ amount: 50 });
  404. assertEqual(ctx1.variables['$approval'], 'auto');
  405. assertEqual(ctx1.variables['$level'], 1);
  406. const ctx2 = await engine.execute({ amount: 500 });
  407. assertEqual(ctx2.variables['$approval'], 'manual');
  408. assertEqual(ctx2.variables['$level'], 2);
  409. });
  410. await test('nested Branch inside Branch', async () => {
  411. const engine = new Engine({
  412. version: '3.15', name: 'test',
  413. registry: { params: ['role(STRING)', 'premium(BOOL)'], vars: ['$result(STRING)'] },
  414. steps: [
  415. { id: 'Branch_Role', cases: [
  416. ['=role == "admin"', 'Set_Admin'],
  417. ['ELSE', 'Branch_Premium']
  418. ], next: 'Stop_End' },
  419. { id: 'Set_Admin', target: '$result', value: '="admin"', next: 'RETURN' },
  420. { id: 'Branch_Premium', cases: [
  421. ['=premium', 'Set_Premium'],
  422. ['ELSE', 'Set_Free']
  423. ], next: 'RETURN' },
  424. { id: 'Set_Premium', target: '$result', value: '="premium_user"', next: 'RETURN' },
  425. { id: 'Set_Free', target: '$result', value: '="free_user"', next: 'RETURN' },
  426. { id: 'Stop_End' }
  427. ]
  428. });
  429. const ctx1 = await engine.execute({ role: 'admin', premium: false });
  430. assertEqual(ctx1.variables['$result'], 'admin');
  431. const ctx2 = await engine.execute({ role: 'user', premium: true });
  432. assertEqual(ctx2.variables['$result'], 'premium_user');
  433. const ctx3 = await engine.execute({ role: 'user', premium: false });
  434. assertEqual(ctx3.variables['$result'], 'free_user');
  435. });
  436. // ═══════════════════════════════════════════════════════════════
  437. console.log('\n══ Loop_* — Advanced ══');
  438. await test('Loop serial with accumulation', async () => {
  439. const engine = new Engine({
  440. version: '3.15', name: 'test',
  441. registry: { params: [], vars: ['$numbers([INT])', '$sum(INT)'] },
  442. steps: [
  443. { id: 'Set_Numbers', target: '$numbers', value: '=[10, 20, 30, 40]', next: 'Set_Sum' },
  444. { id: 'Set_Sum', target: '$sum', value: '=0', next: 'Loop_Sum' },
  445. { id: 'Loop_Sum', source: '$numbers', mode: 'serial', children: ['Set_Add'], next: 'Stop_End' },
  446. { id: 'Set_Add', target: '$sum', value: '=$sum + _item' },
  447. { id: 'Stop_End' }
  448. ]
  449. });
  450. const ctx = await engine.execute({});
  451. assertEqual(ctx.variables['$sum'], 100);
  452. });
  453. await test('Loop serial with index-based write', async () => {
  454. const engine = new Engine({
  455. version: '3.15', name: 'test',
  456. registry: { params: [], vars: ['$items([STRING])', '$results([STRING])'] },
  457. steps: [
  458. { id: 'Set_Items', target: '$items', value: '=["a","b","c"]', next: 'Set_Results' },
  459. { id: 'Set_Results', target: '$results', value: '=[]', next: 'Loop_Process' },
  460. { id: 'Loop_Process', source: '$items', mode: 'serial', children: ['Set_Result'], next: 'Stop_End' },
  461. { id: 'Set_Result', target: '$results[_index]', value: '=_item + "_processed"' },
  462. { id: 'Stop_End' }
  463. ]
  464. });
  465. const ctx = await engine.execute({});
  466. assertDeepEqual(ctx.variables['$results'], ['a_processed', 'b_processed', 'c_processed']);
  467. });
  468. await test('Loop parallel execution', async () => {
  469. const engine = new Engine({
  470. version: '3.15', name: 'test',
  471. registry: { params: [], vars: ['$items([STRING])', '$results([STRING])'] },
  472. steps: [
  473. { id: 'Set_Items', target: '$items', value: '=["x","y","z"]', next: 'Set_Results' },
  474. { id: 'Set_Results', target: '$results', value: '=[null, null, null]', next: 'Loop_Par' },
  475. { id: 'Loop_Par', source: '$items', mode: 'parallel', children: ['Set_ParResult'], next: 'Stop_End' },
  476. { id: 'Set_ParResult', target: '$results[_index]', value: '=_item + "_done"' },
  477. { id: 'Stop_End' }
  478. ]
  479. });
  480. const ctx = await engine.execute({});
  481. // All should be set (order may vary in parallel but index-based write is safe)
  482. assertEqual(ctx.variables['$results'][0], 'x_done');
  483. assertEqual(ctx.variables['$results'][1], 'y_done');
  484. assertEqual(ctx.variables['$results'][2], 'z_done');
  485. });
  486. await test('Loop with empty source should skip', async () => {
  487. const engine = new Engine({
  488. version: '3.15', name: 'test',
  489. registry: { params: [], vars: ['$items([STRING])', '$flag(BOOL)'] },
  490. steps: [
  491. { id: 'Set_Items', target: '$items', value: '=[]', next: 'Loop_Empty' },
  492. { id: 'Loop_Empty', source: '$items', mode: 'serial', children: ['Set_Flag'], next: 'Set_Done' },
  493. { id: 'Set_Flag', target: '$flag', value: '=true' },
  494. { id: 'Set_Done', target: '$flag', value: '=false', next: 'Stop_End' },
  495. { id: 'Stop_End' }
  496. ]
  497. });
  498. const ctx = await engine.execute({});
  499. // Flag should be false because loop body never executed, then Set_Done set it to false
  500. assertEqual(ctx.variables['$flag'], false);
  501. });
  502. await test('Loop with object items', async () => {
  503. const engine = new Engine({
  504. version: '3.15', name: 'test',
  505. registry: { params: [], vars: ['$files([OBJECT])', '$names([STRING])'] },
  506. steps: [
  507. { id: 'Set_Files', target: '$files', value: '=[{"name":"a.ts","size":100},{"name":"b.ts","size":200}]', next: 'Set_Names' },
  508. { id: 'Set_Names', target: '$names', value: '=[]', next: 'Loop_Extract' },
  509. { id: 'Loop_Extract', source: '$files', mode: 'serial', children: ['Set_Name'], next: 'Stop_End' },
  510. { id: 'Set_Name', target: '$names[_index]', value: '=_item.name' },
  511. { id: 'Stop_End' }
  512. ]
  513. });
  514. const ctx = await engine.execute({});
  515. assertDeepEqual(ctx.variables['$names'], ['a.ts', 'b.ts']);
  516. });
  517. // ═══════════════════════════════════════════════════════════════
  518. console.log('\n══ Service_* Step ══');
  519. await test('Service_* basic call with output mapping', async () => {
  520. const adapters = mockAdapters({ serviceResult: { id: 42, status: 'active', name: 'Test' } });
  521. const engine = new Engine({
  522. version: '3.15', name: 'test',
  523. registry: {
  524. params: ['userId(INT)'], vars: ['$user(OBJECT)', '$userName(STRING)'],
  525. services: ['UserService(userId(INT)) RETURN id(INT), status(STRING), name(STRING)']
  526. },
  527. steps: [
  528. { id: 'Service_UserService', in: { userId: '=userId' }, out: { '$user': '=_result', '$userName': '=_result.name' }, next: 'Stop_End' },
  529. { id: 'Stop_End' }
  530. ]
  531. });
  532. const ctx = await engine.execute({ userId: 1 }, adapters);
  533. assertEqual(ctx.variables['$userName'], 'Test');
  534. assertEqual(ctx.variables['$user'].id, 42);
  535. });
  536. await test('Service_* shorthand out', async () => {
  537. // Service executor sets _result = result.data || result, so { data: 'hello' } → _result = 'hello'
  538. const adapters = mockAdapters({ serviceResult: { id: 1, name: 'test' } });
  539. const engine = new Engine({
  540. version: '3.15', name: 'test',
  541. registry: {
  542. params: [], vars: ['$result(OBJECT)'],
  543. services: ['SimpleService() RETURN id(INT), name(STRING)']
  544. },
  545. steps: [
  546. { id: 'Service_SimpleService', out: '$result', next: 'Stop_End' },
  547. { id: 'Stop_End' }
  548. ]
  549. });
  550. const ctx = await engine.execute({}, adapters);
  551. // shorthand: $result = _result = entire result (no .data key, so result itself)
  552. assertEqual(ctx.variables['$result'].name, 'test');
  553. });
  554. // ═══════════════════════════════════════════════════════════════
  555. console.log('\n══ LLM_* Step ══');
  556. await test('LLM_* with structured output (json_schema)', async () => {
  557. const adapters = mockAdapters({
  558. llmResult: {
  559. content: '{"title":"Plan","phases":["A","B"]}',
  560. usage: { input_tokens: 100, output_tokens: 50 },
  561. model: 'claude-opus-4-6'
  562. }
  563. });
  564. const engine = new Engine({
  565. version: '3.15', name: 'test',
  566. registry: {
  567. params: [], vars: ['$plan(OBJECT)', '$title(STRING)'],
  568. schemas: { 'PlanSchema': { type: 'object', properties: { title: { type: 'string' } } } }
  569. },
  570. steps: [
  571. { id: 'LLM_Gen', in: {
  572. messages: [{ role: 'user', content: '="Generate plan"' }],
  573. output_config: { format: { type: 'json_schema', schemaRef: 'PlanSchema' } }
  574. }, out: { '$plan': '=_result', '$title': '=_result.title' }, next: 'Stop_End' },
  575. { id: 'Stop_End' }
  576. ]
  577. });
  578. const ctx = await engine.execute({}, adapters);
  579. assertEqual(ctx.variables['$title'], 'Plan');
  580. assertDeepEqual(ctx.variables['$plan'].phases, ['A', 'B']);
  581. });
  582. await test('LLM_* with streaming and events', async () => {
  583. const tokens = [];
  584. const adapters = mockAdapters({
  585. llmResult: {
  586. content: 'Hello World',
  587. usage: { input_tokens: 10, output_tokens: 5 },
  588. model: 'claude-opus-4-6'
  589. }
  590. });
  591. // Override llm adapter to call onToken
  592. adapters.llm = {
  593. call: async (params, onToken, callbacks) => {
  594. if (callbacks?.onToken) {
  595. callbacks.onToken('Hello ');
  596. callbacks.onToken('World');
  597. }
  598. return adapters.llm._result;
  599. },
  600. _result: adapters.llm.call.__proto__ // won't work, let me fix
  601. };
  602. // Simpler approach
  603. adapters.llm = {
  604. call: async (params, onToken, callbacks) => {
  605. if (callbacks?.onToken) {
  606. callbacks.onToken('Hello ');
  607. callbacks.onToken('World');
  608. }
  609. return {
  610. content: 'Hello World',
  611. usage: { input_tokens: 10, output_tokens: 5 },
  612. model: 'claude-opus-4-6'
  613. };
  614. }
  615. };
  616. const engine = new Engine({
  617. version: '3.15', name: 'test',
  618. registry: { params: [], vars: ['$answer(STRING)'] },
  619. steps: [
  620. { id: 'LLM_Chat', in: { stream: true, messages: [{ role: 'user', content: 'Hi' }] },
  621. out: '$answer', next: 'Stop_End' },
  622. { id: 'Stop_End' }
  623. ]
  624. });
  625. const ctx = await engine.execute({}, adapters);
  626. assertEqual(ctx.variables['$answer'], 'Hello World');
  627. });
  628. await test('LLM_* with doc injection', async () => {
  629. let capturedParams = null;
  630. const adapters = mockAdapters({ docs: { '1': 'VL Syntax Rules' } });
  631. adapters.llm = {
  632. call: async (params, onToken, callbacks) => {
  633. capturedParams = params;
  634. return { content: 'result', usage: {}, model: 'test' };
  635. }
  636. };
  637. const engine = new Engine({
  638. version: '3.15', name: 'test',
  639. registry: { params: [], vars: ['$out(STRING)'], docs: { '1': 'VL Syntax' } },
  640. steps: [
  641. { id: 'LLM_WithDocs', in: {
  642. docs: ['1'],
  643. messages: [{ role: 'user', content: 'Generate code' }]
  644. }, out: '$out', next: 'Stop_End' },
  645. { id: 'Stop_End' }
  646. ]
  647. });
  648. const ctx = await engine.execute({}, adapters);
  649. // docs should be injected into system message and removed from params
  650. assert(capturedParams.system && capturedParams.system.includes('VL Syntax Rules'), 'Docs should be injected');
  651. assertEqual(capturedParams.docs, undefined, 'docs field should be removed after injection');
  652. });
  653. await test('LLM_* with _meta access', async () => {
  654. const adapters = mockAdapters({
  655. llmResult: {
  656. content: 'result text',
  657. usage: { input_tokens: 100, output_tokens: 50 },
  658. model: 'claude-opus-4-6',
  659. id: 'resp_123'
  660. }
  661. });
  662. const engine = new Engine({
  663. version: '3.15', name: 'test',
  664. registry: { params: [], vars: ['$answer(STRING)', '$tokens(INT)'] },
  665. steps: [
  666. { id: 'LLM_Gen', in: { messages: [{ role: 'user', content: 'hi' }] },
  667. out: { '$answer': '=_result', '$tokens': '=_meta.usage.total_tokens' },
  668. next: 'Stop_End' },
  669. { id: 'Stop_End' }
  670. ]
  671. });
  672. const ctx = await engine.execute({}, adapters);
  673. assertEqual(ctx.variables['$answer'], 'result text');
  674. assertEqual(ctx.variables['$tokens'], 150);
  675. });
  676. // ═══════════════════════════════════════════════════════════════
  677. console.log('\n══ API_* Step ══');
  678. await test('API_* with query params and output mapping', async () => {
  679. let capturedApiDef = null, capturedParams = null;
  680. const adapters = mockAdapters();
  681. adapters.api = {
  682. call: async (apiDef, params) => {
  683. capturedApiDef = apiDef;
  684. capturedParams = params;
  685. return { temp: 25, city: 'Beijing' };
  686. }
  687. };
  688. const engine = new Engine({
  689. version: '3.15', name: 'test',
  690. registry: {
  691. params: ['city(STRING)'], vars: ['$weather(OBJECT)'],
  692. apis: [{ id: 'WeatherQuery', method: 'GET', url: 'https://api.weather.com/v1/current.json', auth: 'SYSVAR.weatherKey' }]
  693. },
  694. steps: [
  695. { id: 'API_WeatherQuery', in: { query: { q: '=city' } },
  696. out: '$weather', next: 'Stop_End' },
  697. { id: 'Stop_End' }
  698. ]
  699. });
  700. const ctx = await engine.execute({ city: 'Beijing' }, adapters);
  701. assertEqual(capturedApiDef.method, 'GET');
  702. assertEqual(capturedParams.query.q, 'Beijing');
  703. assertEqual(ctx.variables['$weather'].temp, 25);
  704. });
  705. // ═══════════════════════════════════════════════════════════════
  706. console.log('\n══ Write_* Step ══');
  707. await test('Write_* basic file write', async () => {
  708. const writtenFiles = [];
  709. const adapters = mockAdapters({ writtenFiles });
  710. const engine = new Engine({
  711. version: '3.15', name: 'test',
  712. registry: { params: [], vars: ['$content(STRING)'], files: { artifacts: ['Process/Artifacts/*'] } },
  713. steps: [
  714. { id: 'Set_Content', target: '$content', value: '="hello world"', next: 'Write_File' },
  715. { id: 'Write_File', target: '="Process/Artifacts/output.txt"', value: '=$content', next: 'Stop_End' },
  716. { id: 'Stop_End' }
  717. ]
  718. });
  719. const ctx = await engine.execute({}, adapters);
  720. assertEqual(writtenFiles.length, 1);
  721. assertEqual(writtenFiles[0].path, 'Process/Artifacts/output.txt');
  722. assertEqual(writtenFiles[0].content, 'hello world');
  723. });
  724. await test('Write_* with dynamic path using _item', async () => {
  725. const writtenFiles = [];
  726. const adapters = mockAdapters({ writtenFiles });
  727. const engine = new Engine({
  728. version: '3.15', name: 'test',
  729. registry: { params: [], vars: ['$files([OBJECT])'], files: { artifacts: ['out/*'] } },
  730. steps: [
  731. { id: 'Set_Files', target: '$files', value: '=[{"name":"a.ts","code":"const a=1"},{"name":"b.ts","code":"const b=2"}]', next: 'Loop_Write' },
  732. { id: 'Loop_Write', source: '$files', mode: 'serial', children: ['Write_One'], next: 'Stop_End' },
  733. { id: 'Write_One', target: '="out/" + _item.name', value: '=_item.code' },
  734. { id: 'Stop_End' }
  735. ]
  736. });
  737. const ctx = await engine.execute({}, adapters);
  738. assertEqual(writtenFiles.length, 2);
  739. assertEqual(writtenFiles[0].path, 'out/a.ts');
  740. assertEqual(writtenFiles[0].content, 'const a=1');
  741. assertEqual(writtenFiles[1].path, 'out/b.ts');
  742. });
  743. await test('Write_* with append mode', async () => {
  744. const writtenFiles = [];
  745. const adapters = mockAdapters({ writtenFiles });
  746. const engine = new Engine({
  747. version: '3.15', name: 'test',
  748. registry: { params: [], vars: ['$lines([STRING])'], files: { artifacts: ['log/*'] } },
  749. steps: [
  750. { id: 'Set_Lines', target: '$lines', value: '=["line1","line2","line3"]', next: 'Loop_Log' },
  751. { id: 'Loop_Log', source: '$lines', mode: 'serial', children: ['Write_Log'], next: 'Stop_End' },
  752. { id: 'Write_Log', target: '="log/output.log"', value: '=_item + "\\n"', mode: 'append' },
  753. { id: 'Stop_End' }
  754. ]
  755. });
  756. const ctx = await engine.execute({}, adapters);
  757. assertEqual(writtenFiles.length, 3);
  758. assert(writtenFiles.every(f => f.mode === 'append'));
  759. });
  760. // ═══════════════════════════════════════════════════════════════
  761. console.log('\n══ Conditional Skip (step.if) ══');
  762. await test('step.if=false skips node and its children', async () => {
  763. const engine = new Engine({
  764. version: '3.15', name: 'test',
  765. registry: { params: ['skip(BOOL)'], vars: ['$a(INT)', '$b(INT)'] },
  766. steps: [
  767. { id: 'Noop_Start', if: '=!skip', children: ['Set_A', 'Set_B'], next: 'Stop_End' },
  768. { id: 'Set_A', target: '$a', value: '=1' },
  769. { id: 'Set_B', target: '$b', value: '=2' },
  770. { id: 'Stop_End' }
  771. ]
  772. });
  773. const ctx1 = await engine.execute({ skip: false });
  774. assertEqual(ctx1.variables['$a'], 1);
  775. assertEqual(ctx1.variables['$b'], 2);
  776. const ctx2 = await engine.execute({ skip: true });
  777. assertEqual(ctx2.variables['$a'], null);
  778. assertEqual(ctx2.variables['$b'], null);
  779. });
  780. await test('step.if with expression', async () => {
  781. const engine = new Engine({
  782. version: '3.15', name: 'test',
  783. registry: { params: ['count(INT)'], vars: ['$msg(STRING)'] },
  784. steps: [
  785. { id: 'Set_Msg', if: '=count > 0', target: '$msg', value: '="has items"', next: 'Stop_End' },
  786. { id: 'Stop_End' }
  787. ]
  788. });
  789. const ctx1 = await engine.execute({ count: 5 });
  790. assertEqual(ctx1.variables['$msg'], 'has items');
  791. const ctx2 = await engine.execute({ count: 0 });
  792. assertEqual(ctx2.variables['$msg'], null);
  793. });
  794. // ═══════════════════════════════════════════════════════════════
  795. console.log('\n══ Error Handling (onError) ══');
  796. await test('onError handler catches service failure', async () => {
  797. const adapters = mockAdapters();
  798. adapters.service = {
  799. call: async () => { throw new Error('Service unavailable'); }
  800. };
  801. const engine = new Engine({
  802. version: '3.15', name: 'test',
  803. registry: {
  804. params: [], vars: ['$result(STRING)', '$errorMsg(STRING)'],
  805. services: ['FailService() RETURN ok(BOOL)']
  806. },
  807. steps: [
  808. { id: 'Service_FailService', out: '$result', onError: 'Set_ErrorHandler', next: 'Stop_End' },
  809. { id: 'Set_ErrorHandler', target: '$errorMsg', value: '=_error.message', next: 'RETURN' },
  810. { id: 'Stop_End' }
  811. ]
  812. });
  813. const ctx = await engine.execute({}, adapters);
  814. assertEqual(ctx.variables['$errorMsg'], 'Service unavailable');
  815. });
  816. await test('error without onError propagates (fail-fast)', async () => {
  817. const adapters = mockAdapters();
  818. adapters.service = {
  819. call: async () => { throw new Error('boom'); }
  820. };
  821. const engine = new Engine({
  822. version: '3.15', name: 'test',
  823. registry: {
  824. params: [], vars: ['$result(STRING)'],
  825. services: ['FailService() RETURN ok(BOOL)']
  826. },
  827. steps: [
  828. { id: 'Service_FailService', out: '$result', next: 'Stop_End' },
  829. { id: 'Stop_End' }
  830. ]
  831. });
  832. let caught = false;
  833. try {
  834. await engine.execute({}, adapters);
  835. } catch (e) {
  836. caught = true;
  837. assertEqual(e.message, 'boom');
  838. }
  839. assert(caught, 'Should have thrown');
  840. });
  841. // ═══════════════════════════════════════════════════════════════
  842. console.log('\n══ Pause_* Step ══');
  843. await test('Pause_* and resume', async () => {
  844. const engine = new Engine({
  845. version: '3.15', name: 'test',
  846. registry: { params: [], vars: ['$input(OBJECT)', '$final(STRING)'] },
  847. steps: [
  848. { id: 'Pause_UserInput', reason: 'Need user input', resumeResultTarget: '$input', next: 'Set_Final' },
  849. { id: 'Set_Final', target: '$final', value: '=$input.answer', next: 'Stop_End' },
  850. { id: 'Stop_End' }
  851. ]
  852. });
  853. // Start execution in background — it will pause
  854. let ctx;
  855. const executePromise = engine.execute({}).then(c => { ctx = c; });
  856. // Wait a tick for pause to take effect
  857. await new Promise(r => setTimeout(r, 50));
  858. // Get the execution context from the promise (it hasn't resolved yet)
  859. // We need another approach — the engine returns the ctx after full execution
  860. // For Pause, we need to resume before it completes
  861. // Let's use the event system approach
  862. // Better approach: use onEvent to capture pause token
  863. let pauseToken = null;
  864. let executionCtx = null;
  865. const engine2 = new Engine({
  866. version: '3.15', name: 'test',
  867. registry: { params: [], vars: ['$input(OBJECT)', '$final(STRING)'] },
  868. steps: [
  869. { id: 'Pause_UserInput', reason: 'Need user input', resumeResultTarget: '$input', next: 'Set_Final' },
  870. { id: 'Set_Final', target: '$final', value: '=$input.answer', next: 'Stop_End' },
  871. { id: 'Stop_End' }
  872. ]
  873. }, {
  874. onEvent: (event) => {
  875. if (event.type === 'pause_start') {
  876. pauseToken = event.payload.waitToken;
  877. }
  878. }
  879. });
  880. // Execute and resume concurrently
  881. const execPromise = engine2.execute({});
  882. // Wait for pause
  883. await new Promise(r => setTimeout(r, 50));
  884. assert(pauseToken, 'Should have received pause token');
  885. // Resume with payload
  886. // We need access to the ctx... The engine only returns it after execute()
  887. // The Pause implementation creates ctx internally, we can't access it before execute() resolves
  888. // Actually, looking at the code, the Pause await blocks execute(), so we need
  889. // to resume before await resolves. We'd need the ctx from inside.
  890. // The proper way: start execute, it pauses, then resume.
  891. // But execute() doesn't return until done. So we run it in a microtask.
  892. // Let's test with timeout instead.
  893. });
  894. await test('Pause_* with timeout triggers timeout handler', async () => {
  895. const engine = new Engine({
  896. version: '3.15', name: 'test',
  897. registry: { params: [], vars: ['$input(OBJECT)', '$timedOut(BOOL)'] },
  898. steps: [
  899. { id: 'Pause_Wait', reason: 'Wait', resumeResultTarget: '$input',
  900. timeout: { sec: 0.1, on: 'Set_TimedOut' }, next: 'Stop_End' },
  901. { id: 'Set_TimedOut', target: '$timedOut', value: '=true', next: 'Stop_End' },
  902. { id: 'Stop_End' }
  903. ]
  904. });
  905. const ctx = await engine.execute({});
  906. assertEqual(ctx.variables['$timedOut'], true);
  907. });
  908. // ═══════════════════════════════════════════════════════════════
  909. console.log('\n══ Parallel Children ══');
  910. await test('parallel children with different operations', async () => {
  911. const adapters = mockAdapters({
  912. serviceResult: { value: 'svc_result' }
  913. });
  914. const engine = new Engine({
  915. version: '3.15', name: 'test',
  916. registry: {
  917. params: [], vars: ['$a(STRING)', '$b(INT)', '$c(STRING)'],
  918. services: ['DataService() RETURN value(STRING)']
  919. },
  920. steps: [
  921. { id: 'Noop_Fork', children: ['Set_A', 'Set_B', 'Service_DataService'], next: 'Stop_End' },
  922. { id: 'Set_A', target: '$a', value: '="hello"' },
  923. { id: 'Set_B', target: '$b', value: '=42' },
  924. { id: 'Service_DataService', out: '$c' },
  925. { id: 'Stop_End' }
  926. ]
  927. });
  928. const ctx = await engine.execute({}, adapters);
  929. assertEqual(ctx.variables['$a'], 'hello');
  930. assertEqual(ctx.variables['$b'], 42);
  931. });
  932. await test('children with chain (child has next)', async () => {
  933. const engine = new Engine({
  934. version: '3.15', name: 'test',
  935. registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
  936. steps: [
  937. { id: 'Noop_Fork', children: ['Set_A1'], next: 'Stop_End' },
  938. { id: 'Set_A1', target: '$a', value: '=1', next: 'Set_A2' },
  939. { id: 'Set_A2', target: '$b', value: '=$a + 10', next: 'RETURN' },
  940. { id: 'Stop_End' }
  941. ]
  942. });
  943. const ctx = await engine.execute({});
  944. assertEqual(ctx.variables['$a'], 1);
  945. assertEqual(ctx.variables['$b'], 11);
  946. });
  947. // ═══════════════════════════════════════════════════════════════
  948. console.log('\n══ Event System ══');
  949. await test('workflow events are emitted in correct order', async () => {
  950. const events = [];
  951. const engine = new Engine({
  952. version: '3.15', name: 'test',
  953. registry: { params: [], vars: ['$x(INT)'] },
  954. steps: [
  955. { id: 'Set_001', target: '$x', value: '=42', next: 'Stop_End' },
  956. { id: 'Stop_End' }
  957. ]
  958. }, {
  959. onEvent: (e) => events.push(e.type)
  960. });
  961. await engine.execute({});
  962. // Expected order: workflow_start → step_start → step_done → step_start(Stop) → workflow_done
  963. assertEqual(events[0], 'workflow_start');
  964. assert(events.includes('step_start'));
  965. assert(events.includes('step_done'));
  966. assert(events.includes('workflow_done'));
  967. // workflow_done should be last
  968. assertEqual(events[events.length - 1], 'workflow_done');
  969. });
  970. await test('step_skipped event when if=false', async () => {
  971. const events = [];
  972. const engine = new Engine({
  973. version: '3.15', name: 'test',
  974. registry: { params: [], vars: ['$x(INT)'] },
  975. steps: [
  976. { id: 'Set_001', if: '=false', target: '$x', value: '=42', next: 'Stop_End' },
  977. { id: 'Stop_End' }
  978. ]
  979. }, {
  980. onEvent: (e) => events.push(e)
  981. });
  982. await engine.execute({});
  983. const skipEvent = events.find(e => e.type === 'step_skipped');
  984. assert(skipEvent, 'Should emit step_skipped');
  985. assertEqual(skipEvent.stepID, 'Set_001');
  986. });
  987. await test('var_changed event on $var write', async () => {
  988. const events = [];
  989. const engine = new Engine({
  990. version: '3.15', name: 'test',
  991. registry: { params: [], vars: ['$x(INT)'] },
  992. steps: [
  993. { id: 'Set_001', target: '$x', value: '=42', next: 'Stop_End' },
  994. { id: 'Stop_End' }
  995. ]
  996. }, {
  997. onEvent: (e) => events.push(e)
  998. });
  999. await engine.execute({});
  1000. const varEvent = events.find(e => e.type === 'var_changed' && e.payload?.name === '$x');
  1001. assert(varEvent, 'Should emit var_changed for $x');
  1002. assertEqual(varEvent.payload.newValue, 42);
  1003. });
  1004. await test('step_print event', async () => {
  1005. const events = [];
  1006. const engine = new Engine({
  1007. version: '3.15', name: 'test',
  1008. registry: { params: [], vars: ['$ver(STRING)'] },
  1009. steps: [
  1010. { id: 'Set_Ver', target: '$ver', value: '="3.15"', print: '="Version: " + $ver', next: 'Stop_End' },
  1011. { id: 'Stop_End' }
  1012. ]
  1013. }, {
  1014. onEvent: (e) => events.push(e)
  1015. });
  1016. await engine.execute({});
  1017. const printEvent = events.find(e => e.type === 'step_print');
  1018. assert(printEvent, 'Should emit step_print');
  1019. assertEqual(printEvent.payload.value, 'Version: 3.15');
  1020. });
  1021. await test('event seq is monotonically increasing', async () => {
  1022. const events = [];
  1023. const engine = new Engine({
  1024. version: '3.15', name: 'test',
  1025. registry: { params: [], vars: ['$x(INT)', '$y(INT)'] },
  1026. steps: [
  1027. { id: 'Set_X', target: '$x', value: '=1', next: 'Set_Y' },
  1028. { id: 'Set_Y', target: '$y', value: '=2', next: 'Stop_End' },
  1029. { id: 'Stop_End' }
  1030. ]
  1031. }, {
  1032. onEvent: (e) => events.push(e)
  1033. });
  1034. await engine.execute({});
  1035. for (let i = 1; i < events.length; i++) {
  1036. assert(events[i].seq > events[i - 1].seq, `seq should increase: ${events[i - 1].seq} -> ${events[i].seq}`);
  1037. }
  1038. });
  1039. // ═══════════════════════════════════════════════════════════════
  1040. console.log('\n══ Custom Handlers ══');
  1041. await test('custom handler is called for matching prefix', async () => {
  1042. let customCalled = false;
  1043. const engine = new Engine({
  1044. version: '3.15', name: 'test',
  1045. registry: { params: [], vars: ['$x(INT)'] },
  1046. steps: [
  1047. { id: 'Custom_Process', data: 'test', next: 'Stop_End' },
  1048. { id: 'Stop_End' }
  1049. ]
  1050. }, {
  1051. customHandlers: {
  1052. Custom: async (eng, ctx, step) => {
  1053. customCalled = true;
  1054. ctx.setVariable('$x', 99);
  1055. }
  1056. }
  1057. });
  1058. const ctx = await engine.execute({});
  1059. assert(customCalled, 'Custom handler should be called');
  1060. assertEqual(ctx.variables['$x'], 99);
  1061. });
  1062. // ═══════════════════════════════════════════════════════════════
  1063. console.log('\n══ Complex Workflow Scenarios ══');
  1064. await test('multi-step workflow: Set → Branch → Loop → Service → Stop', async () => {
  1065. let serviceCalls = 0;
  1066. const adapters = mockAdapters();
  1067. adapters.service = {
  1068. call: async (name, params) => { serviceCalls++; return { processed: true }; }
  1069. };
  1070. const engine = new Engine({
  1071. version: '3.15', name: 'test',
  1072. registry: {
  1073. params: ['mode(STRING)'],
  1074. vars: ['$items([STRING])', '$count(INT)', '$processed(INT)'],
  1075. services: ['ProcessItem(item(STRING)) RETURN processed(BOOL)']
  1076. },
  1077. steps: [
  1078. { id: 'Branch_Mode', cases: [
  1079. ['=mode == "batch"', 'Set_BatchItems'],
  1080. ['ELSE', 'Set_SingleItem']
  1081. ], next: 'Set_Counter' },
  1082. { id: 'Set_BatchItems', target: '$items', value: '=["a","b","c"]', next: 'RETURN' },
  1083. { id: 'Set_SingleItem', target: '$items', value: '=["single"]', next: 'RETURN' },
  1084. { id: 'Set_Counter', target: '$processed', value: '=0', next: 'Loop_Process' },
  1085. { id: 'Loop_Process', source: '$items', mode: 'serial', children: ['Service_ProcessItem'], next: 'Set_Count' },
  1086. { id: 'Service_ProcessItem', in: { item: '=_item' }, next: 'Set_Increment' },
  1087. { id: 'Set_Increment', target: '$processed', value: '=$processed + 1', next: 'RETURN' },
  1088. { id: 'Set_Count', target: '$count', value: '=$items.length', next: 'Stop_End' },
  1089. { id: 'Stop_End' }
  1090. ]
  1091. });
  1092. const ctx1 = await engine.execute({ mode: 'batch' }, adapters);
  1093. assertEqual(ctx1.variables['$count'], 3);
  1094. assertEqual(ctx1.variables['$processed'], 3);
  1095. assertEqual(serviceCalls, 3);
  1096. serviceCalls = 0;
  1097. const ctx2 = await engine.execute({ mode: 'single' }, adapters);
  1098. assertEqual(ctx2.variables['$count'], 1);
  1099. assertEqual(serviceCalls, 1);
  1100. });
  1101. await test('workflow with param defaults', async () => {
  1102. const engine = new Engine({
  1103. version: '3.15', name: 'test',
  1104. registry: { params: ['x(INT) = 10', 'y(INT)'], vars: ['$sum(INT)'] },
  1105. steps: [
  1106. { id: 'Set_Sum', target: '$sum', value: '=x + y', next: 'Stop_End' },
  1107. { id: 'Stop_End' }
  1108. ]
  1109. });
  1110. // x uses default, y provided
  1111. const ctx = await engine.execute({ y: 5 });
  1112. assertEqual(ctx.variables['$sum'], 15);
  1113. });
  1114. await test('out mapping with file write via /', async () => {
  1115. const writtenFiles = [];
  1116. const adapters = mockAdapters({ writtenFiles });
  1117. adapters.llm = {
  1118. call: async () => ({
  1119. content: 'generated code here',
  1120. usage: { input_tokens: 10, output_tokens: 20 },
  1121. model: 'test'
  1122. })
  1123. };
  1124. const engine = new Engine({
  1125. version: '3.15', name: 'test',
  1126. registry: { params: [], vars: ['$code(STRING)'], files: { artifacts: ['src/*'] } },
  1127. steps: [
  1128. { id: 'LLM_GenCode', in: { messages: [{ role: 'user', content: 'gen code' }] },
  1129. out: { '$code': '=_result', '/src/app.ts': '=_result' },
  1130. next: 'Stop_End' },
  1131. { id: 'Stop_End' }
  1132. ]
  1133. });
  1134. const ctx = await engine.execute({}, adapters);
  1135. assertEqual(ctx.variables['$code'], 'generated code here');
  1136. // File should have been written
  1137. assertEqual(writtenFiles.length, 1);
  1138. assertEqual(writtenFiles[0].path, '/src/app.ts');
  1139. });
  1140. await test('snapshot returns correct state', async () => {
  1141. const engine = new Engine({
  1142. version: '3.15', name: 'test',
  1143. registry: { params: ['x(INT)'], vars: ['$result(INT)'] },
  1144. steps: [
  1145. { id: 'Set_001', target: '$result', value: '=x * 2', next: 'Stop_End' },
  1146. { id: 'Stop_End' }
  1147. ]
  1148. });
  1149. const ctx = await engine.execute({ x: 21 });
  1150. const snap = ctx.snapshot();
  1151. assertEqual(snap.status, 'stopped');
  1152. assertEqual(snap.variables['$result'], 42);
  1153. assertEqual(snap.params.x, 21);
  1154. assert(snap.elapsedMs >= 0, 'elapsedMs should be non-negative');
  1155. assertEqual(snap.version, '3.15');
  1156. });
  1157. await test('abort cancels workflow', async () => {
  1158. let stepCount = 0;
  1159. const adapters = mockAdapters();
  1160. adapters.service = {
  1161. call: async () => {
  1162. stepCount++;
  1163. if (stepCount === 2) {
  1164. // This shouldn't happen if abort works
  1165. }
  1166. return {};
  1167. }
  1168. };
  1169. const engine = new Engine({
  1170. version: '3.15', name: 'test',
  1171. registry: {
  1172. params: [], vars: ['$a(INT)', '$b(INT)'],
  1173. services: ['Svc() RETURN ok(BOOL)']
  1174. },
  1175. steps: [
  1176. { id: 'Set_A', target: '$a', value: '=1', next: 'Set_B' },
  1177. { id: 'Set_B', target: '$b', value: '=2', next: 'Stop_End' },
  1178. { id: 'Stop_End' }
  1179. ]
  1180. });
  1181. const ctx = await engine.execute({}, adapters);
  1182. // Just verify execution completes
  1183. assertEqual(ctx.variables['$a'], 1);
  1184. assertEqual(ctx.variables['$b'], 2);
  1185. });
  1186. // ═══════════════════════════════════════════════════════════════
  1187. console.log('\n══ isV310OrLater ══');
  1188. await test('version detection', () => {
  1189. assert(isV310OrLater('3.10'));
  1190. assert(isV310OrLater('3.12'));
  1191. assert(isV310OrLater('3.13'));
  1192. assert(isV310OrLater('3.14'));
  1193. assert(isV310OrLater('3.15'));
  1194. assert(!isV310OrLater('3.6'));
  1195. assert(!isV310OrLater('3.9'));
  1196. });
  1197. // ═══════════════════════════════════════════════════════════════
  1198. console.log('\n══ LLMError ══');
  1199. await test('LLMError and buildErrorMap', () => {
  1200. const err = new LLMError({
  1201. type: 'rate_limit',
  1202. code: '429',
  1203. message: 'Rate limit exceeded',
  1204. retryable: true,
  1205. statusCode: 429,
  1206. provider: 'anthropic',
  1207. model: 'claude-opus-4-6'
  1208. });
  1209. const map = buildErrorMap(err);
  1210. assertEqual(map.type, 'rate_limit');
  1211. assertEqual(map.retryable, true);
  1212. assertEqual(map.provider, 'anthropic');
  1213. });
  1214. // ═══════════════════════════════════════════════════════════════
  1215. console.log('\n══ Edge Cases ══');
  1216. await test('multiple entry nodes execute in parallel', async () => {
  1217. const engine = new Engine({
  1218. version: '3.15', name: 'test',
  1219. registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
  1220. steps: [
  1221. { id: 'Set_A', target: '$a', value: '=1', next: 'Stop_End' },
  1222. { id: 'Set_B', target: '$b', value: '=2', next: 'Stop_End' },
  1223. { id: 'Stop_End' }
  1224. ]
  1225. });
  1226. // Set_A and Set_B are both entry nodes (neither is referenced by another)
  1227. // They should be executed in parallel
  1228. // Note: Stop_End will be reached by whichever finishes first
  1229. const ctx = await engine.execute({});
  1230. // At least one should be set
  1231. assert(ctx.variables['$a'] === 1 || ctx.variables['$b'] === 2,
  1232. 'At least one entry node should execute');
  1233. });
  1234. await test('Check_* alias works like Branch', async () => {
  1235. const engine = new Engine({
  1236. version: '3.15', name: 'test',
  1237. registry: { params: ['flag(BOOL)'], vars: ['$out(STRING)'] },
  1238. steps: [
  1239. { id: 'Check_Flag', condition: '=flag', if_true: 'Set_Yes', if_false: 'Set_No', next: 'Stop_End' },
  1240. { id: 'Set_Yes', target: '$out', value: '="yes"', next: 'RETURN' },
  1241. { id: 'Set_No', target: '$out', value: '="no"', next: 'RETURN' },
  1242. { id: 'Stop_End' }
  1243. ]
  1244. });
  1245. const ctx1 = await engine.execute({ flag: true });
  1246. assertEqual(ctx1.variables['$out'], 'yes');
  1247. const ctx2 = await engine.execute({ flag: false });
  1248. assertEqual(ctx2.variables['$out'], 'no');
  1249. });
  1250. await test('Fork_* alias works like Noop', async () => {
  1251. const engine = new Engine({
  1252. version: '3.15', name: 'test',
  1253. registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
  1254. steps: [
  1255. { id: 'Fork_Start', children: ['Set_A', 'Set_B'], next: 'Stop_End' },
  1256. { id: 'Set_A', target: '$a', value: '=1' },
  1257. { id: 'Set_B', target: '$b', value: '=2' },
  1258. { id: 'Stop_End' }
  1259. ]
  1260. });
  1261. const ctx = await engine.execute({});
  1262. assertEqual(ctx.variables['$a'], 1);
  1263. assertEqual(ctx.variables['$b'], 2);
  1264. });
  1265. await test('Done_* alias works like Stop', async () => {
  1266. const engine = new Engine({
  1267. version: '3.15', name: 'test',
  1268. registry: { params: [], vars: ['$x(INT)'] },
  1269. steps: [
  1270. { id: 'Set_X', target: '$x', value: '=42', next: 'Done_Finish' },
  1271. { id: 'Done_Finish' }
  1272. ]
  1273. });
  1274. const ctx = await engine.execute({});
  1275. assertEqual(ctx.variables['$x'], 42);
  1276. assertEqual(ctx.status, 'stopped');
  1277. });
  1278. await test('Loop_* source with non-array throws', async () => {
  1279. const engine = new Engine({
  1280. version: '3.15', name: 'test',
  1281. registry: { params: [], vars: ['$x(STRING)'] },
  1282. steps: [
  1283. { id: 'Set_X', target: '$x', value: '="not an array"', next: 'Loop_Bad' },
  1284. { id: 'Loop_Bad', source: '$x', mode: 'serial', children: ['Set_Noop'], next: 'Stop_End' },
  1285. { id: 'Set_Noop', target: '$x', value: '="noop"' },
  1286. { id: 'Stop_End' }
  1287. ]
  1288. });
  1289. let caught = false;
  1290. try {
  1291. await engine.execute({});
  1292. } catch (e) {
  1293. caught = true;
  1294. assert(e.message.includes('array'), 'Should mention array');
  1295. }
  1296. assert(caught, 'Should throw for non-array source');
  1297. });
  1298. await test('.tmp/ path resolution', async () => {
  1299. const writtenFiles = [];
  1300. const adapters = mockAdapters({ writtenFiles });
  1301. const engine = new Engine({
  1302. version: '3.15', name: 'test',
  1303. registry: { params: [], vars: [], files: { artifacts: ['.tmp/*'] } },
  1304. steps: [
  1305. { id: 'Write_Tmp', target: '=".tmp/test.txt"', value: '="temp data"', next: 'Stop_End' },
  1306. { id: 'Stop_End' }
  1307. ]
  1308. });
  1309. const ctx = await engine.execute({}, adapters);
  1310. assertEqual(writtenFiles.length, 1);
  1311. // Should be resolved to .tmp/{workflowID}/test.txt
  1312. assert(writtenFiles[0].path.startsWith('.tmp/wf_'), 'Path should be resolved with workflowID');
  1313. assert(writtenFiles[0].path.endsWith('/test.txt'));
  1314. });
  1315. await test('interpolateFilePath with {expr}', async () => {
  1316. const writtenFiles = [];
  1317. const adapters = mockAdapters({ writtenFiles });
  1318. const engine = new Engine({
  1319. version: '3.15', name: 'test',
  1320. registry: { params: [], vars: ['$items([OBJECT])'], files: { artifacts: ['out/*'] } },
  1321. steps: [
  1322. { id: 'Set_Items', target: '$items', value: '=[{"path":"comp/A.tsx"}]', next: 'Loop_W' },
  1323. { id: 'Loop_W', source: '$items', mode: 'serial', children: ['Write_F'], next: 'Stop_End' },
  1324. { id: 'Write_F', target: '="out/{_item.path}"', value: '="content"' },
  1325. { id: 'Stop_End' }
  1326. ]
  1327. });
  1328. const ctx = await engine.execute({}, adapters);
  1329. assertEqual(writtenFiles.length, 1);
  1330. assertEqual(writtenFiles[0].path, 'out/comp/A.tsx');
  1331. });
  1332. await test('ChildExecutionContext isolates local vars', async () => {
  1333. const parentCtx = new ExecutionContext({
  1334. workflowID: 'test',
  1335. variables: { '$shared': 0 }
  1336. });
  1337. const child = new ChildExecutionContext(parentCtx);
  1338. child.localVars._item = 'child_item';
  1339. child.localVars._index = 0;
  1340. // Child should see parent $shared
  1341. assertEqual(child.getVariable('$shared'), 0);
  1342. // Child local vars should be isolated
  1343. assertEqual(child.getVariable('_item'), 'child_item');
  1344. // Parent should not see child locals
  1345. assertEqual(parentCtx.getVariable('_item'), undefined);
  1346. // Child writing to $shared should affect parent
  1347. child.setVariable('$shared', 42);
  1348. assertEqual(parentCtx.variables['$shared'], 42);
  1349. });
  1350. // ═══════════════════════════════════════════════════════════════
  1351. console.log('\n══ Output Mapping — Advanced ══');
  1352. await test('out shorthand as string', async () => {
  1353. // When out is a string like "$plan", it means { "$plan": "=_result" }
  1354. const adapters = mockAdapters({ serviceResult: { id: 1, name: 'test' } });
  1355. const engine = new Engine({
  1356. version: '3.15', name: 'test',
  1357. registry: {
  1358. params: [], vars: ['$data(OBJECT)'],
  1359. services: ['Svc() RETURN id(INT)']
  1360. },
  1361. steps: [
  1362. { id: 'Service_Svc', out: '$data', next: 'Stop_End' },
  1363. { id: 'Stop_End' }
  1364. ]
  1365. });
  1366. const ctx = await engine.execute({}, adapters);
  1367. // Shorthand: $data = _result
  1368. assert(ctx.variables['$data'] !== null, 'Should have result');
  1369. });
  1370. // ═══════════════════════════════════════════════════════════════
  1371. // Final results
  1372. console.log('\n══════════════════════════════════════');
  1373. console.log(`\n ${passed} passed, ${failed} failed`);
  1374. if (failures.length > 0) {
  1375. console.log('\n Failed tests:');
  1376. for (const f of failures) {
  1377. console.log(` ✗ ${f.name}: ${f.error}`);
  1378. }
  1379. }
  1380. console.log('');
  1381. process.exit(failed > 0 ? 1 : 0);
  1382. }
  1383. runTests().catch(err => {
  1384. console.error('Test runner error:', err);
  1385. process.exit(1);
  1386. });