diff --git a/web/static/js/workflow-save.test.cjs b/web/static/js/workflow-save.test.cjs new file mode 100644 index 00000000..31339f4b --- /dev/null +++ b/web/static/js/workflow-save.test.cjs @@ -0,0 +1,114 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const source = fs.readFileSync(path.join(__dirname, 'workflows.js'), 'utf8'); + +function createEditor() { + const elements = { + 'workflow-id': { value: '' }, + 'workflow-name': { value: '' }, + 'workflow-description': { value: '' }, + 'workflow-enabled': { checked: true }, + 'workflow-canvas': {} + }; + const nodes = [ + { id: 'start', type: 'start', config: {} }, + { id: 'output', type: 'output', config: { output_key: 'result' } } + ].map(data => ({ id: () => data.id, data: key => data[key], position: () => ({ x: 0, y: 0 }) })); + const state = { calls: [], notifications: [], workflows: [], modalOpen: false }; + const context = { + document: { getElementById: id => elements[id] || null, querySelector: () => null, addEventListener() {} }, + cytoscape: () => ({ + nodes: () => nodes, + edges: () => [{ id: () => 'edge', source: () => nodes[0], target: () => nodes[1], data: () => '' }], + on() {}, resize() {} + }), + openAppModal: () => { state.modalOpen = true; }, + closeAppModal: () => { state.modalOpen = false; }, + showNotification: (message, type) => state.notifications.push({ message, type }), + apiFetch: async (url, options = {}) => { + const body = options.body ? JSON.parse(options.body) : undefined; + state.calls.push({ url, method: options.method || 'GET', body }); + if (url === '/api/workflows/validate') { + if (state.validationGate) await state.validationGate; + return { ok: true }; + } + if (options.method === 'POST' || options.method === 'PUT') { + const workflow = { ...body, graph_json: JSON.stringify(body.graph), version: 1 }; + state.workflows = [workflow]; + return { ok: true, json: async () => ({ workflow }) }; + } + if (url.startsWith('/api/workflows?')) return { ok: true, json: async () => ({ workflows: state.workflows }) }; + throw new Error('Unexpected API: ' + url); + } + }; + context.window = context; + vm.createContext(context); + vm.runInContext(source, context, { filename: 'workflows.js' }); + const fillMeta = () => { + elements['workflow-id'].value = 'save-regression'; + elements['workflow-name'].value = 'Save regression'; + }; + const saves = () => state.calls.filter(call => call.url === '/api/workflows' && call.method === 'POST'); + return { context, state, elements, fillMeta, saves }; +} + +test('Save resumes after metadata confirmation and submits the graph once', async () => { + const editor = createEditor(); + await editor.context.saveWorkflowDraft(); + assert.equal(editor.state.modalOpen, true); + assert.equal(editor.state.calls.length, 0); + editor.fillMeta(); + let finishValidation; + editor.state.validationGate = new Promise(resolve => { finishValidation = resolve; }); + const saving = editor.context.applyWorkflowMetaModal(); + await editor.context.applyWorkflowMetaModal(); + assert.equal(editor.state.calls.filter(call => call.url === '/api/workflows/validate').length, 1); + assert.equal(editor.saves().length, 0); + finishValidation(); + await saving; + assert.equal(editor.saves().length, 1); + assert.equal(editor.saves()[0].body.name, 'Save regression'); + assert.deepEqual(editor.saves()[0].body.graph.nodes.map(node => node.id), ['start', 'output']); + assert.equal(editor.state.modalOpen, false); + assert.equal(editor.state.notifications.at(-1).message, 'workflows.saved'); + assert.ok(editor.state.calls.some(call => call.url.startsWith('/api/workflows?'))); +}); + +test('Cancel clears a pending save; reopening metadata only edits the draft', async () => { + const editor = createEditor(); + await editor.context.saveWorkflowDraft(); + editor.context.closeWorkflowMetaModal(); + editor.fillMeta(); + await editor.context.applyWorkflowMetaModal(); + assert.equal(editor.state.calls.length, 0); + await editor.context.saveWorkflowDraft(); + assert.equal(editor.saves().length, 1); + const callCount = editor.state.calls.length; + editor.context.openWorkflowMetaModal(); + await editor.context.applyWorkflowMetaModal(); + assert.equal(editor.state.calls.length, callCount); +}); + +test('Opening metadata normally replaces pending save intent', async () => { + const editor = createEditor(); + await editor.context.saveWorkflowDraft(); + editor.context.openWorkflowMetaModal(); + editor.fillMeta(); + await editor.context.applyWorkflowMetaModal(); + assert.equal(editor.state.calls.length, 0); +}); + +test('Invalid metadata keeps the pending save until corrected', async () => { + const editor = createEditor(); + await editor.context.saveWorkflowDraft(); + await editor.context.applyWorkflowMetaModal(); + assert.equal(editor.state.modalOpen, true); + assert.equal(editor.state.calls.length, 0); + editor.fillMeta(); + await editor.context.applyWorkflowMetaModal(); + assert.equal(editor.saves().length, 1); +}); diff --git a/web/static/js/workflows.js b/web/static/js/workflows.js index 5afa9ed0..e4633d2c 100644 --- a/web/static/js/workflows.js +++ b/web/static/js/workflows.js @@ -15,6 +15,7 @@ let workflows = []; let currentWorkflowId = ''; + let workflowMetaSavePending = false; let cy = null; let nodeSeq = 1; let edgeSeq = 1; @@ -1993,7 +1994,8 @@ if (wf) fillWorkflowForm(wf); }; - window.openWorkflowMetaModal = function () { + window.openWorkflowMetaModal = function (options) { + workflowMetaSavePending = !!(options && options.saveOnApply); const nameEl = document.getElementById('workflow-name'); const idEl = document.getElementById('workflow-id'); if (currentWorkflowId) { @@ -2009,12 +2011,13 @@ }; window.closeWorkflowMetaModal = function () { + workflowMetaSavePending = false; if (typeof closeAppModal === 'function') { closeAppModal('workflow-meta-modal'); } }; - window.applyWorkflowMetaModal = function () { + window.applyWorkflowMetaModal = async function () { const meta = readWorkflowMetaFromForm(); if (!meta.id || !meta.name) { if (typeof showNotification === 'function') { @@ -2024,7 +2027,9 @@ } updateWorkflowCanvasTitle(); renderWorkflowList(); + const shouldSave = workflowMetaSavePending; closeWorkflowMetaModal(); + if (shouldSave) await saveWorkflowDraft(); }; window.editWorkflowFromList = function (id) { @@ -2232,7 +2237,7 @@ if (typeof showNotification === 'function') { showNotification(_t('workflows.idNameRequired'), 'error'); } - openWorkflowMetaModal(); + openWorkflowMetaModal({ saveOnApply: true }); return; } const graph = elementsToGraph();