fix(editor): Debug in Editor preserves binary data and prevents incorrect dirty marking (#18998)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Danny Martini
2025-09-01 10:19:14 +02:00
committed by GitHub
parent 280dd013ba
commit 6aeced8aed
4 changed files with 185 additions and 10 deletions

View File

@@ -48,6 +48,101 @@ describe('useExecutionDebugging()', () => {
await expect(executionDebugging.applyExecutionData('1')).resolves.not.toThrowError(); 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 () => { it('should show missing nodes warning toast', async () => {
const mockExecution = { const mockExecution = {
data: { data: {

View File

@@ -109,13 +109,18 @@ export const useExecutionDebugging = () => {
let pinnings = 0; let pinnings = 0;
pinnableNodes.forEach((node: INodeUi) => { pinnableNodes.forEach((node: INodeUi) => {
const nodeData = runData[node.name]?.[0]?.data?.main?.[0]; const taskData = runData[node.name]?.[0];
if (nodeData) { if (taskData?.data?.main) {
pinnings++; // Get the first main output that has data, preserving all execution data including binary
workflowsStore.pinData({ const nodeData = taskData.data.main.find((output) => output && output.length > 0);
node, if (nodeData) {
data: nodeData, pinnings++;
}); workflowsStore.pinData({
node,
data: nodeData,
isRestoration: true,
});
}
} }
}); });

View File

@@ -615,6 +615,67 @@ describe('useWorkflowsStore', () => {
workflowsStore.pinData({ node, data }); workflowsStore.pinData({ node, data });
expect(uiStore.stateIsDirty).toBe(true); 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', () => { describe('updateNodeExecutionData', () => {

View File

@@ -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; const nodeName = payload.node.name;
if (!workflow.value.pinData) { if (!workflow.value.pinData) {
@@ -1000,13 +1004,23 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
payload.data = [payload.data]; 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 // Updating existing pinned data
nodeMetadata.value[nodeName].pinnedDataLastUpdatedAt = Date.now(); 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) => 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; workflow.value.pinData[nodeName] = storedPinData;