| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668 |
- import fs from 'fs';
- import path from 'path';
- const html = fs.readFileSync(path.join(process.cwd(), 'public/workflow-editor.html'), 'utf8');
- const scriptMatch = html.match(/<script>([\s\S]*)<\/script>/);
- if (!scriptMatch) {
- console.error('Cannot extract <script> from public/workflow-editor.html');
- process.exit(1);
- }
- const jsCode = scriptMatch[1];
- const elements = {};
- function createEl(id) {
- const classSet = new Set();
- const listeners = new Map();
- const el = {
- id,
- textContent: '',
- value: '',
- children: [],
- dataset: {},
- style: { display: '', left: '', top: '' },
- className: '',
- classList: {
- add(...names) { names.forEach((name) => classSet.add(name)); },
- remove(...names) { names.forEach((name) => classSet.delete(name)); },
- contains(name) { return classSet.has(name); },
- toggle(name, force) {
- if (force === undefined) {
- if (classSet.has(name)) classSet.delete(name);
- else classSet.add(name);
- return classSet.has(name);
- }
- if (force) classSet.add(name);
- else classSet.delete(name);
- return !!force;
- },
- },
- offsetLeft: 100,
- offsetTop: 100,
- offsetWidth: 240,
- offsetHeight: 120,
- clientWidth: 800,
- clientHeight: 600,
- scrollLeft: 0,
- scrollTop: 0,
- appendChild(child) { this.children.push(child); return child; },
- querySelectorAll() { return []; },
- addEventListener(type, handler) {
- if (!listeners.has(type)) listeners.set(type, []);
- listeners.get(type).push(handler);
- },
- setAttribute() {},
- click() {},
- scrollIntoView() {},
- closest() { return null; },
- remove() {},
- getContext() {
- return {
- clearRect() {}, beginPath() {}, moveTo() {}, lineTo() {}, stroke() {},
- fillRect() {}, fill() {}, fillText() {}, arc() {}, bezierCurveTo() {},
- quadraticCurveTo() {}, closePath() {}, setLineDash() {}, strokeRect() {},
- scale() {},
- set globalAlpha(v) {},
- get globalAlpha() { return 1; },
- set fillStyle(v) {},
- get fillStyle() { return ''; },
- set strokeStyle(v) {},
- get strokeStyle() { return ''; },
- set lineWidth(v) {},
- set font(v) {},
- set textAlign(v) {},
- };
- },
- get files() { return []; },
- };
- let innerHTML = '';
- Object.defineProperty(el, 'innerHTML', {
- get() { return innerHTML; },
- set(value) {
- innerHTML = value;
- if (value === '') this.children = [];
- },
- });
- el.dispatch = async (type, event = {}) => {
- const handlers = listeners.get(type) || [];
- for (const handler of handlers) {
- await handler({
- preventDefault() {},
- stopPropagation() {},
- button: 0,
- ...event,
- });
- }
- };
- return el;
- }
- function $(id) {
- if (!elements[id]) elements[id] = createEl(id);
- return elements[id];
- }
- global.document = {
- getElementById: (id) => $(id),
- createElement: (tag) => createEl(tag),
- createElementNS: (ns, tag) => createEl(tag),
- addEventListener() {},
- body: createEl('body'),
- };
- global.window = {
- parent: { postMessage() {} },
- addEventListener() {},
- innerWidth: 1200,
- innerHeight: 800,
- location: { search: '' },
- };
- Object.defineProperty(globalThis, 'navigator', {
- configurable: true,
- value: { clipboard: { writeText() {} } },
- });
- global.localStorage = {
- _store: {},
- getItem(k) { return this._store[k] || null; },
- setItem(k, v) { this._store[k] = v; },
- removeItem(k) { delete this._store[k]; },
- };
- global.URL = { createObjectURL() { return 'blob:test'; } };
- global.Blob = class Blob { constructor() {} };
- global.requestAnimationFrame = (fn) => fn();
- global.setTimeout = (fn) => fn();
- global.fetch = async (url) => {
- if (url === '/api/workflow/ephemeral') {
- return { json: async () => ({ ok: true, name: '_ephemeral-test' }) };
- }
- if (String(url).startsWith('/api/workflow-content?')) {
- return {
- ok: true,
- json: async () => ({
- workflowRef: 'child-demo',
- title: 'Child Demo Workflow',
- workflow: {
- name: 'Child Demo Workflow',
- version: '3.16',
- steps: [{ id: 'Stop_End' }],
- },
- }),
- };
- }
- return {
- ok: true,
- json: async () => ({}),
- body: {
- getReader() {
- return { read: async () => ({ done: true }) };
- },
- },
- };
- };
- const bootEditor = new Function('global', `
- ${jsCode}
- global._editor = {
- parseWorkflow,
- getStepType,
- streamSSE,
- handleExecEvent,
- ensureWorkflowRef,
- runWorkflow,
- rerunFromStep,
- openSubflowNode,
- navigateBack,
- loadWorkflowFromLocation,
- toggleCompactMode,
- toggleSimplifyGraph,
- toggleTypeFilter,
- toggleEventPanel,
- getTypeIcon,
- selectRunSession,
- getState: () => state,
- getRenderedNodeHtml: (nodeId) => {
- const layer = $('nodesLayer');
- const node = (layer.children || []).find((child) => child.id === \`node-\${nodeId}\`);
- return node?.innerHTML || '';
- },
- getCheckpoint: () => _lastCheckpoint,
- getRunID: () => _currentRunID,
- getStatus: () => $('statusLabel').textContent,
- getSelectedEvents: () => (getSelectedRunSession()?.events || []).map((event) => ({
- type: event.type,
- summary: event.summary,
- detail: event.detail,
- level: event.level,
- })),
- getRunSessions: () => Array.from(_runSessions.values()).map((session) => ({
- sessionID: session.sessionID,
- clientRunToken: session.clientRunToken,
- runID: session.runID,
- label: session.label,
- status: session.status,
- currentStepID: session.currentStepID,
- eventCount: Array.isArray(session.events) ? session.events.length : 0,
- })),
- getBreadcrumb: () => $('wfBreadcrumb').textContent,
- getTypeSidebarHtml: () => $('typeList').innerHTML,
- getTypeSummaryHtml: () => $('typeSummary').innerHTML,
- getTypePanelTitle: () => $('typePanelTitle').textContent,
- getRenderedNodeIds: () => (($('nodesLayer').children || []).map((child) => child.id)),
- getCanvasSize: () => ({ width: $('canvas').style.width, height: $('canvas').style.height }),
- getNodeCoords: (nodeId) => {
- const node = state.nodes.find((entry) => entry.id === nodeId);
- return node ? { x: node.x, y: node.y } : null;
- },
- triggerNodeEvent: async (nodeId, type, event = {}) => {
- const layer = $('nodesLayer');
- const node = (layer.children || []).find((child) => child.id === \`node-\${nodeId}\`);
- if (!node?.dispatch) throw new Error('node element missing');
- await node.dispatch(type, event);
- },
- selectNode: (nodeId) => {
- state.selectedNodeId = nodeId;
- renderNodes();
- renderConnections();
- updateNavigationChrome();
- },
- deselectNode: () => {
- deselectSelectedNode();
- },
- getSelectedNodeId: () => state.selectedNodeId,
- triggerElementEvent: async (id, type, event = {}) => {
- const el = $(id);
- if (!el?.dispatch) throw new Error('element missing');
- await el.dispatch(type, event);
- },
- bodyHasClass: (name) => document.body.classList.contains(name),
- };
- `);
- bootEditor(global);
- const editor = global._editor;
- let passed = 0;
- let failed = 0;
- function test(name, fn) {
- try {
- fn();
- console.log(` ✓ ${name}`);
- passed++;
- } catch (e) {
- console.log(` ✗ ${name}: ${e.message}`);
- failed++;
- }
- }
- async function testAsync(name, fn) {
- try {
- await fn();
- console.log(` ✓ ${name}`);
- passed++;
- } catch (e) {
- console.log(` ✗ ${name}: ${e.message}`);
- failed++;
- }
- }
- function assert(cond, msg) {
- if (!cond) throw new Error(msg || 'Assertion failed');
- }
- function assertEqual(a, b, msg) {
- if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
- }
- function makeStreamResponse(chunks) {
- let idx = 0;
- return {
- body: {
- getReader() {
- return {
- async read() {
- if (idx >= chunks.length) return { done: true };
- return { done: false, value: Buffer.from(chunks[idx++], 'utf8') };
- },
- };
- },
- },
- };
- }
- const workflow = {
- name: 'EditorRegression',
- version: '3.16',
- steps: [
- { id: 'Set_Start', target: '$msg', value: '="hello"', next: 'MetaDiff_Compute' },
- { id: 'MetaDiff_Compute', in: { oldMeta: '{}', newMeta: '{}' }, next: 'Stop_End' },
- { id: 'Stop_End' },
- ],
- };
- console.log('\n── Workflow Editor Regression ──');
- test('normalizes explicit Branch_ type', () => {
- assertEqual(editor.getStepType({ id: 'Branch_Check', type: 'Branch_' }), 'Branch');
- });
- test('preserves custom step prefix instead of forcing LLM', () => {
- assertEqual(editor.getStepType({ id: 'MetaDiff_020_DiffMeta' }), 'MetaDiff');
- });
- test('custom step uses readable fallback icon', () => {
- assertEqual(editor.getTypeIcon('MetaDiff'), 'ME');
- });
- test('Tool steps use dedicated icon', () => {
- assertEqual(editor.getTypeIcon('Tool'), 'TL');
- });
- test('WorkflowRun tool is promoted to Subflow display type', () => {
- assertEqual(editor.getStepType({ id: 'Tool_RunChild', tool: 'WorkflowRun' }), 'Subflow');
- });
- test('Subflow prefix stays first-class in the editor', () => {
- assertEqual(editor.getStepType({ id: 'Subflow_020_ExploreChild', workflow_path: 'child.json' }), 'Subflow');
- assertEqual(editor.getTypeIcon('Subflow'), 'SF');
- });
- test('parseWorkflow keeps custom node type for rendering', () => {
- editor.parseWorkflow(workflow, 'editor-regression');
- const node = editor.getState().nodes.find((n) => n.id === 'MetaDiff_Compute');
- assert(node, 'MetaDiff node missing');
- assertEqual(node.type, 'MetaDiff');
- });
- test('parseWorkflow keeps Tool node as first-class type', () => {
- editor.parseWorkflow({
- name: 'ToolWorkflow',
- version: '3.16',
- steps: [
- { id: 'Tool_UseReadFile', tool: 'ReadFile', input: { file_path: 'README.md' }, next: 'Stop_End' },
- { id: 'Stop_End' },
- ],
- }, 'tool-workflow');
- const node = editor.getState().nodes.find((n) => n.id === 'Tool_UseReadFile');
- assert(node, 'Tool node missing');
- assertEqual(node.type, 'Tool');
- });
- test('parseWorkflow renders subflow nodes with subflow type metadata', () => {
- editor.parseWorkflow({
- name: 'SubflowWorkflow',
- version: '3.16',
- steps: [
- { id: 'Subflow_020_ExploreChild', workflow_path: 'child.json', params: { seed: '="alpha"' }, next: 'Stop_End' },
- { id: 'Stop_End' },
- ],
- }, 'subflow-workflow');
- const node = editor.getState().nodes.find((n) => n.id === 'Subflow_020_ExploreChild');
- assert(node, 'Subflow node missing');
- assertEqual(node.type, 'Subflow');
- const html = editor.getRenderedNodeHtml('Subflow_020_ExploreChild');
- assert(html.includes('Subflow'), 'rendered html should include subflow type pill');
- assert(html.includes('Idle'), 'rendered html should include idle state pill');
- assert(editor.getTypeSidebarHtml().includes('Subflow'), 'type sidebar should include subflow entry');
- assert(editor.getTypeSummaryHtml().includes('Supported'), 'type summary should render stats');
- assert(!editor.getTypeSidebarHtml().includes('Service'), 'used-only sidebar should hide zero-count built-ins by default');
- });
- test('editor starts in compact embedded mode with events collapsed', () => {
- assert(editor.bodyHasClass('compact-ui'), 'body should start in compact mode');
- assert(editor.bodyHasClass('events-collapsed'), 'events should start collapsed');
- });
- test('dense view keeps Set/Write/Stop visible and compresses layout only', () => {
- editor.parseWorkflow(workflow, 'editor-regression');
- let rendered = editor.getRenderedNodeIds();
- assert(rendered.includes('node-MetaDiff_Compute'), 'dense view should keep structural nodes');
- assert(rendered.includes('node-Set_Start'), 'dense view should keep Set nodes');
- assert(rendered.includes('node-Stop_End'), 'dense view should keep Stop nodes');
- assertEqual(editor.getTypePanelTitle(), 'Node Types');
- assert(editor.getCanvasSize().width, 'canvas footprint should be tightened to rendered graph');
- const denseX = editor.getNodeCoords('MetaDiff_Compute')?.x;
- editor.toggleSimplifyGraph();
- rendered = editor.getRenderedNodeIds();
- const relaxedX = editor.getNodeCoords('MetaDiff_Compute')?.x;
- assert(denseX < relaxedX, 'dense view should place layers closer than relaxed view');
- editor.toggleSimplifyGraph();
- });
- test('selected node expands to show full inputs without truncation', () => {
- const longValue = '/Users/ivx/Documents/VLProjects/_tests/WorkflowEditorComplexDemoTest/.vl-code/workflows/cave-deep-scout-generated.json';
- editor.parseWorkflow({
- name: 'ExpandedNodeWorkflow',
- version: '3.16',
- steps: [
- {
- id: 'Tool_010_LongInput',
- tool: 'WriteFile',
- input: {
- file_path: longValue,
- content: 'x'.repeat(72),
- encoding: 'utf8',
- mode: 'overwrite',
- extraFlag: 'keep-this-visible',
- },
- next: 'Stop_End',
- },
- { id: 'Stop_End' },
- ],
- }, 'expanded-node-workflow');
- let html = editor.getRenderedNodeHtml('Tool_010_LongInput');
- assert(!html.includes('keep-this-visible'), 'collapsed node should still summarize inputs');
- editor.selectNode('Tool_010_LongInput');
- html = editor.getRenderedNodeHtml('Tool_010_LongInput');
- assert(html.includes(longValue), 'selected node should show the full file_path');
- assert(html.includes('keep-this-visible'), 'selected node should show additional input fields');
- assert(html.includes('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), 'selected node should show the full long content');
- });
- await testAsync('clicking canvas blank space collapses the expanded node', async () => {
- editor.parseWorkflow(workflow, 'editor-regression');
- editor.selectNode('Set_Start');
- assertEqual(editor.getSelectedNodeId(), 'Set_Start');
- await editor.triggerElementEvent('canvasWrap', 'click', {
- target: { closest: () => null },
- });
- assertEqual(editor.getSelectedNodeId(), null);
- });
- await testAsync('node dismiss button collapses the expanded node', async () => {
- editor.parseWorkflow(workflow, 'editor-regression');
- editor.selectNode('Set_Start');
- assertEqual(editor.getSelectedNodeId(), 'Set_Start');
- await editor.triggerNodeEvent('Set_Start', 'click', {
- target: { closest: (selector) => (selector === '.node-dismiss' ? {} : null) },
- });
- assertEqual(editor.getSelectedNodeId(), null);
- });
- await testAsync('double-clicking a Subflow node opens the child workflow', async () => {
- editor.parseWorkflow({
- name: 'ParentWithChildDoubleClick',
- version: '3.16',
- steps: [
- { id: 'Subflow_010_ExploreChild', workflow_path: 'child-demo', next: 'Stop_End' },
- { id: 'Stop_End' },
- ],
- }, 'parent-with-child-dbl');
- await editor.triggerNodeEvent('Subflow_010_ExploreChild', 'dblclick');
- assertEqual($('wfTitle').textContent, 'Child Demo Workflow');
- editor.navigateBack();
- });
- test('type filter toggle can reveal all built-in node types', () => {
- editor.toggleTypeFilter();
- assert(editor.getTypeSidebarHtml().includes('Service'), 'all-types mode should include zero-count built-ins');
- editor.toggleTypeFilter();
- });
- await testAsync('can drill into a static subflow and navigate back to the parent workflow', async () => {
- editor.parseWorkflow({
- name: 'ParentWithChild',
- version: '3.16',
- steps: [
- { id: 'Subflow_010_ExploreChild', workflow_path: 'child-demo', next: 'Stop_End' },
- { id: 'Stop_End' },
- ],
- }, 'parent-with-child');
- const parentTitle = $('wfTitle').textContent;
- editor.getState().selectedNodeId = 'Subflow_010_ExploreChild';
- await editor.openSubflowNode(editor.getState().nodes.find((node) => node.id === 'Subflow_010_ExploreChild'));
- assertEqual($('wfTitle').textContent, 'Child Demo Workflow');
- assert(editor.getBreadcrumb().includes('ParentWithChild'), 'breadcrumb should include parent workflow');
- editor.navigateBack();
- assertEqual($('wfTitle').textContent, parentTitle);
- assert(editor.getBreadcrumb().includes('ParentWithChild'), 'breadcrumb should restore parent workflow');
- });
- await testAsync('loadWorkflowFromLocation reads workflow query parameter', async () => {
- window.location.search = '?workflow=child-demo';
- await editor.loadWorkflowFromLocation();
- assertEqual($('wfTitle').textContent, 'Child Demo Workflow');
- window.location.search = '';
- });
- await testAsync('streamSSE maps event names into payload type and captures checkpoint', async () => {
- editor.parseWorkflow(workflow, 'editor-regression');
- const response = makeStreamResponse([
- 'event: node_start\ndata: {"nodeId":"Set_Start"}\n\n',
- 'event: checkpoint\ndata: {"runID":"run_123","stepID":"Set_Start","completedSteps":["Set_Start"],"checkpoint":{"currentStepID":"Set_Start","variables":{"$msg":"hello"}}}\n\n',
- 'event: node_done\ndata: {"nodeId":"Set_Start"}\n\n',
- 'event: done\ndata: {"filesWritten":[]}\n\n',
- ]);
- await editor.streamSSE(response);
- const node = editor.getState().nodes.find((n) => n.id === 'Set_Start');
- assert(node?.status === 'done', 'Set_Start should be marked done');
- assertEqual(editor.getRunID(), 'run_123');
- assertEqual(editor.getCheckpoint()?.currentStepID, 'Set_Start');
- assertEqual(editor.getCheckpoint()?.variables?.$msg, 'hello');
- assertEqual(editor.getStatus(), 'Complete Run 1! 0 files written');
- });
- await testAsync('keeps separate run sessions and can switch selected run', async () => {
- localStorage._store = {};
- editor.parseWorkflow(workflow, 'editor-regression');
- await editor.streamSSE(makeStreamResponse([
- 'event: checkpoint\ndata: {"runID":"run_A","checkpoint":{"currentStepID":"Set_Start","variables":{"$msg":"A"}}}\n\n',
- 'event: node_done\ndata: {"runID":"run_A","nodeId":"Set_Start"}\n\n',
- ]), 'run_A');
- await editor.streamSSE(makeStreamResponse([
- 'event: checkpoint\ndata: {"runID":"run_B","checkpoint":{"currentStepID":"MetaDiff_Compute","variables":{"$msg":"B"}}}\n\n',
- 'event: pause\ndata: {"runID":"run_B","nodeId":"MetaDiff_Compute"}\n\n',
- ]), 'run_B');
- const sessions = editor.getRunSessions();
- assertEqual(sessions.length, 2, 'should track two runs');
- assert(sessions.some((session) => session.runID === 'run_A' && session.status === 'running'), 'run_A missing');
- assert(sessions.some((session) => session.runID === 'run_B' && session.status === 'paused'), 'run_B missing');
- editor.selectRunSession('run_A');
- assertEqual(editor.getRunID(), 'run_A');
- assertEqual(editor.getCheckpoint()?.variables?.$msg, 'A');
- editor.selectRunSession('run_B');
- assertEqual(editor.getRunID(), 'run_B');
- assertEqual(editor.getCheckpoint()?.variables?.$msg, 'B');
- });
- await testAsync('client run token stays stable before first checkpoint and isolates concurrent starts', async () => {
- localStorage._store = {};
- editor.parseWorkflow(workflow, 'editor-regression');
- await editor.streamSSE(makeStreamResponse([
- 'event: node_start\ndata: {"clientRunToken":"client:A","nodeId":"Set_Start"}\n\n',
- 'event: checkpoint\ndata: {"clientRunToken":"client:A","runID":"run_A","checkpoint":{"currentStepID":"Set_Start","variables":{"$msg":"A"}}}\n\n',
- ]), 'client:A');
- await editor.streamSSE(makeStreamResponse([
- 'event: node_start\ndata: {"clientRunToken":"client:B","nodeId":"MetaDiff_Compute"}\n\n',
- 'event: pause\ndata: {"clientRunToken":"client:B","runID":"run_B","nodeId":"MetaDiff_Compute"}\n\n',
- 'event: checkpoint\ndata: {"clientRunToken":"client:B","runID":"run_B","checkpoint":{"currentStepID":"MetaDiff_Compute","variables":{"$msg":"B"}}}\n\n',
- ]), 'client:B');
- const sessions = editor.getRunSessions();
- assert(sessions.some((session) => session.sessionID === 'client:A' && session.runID === 'run_A'), 'client:A session missing');
- assert(sessions.some((session) => session.sessionID === 'client:B' && session.runID === 'run_B' && session.status === 'paused'), 'client:B session missing');
- editor.selectRunSession('client:A');
- assertEqual(editor.getRunID(), 'run_A');
- assertEqual(editor.getState().nodes.find((n) => n.id === 'Set_Start')?.status, 'running');
- editor.selectRunSession('client:B');
- assertEqual(editor.getRunID(), 'run_B');
- assertEqual(editor.getState().nodes.find((n) => n.id === 'MetaDiff_Compute')?.status, 'paused');
- });
- await testAsync('captures tool_message and tool lifecycle events in selected run log', async () => {
- localStorage._store = {};
- editor.parseWorkflow({
- name: 'ToolEventWorkflow',
- version: '3.16',
- steps: [
- { id: 'Tool_010_ReadFile', tool: 'ReadFile', input: { file_path: 'README.md' }, next: 'Stop_End' },
- { id: 'Stop_End' },
- ],
- }, 'tool-event-workflow');
- await editor.streamSSE(makeStreamResponse([
- 'event: workflow_start\ndata: {"clientRunToken":"client:tool"}\n\n',
- 'event: tool_start\ndata: {"clientRunToken":"client:tool","name":"ReadFile","stepId":"Tool_010_ReadFile","input":{"file_path":"README.md"}}\n\n',
- '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',
- 'event: tool_done\ndata: {"clientRunToken":"client:tool","runID":"run_tool","name":"ReadFile","stepId":"Tool_010_ReadFile","output":{"result":"ok"}}\n\n',
- 'event: done\ndata: {"clientRunToken":"client:tool","runID":"run_tool","filesWritten":[]}\n\n',
- ]), 'client:tool');
- const sessions = editor.getRunSessions();
- const run = sessions.find((session) => session.sessionID === 'client:tool');
- assert(run, 'tool run missing');
- assert(run.eventCount >= 4, 'expected tool events to be recorded');
- editor.selectRunSession('client:tool');
- const events = editor.getSelectedEvents();
- assert(events.some((event) => event.type === 'tool_message' && event.summary.includes('Reading file')), 'missing tool_message summary');
- assert(events.some((event) => event.type === 'tool_done'), 'missing tool_done event');
- });
- await testAsync('tool_start marks the active node as waiting and tool_done returns it to running', async () => {
- localStorage._store = {};
- editor.parseWorkflow({
- name: 'SubflowWaitingWorkflow',
- version: '3.16',
- steps: [
- { id: 'Subflow_010_Explore', workflow_path: 'child.json', next: 'Stop_End' },
- { id: 'Stop_End' },
- ],
- }, 'subflow-waiting');
- editor.handleExecEvent({ type: 'workflow_start', clientRunToken: 'client:wait' }, 'client:wait');
- editor.handleExecEvent({ type: 'node_start', clientRunToken: 'client:wait', nodeId: 'Subflow_010_Explore' }, 'client:wait');
- editor.handleExecEvent({ type: 'tool_start', clientRunToken: 'client:wait', stepId: 'Subflow_010_Explore', name: 'WorkflowRun' }, 'client:wait');
- editor.selectRunSession('client:wait');
- let node = editor.getState().nodes.find((n) => n.id === 'Subflow_010_Explore');
- assertEqual(node?.status, 'waiting');
- assert(editor.getStatus().includes('Waiting'), 'status label should show waiting');
- editor.handleExecEvent({ type: 'tool_done', clientRunToken: 'client:wait', stepId: 'Subflow_010_Explore', name: 'WorkflowRun' }, 'client:wait');
- node = editor.getState().nodes.find((n) => n.id === 'Subflow_010_Explore');
- assertEqual(node?.status, 'running');
- });
- await testAsync('runWorkflow allows a second run while the first run stream is still active', async () => {
- localStorage._store = {};
- editor.parseWorkflow(workflow, 'editor-regression');
- let executeCalls = 0;
- global.fetch = async (url) => {
- if (url === '/api/workflow/ephemeral') {
- return { json: async () => ({ ok: true, name: '_ephemeral-test' }) };
- }
- if (url === '/api/workflow/execute') {
- executeCalls += 1;
- if (executeCalls === 1) {
- return makeStreamResponse([
- 'event: workflow_start\ndata: {"clientRunToken":"client:1"}\n\n',
- 'event: node_start\ndata: {"clientRunToken":"client:1","nodeId":"Set_Start"}\n\n',
- 'event: checkpoint\ndata: {"clientRunToken":"client:1","runID":"run_1","checkpoint":{"currentStepID":"Set_Start","variables":{"$msg":"one"}}}\n\n',
- 'event: pause\ndata: {"clientRunToken":"client:1","runID":"run_1","nodeId":"Set_Start"}\n\n',
- ]);
- }
- return makeStreamResponse([
- 'event: workflow_start\ndata: {"clientRunToken":"client:2"}\n\n',
- 'event: node_start\ndata: {"clientRunToken":"client:2","nodeId":"MetaDiff_Compute"}\n\n',
- 'event: checkpoint\ndata: {"clientRunToken":"client:2","runID":"run_2","checkpoint":{"currentStepID":"MetaDiff_Compute","variables":{"$msg":"two"}}}\n\n',
- 'event: pause\ndata: {"clientRunToken":"client:2","runID":"run_2","nodeId":"MetaDiff_Compute"}\n\n',
- ]);
- }
- return {
- ok: true,
- json: async () => ({}),
- body: { getReader() { return { read: async () => ({ done: true }) }; } },
- };
- };
- await Promise.all([editor.runWorkflow(), editor.runWorkflow()]);
- const sessions = editor.getRunSessions();
- assertEqual(executeCalls, 2, 'should issue two execute requests');
- assertEqual(sessions.length, 2, 'should keep two run sessions');
- assert(sessions.some((session) => session.sessionID?.startsWith('client:') && session.runID === 'run_1'), 'first run missing');
- assert(sessions.some((session) => session.sessionID?.startsWith('client:') && session.runID === 'run_2'), 'second run missing');
- });
- await testAsync('ensureWorkflowRef stages imported workflows via ephemeral API', async () => {
- editor.parseWorkflow({ name: 'UnsavedWorkflow', version: '3.16', steps: [{ id: 'Stop_End' }] });
- const ref = await editor.ensureWorkflowRef();
- assertEqual(ref, '_ephemeral-test');
- });
- console.log(`\n── Results ──\n\n ${passed} passed, ${failed} failed\n`);
- process.exit(failed > 0 ? 1 : 0);
|