From 6aeced8aed4672a5aeae6a82b96c8f9fed69fab9 Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Mon, 1 Sep 2025 10:19:14 +0200 Subject: [PATCH] fix(editor): Debug in Editor preserves binary data and prevents incorrect dirty marking (#18998) Co-authored-by: Claude --- .../composables/useExecutionDebugging.test.ts | 95 +++++++++++++++++++ .../src/composables/useExecutionDebugging.ts | 19 ++-- .../src/stores/workflows.store.test.ts | 61 ++++++++++++ .../editor-ui/src/stores/workflows.store.ts | 20 +++- 4 files changed, 185 insertions(+), 10 deletions(-) diff --git a/packages/frontend/editor-ui/src/composables/useExecutionDebugging.test.ts b/packages/frontend/editor-ui/src/composables/useExecutionDebugging.test.ts index d92af01607..23ce86239a 100644 --- a/packages/frontend/editor-ui/src/composables/useExecutionDebugging.test.ts +++ b/packages/frontend/editor-ui/src/composables/useExecutionDebugging.test.ts @@ -48,6 +48,101 @@ describe('useExecutionDebugging()', () => { await expect(executionDebugging.applyExecutionData('1')).resolves.not.toThrowError(); }); + it('should pin binary data correctly during debug restoration', async () => { + const mockExecution = { + data: { + resultData: { + runData: { + TriggerNode: [ + { + data: { + main: [ + [ + { + json: { test: 'data' }, + binary: { + data: { + fileName: 'test.txt', + mimeType: 'text/plain', + data: 'dGVzdCBkYXRh', + }, + }, + }, + ], + ], + }, + }, + ], + }, + }, + }, + } as unknown as IExecutionResponse; + + const workflowStore = mockedStore(useWorkflowsStore); + workflowStore.getNodes.mockReturnValue([{ name: 'TriggerNode' }] as INodeUi[]); + workflowStore.getExecution.mockResolvedValueOnce(mockExecution); + workflowStore.workflowObject = { + pinData: {}, + getParentNodes: vi.fn().mockReturnValue([]), + } as unknown as Workflow; + + await executionDebugging.applyExecutionData('1'); + + expect(workflowStore.pinData).toHaveBeenCalledWith({ + node: { name: 'TriggerNode' }, + data: [ + { + json: { test: 'data' }, + binary: { + data: { + fileName: 'test.txt', + mimeType: 'text/plain', + data: 'dGVzdCBkYXRh', + }, + }, + }, + ], + isRestoration: true, + }); + }); + + it('should handle nodes with multiple main outputs during debug restoration', async () => { + const mockExecution = { + data: { + resultData: { + runData: { + TriggerNode: [ + { + data: { + main: [ + [], // Empty first output + [{ json: { test: 'data' } }], // Data in second output + ], + }, + }, + ], + }, + }, + }, + } as unknown as IExecutionResponse; + + const workflowStore = mockedStore(useWorkflowsStore); + workflowStore.getNodes.mockReturnValue([{ name: 'TriggerNode' }] as INodeUi[]); + workflowStore.getExecution.mockResolvedValueOnce(mockExecution); + workflowStore.workflowObject = { + pinData: {}, + getParentNodes: vi.fn().mockReturnValue([]), + } as unknown as Workflow; + + await executionDebugging.applyExecutionData('1'); + + expect(workflowStore.pinData).toHaveBeenCalledWith({ + node: { name: 'TriggerNode' }, + data: [{ json: { test: 'data' } }], + isRestoration: true, + }); + }); + it('should show missing nodes warning toast', async () => { const mockExecution = { data: { diff --git a/packages/frontend/editor-ui/src/composables/useExecutionDebugging.ts b/packages/frontend/editor-ui/src/composables/useExecutionDebugging.ts index eefc1fe1df..cd97826174 100644 --- a/packages/frontend/editor-ui/src/composables/useExecutionDebugging.ts +++ b/packages/frontend/editor-ui/src/composables/useExecutionDebugging.ts @@ -109,13 +109,18 @@ export const useExecutionDebugging = () => { let pinnings = 0; pinnableNodes.forEach((node: INodeUi) => { - const nodeData = runData[node.name]?.[0]?.data?.main?.[0]; - if (nodeData) { - pinnings++; - workflowsStore.pinData({ - node, - data: nodeData, - }); + const taskData = runData[node.name]?.[0]; + if (taskData?.data?.main) { + // Get the first main output that has data, preserving all execution data including binary + const nodeData = taskData.data.main.find((output) => output && output.length > 0); + if (nodeData) { + pinnings++; + workflowsStore.pinData({ + node, + data: nodeData, + isRestoration: true, + }); + } } }); diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts index 36aeeb31b1..0a0a98354f 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts @@ -615,6 +615,67 @@ describe('useWorkflowsStore', () => { workflowsStore.pinData({ node, data }); expect(uiStore.stateIsDirty).toBe(true); }); + + it('should preserve binary data when pinning', async () => { + const node = { name: 'TestNode' } as INodeUi; + const data = [ + { + json: { test: 'data' }, + binary: { + data: { + fileName: 'test.txt', + mimeType: 'text/plain', + data: 'dGVzdCBkYXRh', + }, + }, + }, + ] as unknown as INodeExecutionData[]; + + workflowsStore.pinData({ node, data }); + + expect(workflowsStore.workflow.pinData?.[node.name]).toEqual([ + { + json: { test: 'data' }, + binary: { + data: { + fileName: 'test.txt', + mimeType: 'text/plain', + data: 'dGVzdCBkYXRh', + }, + }, + }, + ]); + }); + + it('should not update timestamp during restoration', async () => { + const node = { name: 'TestNode' } as INodeUi; + const data = [{ json: 'testData' }] as unknown as INodeExecutionData[]; + + // Set up existing pinned data with metadata + workflowsStore.workflow.pinData = { [node.name]: data }; + workflowsStore.nodeMetadata[node.name] = { pristine: false, pinnedDataLastUpdatedAt: 1000 }; + + workflowsStore.pinData({ node, data, isRestoration: true }); + + expect(workflowsStore.nodeMetadata[node.name].pinnedDataLastUpdatedAt).toBeUndefined(); + }); + + it('should clear timestamps during restoration', async () => { + const node = { name: 'TestNode' } as INodeUi; + const data = [{ json: 'testData' }] as unknown as INodeExecutionData[]; + + // Set up existing metadata with timestamps + workflowsStore.nodeMetadata[node.name] = { + pristine: false, + pinnedDataLastUpdatedAt: 1000, + pinnedDataLastRemovedAt: 2000, + }; + + workflowsStore.pinData({ node, data, isRestoration: true }); + + expect(workflowsStore.nodeMetadata[node.name].pinnedDataLastUpdatedAt).toBeUndefined(); + expect(workflowsStore.nodeMetadata[node.name].pinnedDataLastRemovedAt).toBeUndefined(); + }); }); describe('updateNodeExecutionData', () => { diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.ts b/packages/frontend/editor-ui/src/stores/workflows.store.ts index 064c2e4fb9..5f0483bbab 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.ts @@ -989,7 +989,11 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { ); } - function pinData(payload: { node: INodeUi; data: INodeExecutionData[] }): void { + function pinData(payload: { + node: INodeUi; + data: INodeExecutionData[]; + isRestoration?: boolean; + }): void { const nodeName = payload.node.name; if (!workflow.value.pinData) { @@ -1000,13 +1004,23 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { payload.data = [payload.data]; } - if ((workflow.value.pinData?.[nodeName] ?? []).length > 0 && nodeMetadata.value[nodeName]) { + if ( + (workflow.value.pinData?.[nodeName] ?? []).length > 0 && + nodeMetadata.value[nodeName] && + !payload.isRestoration + ) { // Updating existing pinned data nodeMetadata.value[nodeName].pinnedDataLastUpdatedAt = Date.now(); + } else if (payload.isRestoration && nodeMetadata.value[nodeName]) { + // Clear timestamps during restoration to prevent incorrect dirty marking + delete nodeMetadata.value[nodeName].pinnedDataLastUpdatedAt; + delete nodeMetadata.value[nodeName].pinnedDataLastRemovedAt; } const storedPinData = payload.data.map((item) => - isJsonKeyObject(item) ? { json: item.json } : { json: item }, + isJsonKeyObject(item) + ? { json: item.json, ...(item.binary && { binary: item.binary }) } + : { json: item }, ); workflow.value.pinData[nodeName] = storedPinData;