test-workflow-editor.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. import fs from 'fs';
  2. import path from 'path';
  3. const html = fs.readFileSync(path.join(process.cwd(), 'public/workflow-editor.html'), 'utf8');
  4. const scriptMatch = html.match(/<script>([\s\S]*)<\/script>/);
  5. if (!scriptMatch) {
  6. console.error('Cannot extract <script> from public/workflow-editor.html');
  7. process.exit(1);
  8. }
  9. const jsCode = scriptMatch[1];
  10. const elements = {};
  11. function createEl(id) {
  12. const classSet = new Set();
  13. const listeners = new Map();
  14. const el = {
  15. id,
  16. textContent: '',
  17. value: '',
  18. children: [],
  19. dataset: {},
  20. style: { display: '', left: '', top: '' },
  21. className: '',
  22. classList: {
  23. add(...names) { names.forEach((name) => classSet.add(name)); },
  24. remove(...names) { names.forEach((name) => classSet.delete(name)); },
  25. contains(name) { return classSet.has(name); },
  26. toggle(name, force) {
  27. if (force === undefined) {
  28. if (classSet.has(name)) classSet.delete(name);
  29. else classSet.add(name);
  30. return classSet.has(name);
  31. }
  32. if (force) classSet.add(name);
  33. else classSet.delete(name);
  34. return !!force;
  35. },
  36. },
  37. offsetLeft: 100,
  38. offsetTop: 100,
  39. offsetWidth: 240,
  40. offsetHeight: 120,
  41. clientWidth: 800,
  42. clientHeight: 600,
  43. scrollLeft: 0,
  44. scrollTop: 0,
  45. appendChild(child) { this.children.push(child); return child; },
  46. querySelectorAll() { return []; },
  47. addEventListener(type, handler) {
  48. if (!listeners.has(type)) listeners.set(type, []);
  49. listeners.get(type).push(handler);
  50. },
  51. setAttribute() {},
  52. click() {},
  53. scrollIntoView() {},
  54. closest() { return null; },
  55. remove() {},
  56. getContext() {
  57. return {
  58. clearRect() {}, beginPath() {}, moveTo() {}, lineTo() {}, stroke() {},
  59. fillRect() {}, fill() {}, fillText() {}, arc() {}, bezierCurveTo() {},
  60. quadraticCurveTo() {}, closePath() {}, setLineDash() {}, strokeRect() {},
  61. scale() {},
  62. set globalAlpha(v) {},
  63. get globalAlpha() { return 1; },
  64. set fillStyle(v) {},
  65. get fillStyle() { return ''; },
  66. set strokeStyle(v) {},
  67. get strokeStyle() { return ''; },
  68. set lineWidth(v) {},
  69. set font(v) {},
  70. set textAlign(v) {},
  71. };
  72. },
  73. get files() { return []; },
  74. };
  75. let innerHTML = '';
  76. Object.defineProperty(el, 'innerHTML', {
  77. get() { return innerHTML; },
  78. set(value) {
  79. innerHTML = value;
  80. if (value === '') this.children = [];
  81. },
  82. });
  83. el.dispatch = async (type, event = {}) => {
  84. const handlers = listeners.get(type) || [];
  85. for (const handler of handlers) {
  86. await handler({
  87. preventDefault() {},
  88. stopPropagation() {},
  89. button: 0,
  90. ...event,
  91. });
  92. }
  93. };
  94. return el;
  95. }
  96. function $(id) {
  97. if (!elements[id]) elements[id] = createEl(id);
  98. return elements[id];
  99. }
  100. global.document = {
  101. getElementById: (id) => $(id),
  102. createElement: (tag) => createEl(tag),
  103. createElementNS: (ns, tag) => createEl(tag),
  104. addEventListener() {},
  105. body: createEl('body'),
  106. };
  107. global.window = {
  108. parent: { postMessage() {} },
  109. addEventListener() {},
  110. innerWidth: 1200,
  111. innerHeight: 800,
  112. location: { search: '' },
  113. };
  114. Object.defineProperty(globalThis, 'navigator', {
  115. configurable: true,
  116. value: { clipboard: { writeText() {} } },
  117. });
  118. global.localStorage = {
  119. _store: {},
  120. getItem(k) { return this._store[k] || null; },
  121. setItem(k, v) { this._store[k] = v; },
  122. removeItem(k) { delete this._store[k]; },
  123. };
  124. global.URL = { createObjectURL() { return 'blob:test'; } };
  125. global.Blob = class Blob { constructor() {} };
  126. global.requestAnimationFrame = (fn) => fn();
  127. global.setTimeout = (fn) => fn();
  128. global.fetch = async (url) => {
  129. if (url === '/api/workflow/ephemeral') {
  130. return { json: async () => ({ ok: true, name: '_ephemeral-test' }) };
  131. }
  132. if (String(url).startsWith('/api/workflow-content?')) {
  133. return {
  134. ok: true,
  135. json: async () => ({
  136. workflowRef: 'child-demo',
  137. title: 'Child Demo Workflow',
  138. workflow: {
  139. name: 'Child Demo Workflow',
  140. version: '3.16',
  141. steps: [{ id: 'Stop_End' }],
  142. },
  143. }),
  144. };
  145. }
  146. return {
  147. ok: true,
  148. json: async () => ({}),
  149. body: {
  150. getReader() {
  151. return { read: async () => ({ done: true }) };
  152. },
  153. },
  154. };
  155. };
  156. const bootEditor = new Function('global', `
  157. ${jsCode}
  158. global._editor = {
  159. parseWorkflow,
  160. getStepType,
  161. streamSSE,
  162. handleExecEvent,
  163. ensureWorkflowRef,
  164. runWorkflow,
  165. rerunFromStep,
  166. openSubflowNode,
  167. navigateBack,
  168. loadWorkflowFromLocation,
  169. toggleCompactMode,
  170. toggleSimplifyGraph,
  171. toggleTypeFilter,
  172. toggleEventPanel,
  173. getTypeIcon,
  174. selectRunSession,
  175. getState: () => state,
  176. getRenderedNodeHtml: (nodeId) => {
  177. const layer = $('nodesLayer');
  178. const node = (layer.children || []).find((child) => child.id === \`node-\${nodeId}\`);
  179. return node?.innerHTML || '';
  180. },
  181. getCheckpoint: () => _lastCheckpoint,
  182. getRunID: () => _currentRunID,
  183. getStatus: () => $('statusLabel').textContent,
  184. getSelectedEvents: () => (getSelectedRunSession()?.events || []).map((event) => ({
  185. type: event.type,
  186. summary: event.summary,
  187. detail: event.detail,
  188. level: event.level,
  189. })),
  190. getRunSessions: () => Array.from(_runSessions.values()).map((session) => ({
  191. sessionID: session.sessionID,
  192. clientRunToken: session.clientRunToken,
  193. runID: session.runID,
  194. label: session.label,
  195. status: session.status,
  196. currentStepID: session.currentStepID,
  197. eventCount: Array.isArray(session.events) ? session.events.length : 0,
  198. })),
  199. getBreadcrumb: () => $('wfBreadcrumb').textContent,
  200. getTypeSidebarHtml: () => $('typeList').innerHTML,
  201. getTypeSummaryHtml: () => $('typeSummary').innerHTML,
  202. getTypePanelTitle: () => $('typePanelTitle').textContent,
  203. getRenderedNodeIds: () => (($('nodesLayer').children || []).map((child) => child.id)),
  204. getCanvasSize: () => ({ width: $('canvas').style.width, height: $('canvas').style.height }),
  205. getNodeCoords: (nodeId) => {
  206. const node = state.nodes.find((entry) => entry.id === nodeId);
  207. return node ? { x: node.x, y: node.y } : null;
  208. },
  209. triggerNodeEvent: async (nodeId, type, event = {}) => {
  210. const layer = $('nodesLayer');
  211. const node = (layer.children || []).find((child) => child.id === \`node-\${nodeId}\`);
  212. if (!node?.dispatch) throw new Error('node element missing');
  213. await node.dispatch(type, event);
  214. },
  215. selectNode: (nodeId) => {
  216. state.selectedNodeId = nodeId;
  217. renderNodes();
  218. renderConnections();
  219. updateNavigationChrome();
  220. },
  221. deselectNode: () => {
  222. deselectSelectedNode();
  223. },
  224. getSelectedNodeId: () => state.selectedNodeId,
  225. triggerElementEvent: async (id, type, event = {}) => {
  226. const el = $(id);
  227. if (!el?.dispatch) throw new Error('element missing');
  228. await el.dispatch(type, event);
  229. },
  230. bodyHasClass: (name) => document.body.classList.contains(name),
  231. };
  232. `);
  233. bootEditor(global);
  234. const editor = global._editor;
  235. let passed = 0;
  236. let failed = 0;
  237. function test(name, fn) {
  238. try {
  239. fn();
  240. console.log(` ✓ ${name}`);
  241. passed++;
  242. } catch (e) {
  243. console.log(` ✗ ${name}: ${e.message}`);
  244. failed++;
  245. }
  246. }
  247. async function testAsync(name, fn) {
  248. try {
  249. await fn();
  250. console.log(` ✓ ${name}`);
  251. passed++;
  252. } catch (e) {
  253. console.log(` ✗ ${name}: ${e.message}`);
  254. failed++;
  255. }
  256. }
  257. function assert(cond, msg) {
  258. if (!cond) throw new Error(msg || 'Assertion failed');
  259. }
  260. function assertEqual(a, b, msg) {
  261. if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
  262. }
  263. function makeStreamResponse(chunks) {
  264. let idx = 0;
  265. return {
  266. body: {
  267. getReader() {
  268. return {
  269. async read() {
  270. if (idx >= chunks.length) return { done: true };
  271. return { done: false, value: Buffer.from(chunks[idx++], 'utf8') };
  272. },
  273. };
  274. },
  275. },
  276. };
  277. }
  278. const workflow = {
  279. name: 'EditorRegression',
  280. version: '3.16',
  281. steps: [
  282. { id: 'Set_Start', target: '$msg', value: '="hello"', next: 'MetaDiff_Compute' },
  283. { id: 'MetaDiff_Compute', in: { oldMeta: '{}', newMeta: '{}' }, next: 'Stop_End' },
  284. { id: 'Stop_End' },
  285. ],
  286. };
  287. console.log('\n── Workflow Editor Regression ──');
  288. test('normalizes explicit Branch_ type', () => {
  289. assertEqual(editor.getStepType({ id: 'Branch_Check', type: 'Branch_' }), 'Branch');
  290. });
  291. test('preserves custom step prefix instead of forcing LLM', () => {
  292. assertEqual(editor.getStepType({ id: 'MetaDiff_020_DiffMeta' }), 'MetaDiff');
  293. });
  294. test('custom step uses readable fallback icon', () => {
  295. assertEqual(editor.getTypeIcon('MetaDiff'), 'ME');
  296. });
  297. test('Tool steps use dedicated icon', () => {
  298. assertEqual(editor.getTypeIcon('Tool'), 'TL');
  299. });
  300. test('WorkflowRun tool is promoted to Subflow display type', () => {
  301. assertEqual(editor.getStepType({ id: 'Tool_RunChild', tool: 'WorkflowRun' }), 'Subflow');
  302. });
  303. test('Subflow prefix stays first-class in the editor', () => {
  304. assertEqual(editor.getStepType({ id: 'Subflow_020_ExploreChild', workflow_path: 'child.json' }), 'Subflow');
  305. assertEqual(editor.getTypeIcon('Subflow'), 'SF');
  306. });
  307. test('parseWorkflow keeps custom node type for rendering', () => {
  308. editor.parseWorkflow(workflow, 'editor-regression');
  309. const node = editor.getState().nodes.find((n) => n.id === 'MetaDiff_Compute');
  310. assert(node, 'MetaDiff node missing');
  311. assertEqual(node.type, 'MetaDiff');
  312. });
  313. test('parseWorkflow keeps Tool node as first-class type', () => {
  314. editor.parseWorkflow({
  315. name: 'ToolWorkflow',
  316. version: '3.16',
  317. steps: [
  318. { id: 'Tool_UseReadFile', tool: 'ReadFile', input: { file_path: 'README.md' }, next: 'Stop_End' },
  319. { id: 'Stop_End' },
  320. ],
  321. }, 'tool-workflow');
  322. const node = editor.getState().nodes.find((n) => n.id === 'Tool_UseReadFile');
  323. assert(node, 'Tool node missing');
  324. assertEqual(node.type, 'Tool');
  325. });
  326. test('parseWorkflow renders subflow nodes with subflow type metadata', () => {
  327. editor.parseWorkflow({
  328. name: 'SubflowWorkflow',
  329. version: '3.16',
  330. steps: [
  331. { id: 'Subflow_020_ExploreChild', workflow_path: 'child.json', params: { seed: '="alpha"' }, next: 'Stop_End' },
  332. { id: 'Stop_End' },
  333. ],
  334. }, 'subflow-workflow');
  335. const node = editor.getState().nodes.find((n) => n.id === 'Subflow_020_ExploreChild');
  336. assert(node, 'Subflow node missing');
  337. assertEqual(node.type, 'Subflow');
  338. const html = editor.getRenderedNodeHtml('Subflow_020_ExploreChild');
  339. assert(html.includes('Subflow'), 'rendered html should include subflow type pill');
  340. assert(html.includes('Idle'), 'rendered html should include idle state pill');
  341. assert(editor.getTypeSidebarHtml().includes('Subflow'), 'type sidebar should include subflow entry');
  342. assert(editor.getTypeSummaryHtml().includes('Supported'), 'type summary should render stats');
  343. assert(!editor.getTypeSidebarHtml().includes('Service'), 'used-only sidebar should hide zero-count built-ins by default');
  344. });
  345. test('editor starts in compact embedded mode with events collapsed', () => {
  346. assert(editor.bodyHasClass('compact-ui'), 'body should start in compact mode');
  347. assert(editor.bodyHasClass('events-collapsed'), 'events should start collapsed');
  348. });
  349. test('dense view keeps Set/Write/Stop visible and compresses layout only', () => {
  350. editor.parseWorkflow(workflow, 'editor-regression');
  351. let rendered = editor.getRenderedNodeIds();
  352. assert(rendered.includes('node-MetaDiff_Compute'), 'dense view should keep structural nodes');
  353. assert(rendered.includes('node-Set_Start'), 'dense view should keep Set nodes');
  354. assert(rendered.includes('node-Stop_End'), 'dense view should keep Stop nodes');
  355. assertEqual(editor.getTypePanelTitle(), 'Node Types');
  356. assert(editor.getCanvasSize().width, 'canvas footprint should be tightened to rendered graph');
  357. const denseX = editor.getNodeCoords('MetaDiff_Compute')?.x;
  358. editor.toggleSimplifyGraph();
  359. rendered = editor.getRenderedNodeIds();
  360. const relaxedX = editor.getNodeCoords('MetaDiff_Compute')?.x;
  361. assert(denseX < relaxedX, 'dense view should place layers closer than relaxed view');
  362. editor.toggleSimplifyGraph();
  363. });
  364. test('selected node expands to show full inputs without truncation', () => {
  365. const longValue = '/Users/ivx/Documents/VLProjects/_tests/WorkflowEditorComplexDemoTest/.vl-code/workflows/cave-deep-scout-generated.json';
  366. editor.parseWorkflow({
  367. name: 'ExpandedNodeWorkflow',
  368. version: '3.16',
  369. steps: [
  370. {
  371. id: 'Tool_010_LongInput',
  372. tool: 'WriteFile',
  373. input: {
  374. file_path: longValue,
  375. content: 'x'.repeat(72),
  376. encoding: 'utf8',
  377. mode: 'overwrite',
  378. extraFlag: 'keep-this-visible',
  379. },
  380. next: 'Stop_End',
  381. },
  382. { id: 'Stop_End' },
  383. ],
  384. }, 'expanded-node-workflow');
  385. let html = editor.getRenderedNodeHtml('Tool_010_LongInput');
  386. assert(!html.includes('keep-this-visible'), 'collapsed node should still summarize inputs');
  387. editor.selectNode('Tool_010_LongInput');
  388. html = editor.getRenderedNodeHtml('Tool_010_LongInput');
  389. assert(html.includes(longValue), 'selected node should show the full file_path');
  390. assert(html.includes('keep-this-visible'), 'selected node should show additional input fields');
  391. assert(html.includes('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), 'selected node should show the full long content');
  392. });
  393. await testAsync('clicking canvas blank space collapses the expanded node', async () => {
  394. editor.parseWorkflow(workflow, 'editor-regression');
  395. editor.selectNode('Set_Start');
  396. assertEqual(editor.getSelectedNodeId(), 'Set_Start');
  397. await editor.triggerElementEvent('canvasWrap', 'click', {
  398. target: { closest: () => null },
  399. });
  400. assertEqual(editor.getSelectedNodeId(), null);
  401. });
  402. await testAsync('node dismiss button collapses the expanded node', async () => {
  403. editor.parseWorkflow(workflow, 'editor-regression');
  404. editor.selectNode('Set_Start');
  405. assertEqual(editor.getSelectedNodeId(), 'Set_Start');
  406. await editor.triggerNodeEvent('Set_Start', 'click', {
  407. target: { closest: (selector) => (selector === '.node-dismiss' ? {} : null) },
  408. });
  409. assertEqual(editor.getSelectedNodeId(), null);
  410. });
  411. await testAsync('double-clicking a Subflow node opens the child workflow', async () => {
  412. editor.parseWorkflow({
  413. name: 'ParentWithChildDoubleClick',
  414. version: '3.16',
  415. steps: [
  416. { id: 'Subflow_010_ExploreChild', workflow_path: 'child-demo', next: 'Stop_End' },
  417. { id: 'Stop_End' },
  418. ],
  419. }, 'parent-with-child-dbl');
  420. await editor.triggerNodeEvent('Subflow_010_ExploreChild', 'dblclick');
  421. assertEqual($('wfTitle').textContent, 'Child Demo Workflow');
  422. editor.navigateBack();
  423. });
  424. test('type filter toggle can reveal all built-in node types', () => {
  425. editor.toggleTypeFilter();
  426. assert(editor.getTypeSidebarHtml().includes('Service'), 'all-types mode should include zero-count built-ins');
  427. editor.toggleTypeFilter();
  428. });
  429. await testAsync('can drill into a static subflow and navigate back to the parent workflow', async () => {
  430. editor.parseWorkflow({
  431. name: 'ParentWithChild',
  432. version: '3.16',
  433. steps: [
  434. { id: 'Subflow_010_ExploreChild', workflow_path: 'child-demo', next: 'Stop_End' },
  435. { id: 'Stop_End' },
  436. ],
  437. }, 'parent-with-child');
  438. const parentTitle = $('wfTitle').textContent;
  439. editor.getState().selectedNodeId = 'Subflow_010_ExploreChild';
  440. await editor.openSubflowNode(editor.getState().nodes.find((node) => node.id === 'Subflow_010_ExploreChild'));
  441. assertEqual($('wfTitle').textContent, 'Child Demo Workflow');
  442. assert(editor.getBreadcrumb().includes('ParentWithChild'), 'breadcrumb should include parent workflow');
  443. editor.navigateBack();
  444. assertEqual($('wfTitle').textContent, parentTitle);
  445. assert(editor.getBreadcrumb().includes('ParentWithChild'), 'breadcrumb should restore parent workflow');
  446. });
  447. await testAsync('loadWorkflowFromLocation reads workflow query parameter', async () => {
  448. window.location.search = '?workflow=child-demo';
  449. await editor.loadWorkflowFromLocation();
  450. assertEqual($('wfTitle').textContent, 'Child Demo Workflow');
  451. window.location.search = '';
  452. });
  453. await testAsync('streamSSE maps event names into payload type and captures checkpoint', async () => {
  454. editor.parseWorkflow(workflow, 'editor-regression');
  455. const response = makeStreamResponse([
  456. 'event: node_start\ndata: {"nodeId":"Set_Start"}\n\n',
  457. 'event: checkpoint\ndata: {"runID":"run_123","stepID":"Set_Start","completedSteps":["Set_Start"],"checkpoint":{"currentStepID":"Set_Start","variables":{"$msg":"hello"}}}\n\n',
  458. 'event: node_done\ndata: {"nodeId":"Set_Start"}\n\n',
  459. 'event: done\ndata: {"filesWritten":[]}\n\n',
  460. ]);
  461. await editor.streamSSE(response);
  462. const node = editor.getState().nodes.find((n) => n.id === 'Set_Start');
  463. assert(node?.status === 'done', 'Set_Start should be marked done');
  464. assertEqual(editor.getRunID(), 'run_123');
  465. assertEqual(editor.getCheckpoint()?.currentStepID, 'Set_Start');
  466. assertEqual(editor.getCheckpoint()?.variables?.$msg, 'hello');
  467. assertEqual(editor.getStatus(), 'Complete Run 1! 0 files written');
  468. });
  469. await testAsync('keeps separate run sessions and can switch selected run', async () => {
  470. localStorage._store = {};
  471. editor.parseWorkflow(workflow, 'editor-regression');
  472. await editor.streamSSE(makeStreamResponse([
  473. 'event: checkpoint\ndata: {"runID":"run_A","checkpoint":{"currentStepID":"Set_Start","variables":{"$msg":"A"}}}\n\n',
  474. 'event: node_done\ndata: {"runID":"run_A","nodeId":"Set_Start"}\n\n',
  475. ]), 'run_A');
  476. await editor.streamSSE(makeStreamResponse([
  477. 'event: checkpoint\ndata: {"runID":"run_B","checkpoint":{"currentStepID":"MetaDiff_Compute","variables":{"$msg":"B"}}}\n\n',
  478. 'event: pause\ndata: {"runID":"run_B","nodeId":"MetaDiff_Compute"}\n\n',
  479. ]), 'run_B');
  480. const sessions = editor.getRunSessions();
  481. assertEqual(sessions.length, 2, 'should track two runs');
  482. assert(sessions.some((session) => session.runID === 'run_A' && session.status === 'running'), 'run_A missing');
  483. assert(sessions.some((session) => session.runID === 'run_B' && session.status === 'paused'), 'run_B missing');
  484. editor.selectRunSession('run_A');
  485. assertEqual(editor.getRunID(), 'run_A');
  486. assertEqual(editor.getCheckpoint()?.variables?.$msg, 'A');
  487. editor.selectRunSession('run_B');
  488. assertEqual(editor.getRunID(), 'run_B');
  489. assertEqual(editor.getCheckpoint()?.variables?.$msg, 'B');
  490. });
  491. await testAsync('client run token stays stable before first checkpoint and isolates concurrent starts', async () => {
  492. localStorage._store = {};
  493. editor.parseWorkflow(workflow, 'editor-regression');
  494. await editor.streamSSE(makeStreamResponse([
  495. 'event: node_start\ndata: {"clientRunToken":"client:A","nodeId":"Set_Start"}\n\n',
  496. 'event: checkpoint\ndata: {"clientRunToken":"client:A","runID":"run_A","checkpoint":{"currentStepID":"Set_Start","variables":{"$msg":"A"}}}\n\n',
  497. ]), 'client:A');
  498. await editor.streamSSE(makeStreamResponse([
  499. 'event: node_start\ndata: {"clientRunToken":"client:B","nodeId":"MetaDiff_Compute"}\n\n',
  500. 'event: pause\ndata: {"clientRunToken":"client:B","runID":"run_B","nodeId":"MetaDiff_Compute"}\n\n',
  501. 'event: checkpoint\ndata: {"clientRunToken":"client:B","runID":"run_B","checkpoint":{"currentStepID":"MetaDiff_Compute","variables":{"$msg":"B"}}}\n\n',
  502. ]), 'client:B');
  503. const sessions = editor.getRunSessions();
  504. assert(sessions.some((session) => session.sessionID === 'client:A' && session.runID === 'run_A'), 'client:A session missing');
  505. assert(sessions.some((session) => session.sessionID === 'client:B' && session.runID === 'run_B' && session.status === 'paused'), 'client:B session missing');
  506. editor.selectRunSession('client:A');
  507. assertEqual(editor.getRunID(), 'run_A');
  508. assertEqual(editor.getState().nodes.find((n) => n.id === 'Set_Start')?.status, 'running');
  509. editor.selectRunSession('client:B');
  510. assertEqual(editor.getRunID(), 'run_B');
  511. assertEqual(editor.getState().nodes.find((n) => n.id === 'MetaDiff_Compute')?.status, 'paused');
  512. });
  513. await testAsync('captures tool_message and tool lifecycle events in selected run log', async () => {
  514. localStorage._store = {};
  515. editor.parseWorkflow({
  516. name: 'ToolEventWorkflow',
  517. version: '3.16',
  518. steps: [
  519. { id: 'Tool_010_ReadFile', tool: 'ReadFile', input: { file_path: 'README.md' }, next: 'Stop_End' },
  520. { id: 'Stop_End' },
  521. ],
  522. }, 'tool-event-workflow');
  523. await editor.streamSSE(makeStreamResponse([
  524. 'event: workflow_start\ndata: {"clientRunToken":"client:tool"}\n\n',
  525. 'event: tool_start\ndata: {"clientRunToken":"client:tool","name":"ReadFile","stepId":"Tool_010_ReadFile","input":{"file_path":"README.md"}}\n\n',
  526. 'event: tool_message\ndata: {"clientRunToken":"client:tool","name":"ReadFile","stepId":"Tool_010_ReadFile","level":"info","message":"Reading file","data":{"file_path":"README.md"}}\n\n',
  527. 'event: tool_done\ndata: {"clientRunToken":"client:tool","runID":"run_tool","name":"ReadFile","stepId":"Tool_010_ReadFile","output":{"result":"ok"}}\n\n',
  528. 'event: done\ndata: {"clientRunToken":"client:tool","runID":"run_tool","filesWritten":[]}\n\n',
  529. ]), 'client:tool');
  530. const sessions = editor.getRunSessions();
  531. const run = sessions.find((session) => session.sessionID === 'client:tool');
  532. assert(run, 'tool run missing');
  533. assert(run.eventCount >= 4, 'expected tool events to be recorded');
  534. editor.selectRunSession('client:tool');
  535. const events = editor.getSelectedEvents();
  536. assert(events.some((event) => event.type === 'tool_message' && event.summary.includes('Reading file')), 'missing tool_message summary');
  537. assert(events.some((event) => event.type === 'tool_done'), 'missing tool_done event');
  538. });
  539. await testAsync('tool_start marks the active node as waiting and tool_done returns it to running', async () => {
  540. localStorage._store = {};
  541. editor.parseWorkflow({
  542. name: 'SubflowWaitingWorkflow',
  543. version: '3.16',
  544. steps: [
  545. { id: 'Subflow_010_Explore', workflow_path: 'child.json', next: 'Stop_End' },
  546. { id: 'Stop_End' },
  547. ],
  548. }, 'subflow-waiting');
  549. editor.handleExecEvent({ type: 'workflow_start', clientRunToken: 'client:wait' }, 'client:wait');
  550. editor.handleExecEvent({ type: 'node_start', clientRunToken: 'client:wait', nodeId: 'Subflow_010_Explore' }, 'client:wait');
  551. editor.handleExecEvent({ type: 'tool_start', clientRunToken: 'client:wait', stepId: 'Subflow_010_Explore', name: 'WorkflowRun' }, 'client:wait');
  552. editor.selectRunSession('client:wait');
  553. let node = editor.getState().nodes.find((n) => n.id === 'Subflow_010_Explore');
  554. assertEqual(node?.status, 'waiting');
  555. assert(editor.getStatus().includes('Waiting'), 'status label should show waiting');
  556. editor.handleExecEvent({ type: 'tool_done', clientRunToken: 'client:wait', stepId: 'Subflow_010_Explore', name: 'WorkflowRun' }, 'client:wait');
  557. node = editor.getState().nodes.find((n) => n.id === 'Subflow_010_Explore');
  558. assertEqual(node?.status, 'running');
  559. });
  560. await testAsync('runWorkflow allows a second run while the first run stream is still active', async () => {
  561. localStorage._store = {};
  562. editor.parseWorkflow(workflow, 'editor-regression');
  563. let executeCalls = 0;
  564. global.fetch = async (url) => {
  565. if (url === '/api/workflow/ephemeral') {
  566. return { json: async () => ({ ok: true, name: '_ephemeral-test' }) };
  567. }
  568. if (url === '/api/workflow/execute') {
  569. executeCalls += 1;
  570. if (executeCalls === 1) {
  571. return makeStreamResponse([
  572. 'event: workflow_start\ndata: {"clientRunToken":"client:1"}\n\n',
  573. 'event: node_start\ndata: {"clientRunToken":"client:1","nodeId":"Set_Start"}\n\n',
  574. 'event: checkpoint\ndata: {"clientRunToken":"client:1","runID":"run_1","checkpoint":{"currentStepID":"Set_Start","variables":{"$msg":"one"}}}\n\n',
  575. 'event: pause\ndata: {"clientRunToken":"client:1","runID":"run_1","nodeId":"Set_Start"}\n\n',
  576. ]);
  577. }
  578. return makeStreamResponse([
  579. 'event: workflow_start\ndata: {"clientRunToken":"client:2"}\n\n',
  580. 'event: node_start\ndata: {"clientRunToken":"client:2","nodeId":"MetaDiff_Compute"}\n\n',
  581. 'event: checkpoint\ndata: {"clientRunToken":"client:2","runID":"run_2","checkpoint":{"currentStepID":"MetaDiff_Compute","variables":{"$msg":"two"}}}\n\n',
  582. 'event: pause\ndata: {"clientRunToken":"client:2","runID":"run_2","nodeId":"MetaDiff_Compute"}\n\n',
  583. ]);
  584. }
  585. return {
  586. ok: true,
  587. json: async () => ({}),
  588. body: { getReader() { return { read: async () => ({ done: true }) }; } },
  589. };
  590. };
  591. await Promise.all([editor.runWorkflow(), editor.runWorkflow()]);
  592. const sessions = editor.getRunSessions();
  593. assertEqual(executeCalls, 2, 'should issue two execute requests');
  594. assertEqual(sessions.length, 2, 'should keep two run sessions');
  595. assert(sessions.some((session) => session.sessionID?.startsWith('client:') && session.runID === 'run_1'), 'first run missing');
  596. assert(sessions.some((session) => session.sessionID?.startsWith('client:') && session.runID === 'run_2'), 'second run missing');
  597. });
  598. await testAsync('ensureWorkflowRef stages imported workflows via ephemeral API', async () => {
  599. editor.parseWorkflow({ name: 'UnsavedWorkflow', version: '3.16', steps: [{ id: 'Stop_End' }] });
  600. const ref = await editor.ensureWorkflowRef();
  601. assertEqual(ref, '_ephemeral-test');
  602. });
  603. console.log(`\n── Results ──\n\n ${passed} passed, ${failed} failed\n`);
  604. process.exit(failed > 0 ? 1 : 0);