fix(editor): Update node execution itemCount to support multiple outputs (no-changelog) (#19646)

This commit is contained in:
Alex Grozav
2025-09-18 12:11:04 +01:00
committed by GitHub
parent dee22162f4
commit 83b2a5772e
13 changed files with 435 additions and 27 deletions

View File

@@ -0,0 +1,190 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { nodeExecuteAfter } from './nodeExecuteAfter';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useAssistantStore } from '@/stores/assistant.store';
import { mockedStore } from '@/__tests__/utils';
import type { NodeExecuteAfter } from '@n8n/api-types/push/execution';
import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow';
describe('nodeExecuteAfter', () => {
beforeEach(() => {
const pinia = createTestingPinia({
stubActions: true,
});
setActivePinia(pinia);
});
it('should update node execution data with placeholder and remove executing node', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const assistantStore = mockedStore(useAssistantStore);
const event: NodeExecuteAfter = {
type: 'nodeExecuteAfter',
data: {
executionId: 'exec-1',
nodeName: 'Test Node',
itemCountByConnectionType: { main: [2, 1] },
data: {
executionTime: 100,
startTime: 1234567890,
executionIndex: 0,
source: [],
},
},
};
await nodeExecuteAfter(event);
expect(workflowsStore.updateNodeExecutionData).toHaveBeenCalledTimes(1);
expect(workflowsStore.removeExecutingNode).toHaveBeenCalledTimes(1);
expect(workflowsStore.removeExecutingNode).toHaveBeenCalledWith('Test Node');
expect(assistantStore.onNodeExecution).toHaveBeenCalledTimes(1);
expect(assistantStore.onNodeExecution).toHaveBeenCalledWith(event.data);
// Verify the placeholder data structure
const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0];
expect(updateCall.data.data).toEqual({
main: [
Array.from({ length: 2 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }),
Array.from({ length: 1 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }),
],
});
});
it('should handle multiple connection types', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const event: NodeExecuteAfter = {
type: 'nodeExecuteAfter',
data: {
executionId: 'exec-1',
nodeName: 'Test Node',
itemCountByConnectionType: {
main: [3],
ai_memory: [1, 2],
ai_tool: [1],
},
data: {
executionTime: 100,
startTime: 1234567890,
executionIndex: 0,
source: [],
},
},
};
await nodeExecuteAfter(event);
const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0];
expect(updateCall.data.data).toEqual({
main: [
Array.from({ length: 3 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }),
],
ai_memory: [
Array.from({ length: 1 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }),
Array.from({ length: 2 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }),
],
ai_tool: [
Array.from({ length: 1 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }),
],
});
});
it('should handle empty itemCountByConnectionType', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const event: NodeExecuteAfter = {
type: 'nodeExecuteAfter',
data: {
executionId: 'exec-1',
nodeName: 'Test Node',
itemCountByConnectionType: {},
data: {
executionTime: 100,
startTime: 1234567890,
executionIndex: 0,
source: [],
},
},
};
await nodeExecuteAfter(event);
const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0];
expect(updateCall.data.data).toEqual({
main: [],
});
});
it('should preserve original data structure except for data property', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const event: NodeExecuteAfter = {
type: 'nodeExecuteAfter',
data: {
executionId: 'exec-1',
nodeName: 'Test Node',
itemCountByConnectionType: { main: [1] },
data: {
executionTime: 100,
startTime: 1234567890,
executionIndex: 0,
source: [null],
},
},
};
await nodeExecuteAfter(event);
const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0];
expect(updateCall.executionId).toBe('exec-1');
expect(updateCall.nodeName).toBe('Test Node');
expect(updateCall.data.executionTime).toBe(100);
expect(updateCall.data.startTime).toBe(1234567890);
expect(updateCall.data.executionIndex).toBe(0);
expect(updateCall.data.source).toEqual([null]);
// Only the data property should be replaced with placeholder
expect(updateCall.data.data).toEqual({
main: [
Array.from({ length: 1 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }),
],
});
});
it('should filter out invalid connection types', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const event: NodeExecuteAfter = {
type: 'nodeExecuteAfter',
data: {
executionId: 'exec-1',
nodeName: 'Test Node',
itemCountByConnectionType: {
main: [1],
// @ts-expect-error Testing invalid connection type
invalid_connection: [2], // This should be filtered out by isValidNodeConnectionType
},
data: {
executionTime: 100,
startTime: 1234567890,
executionIndex: 0,
source: [],
},
},
};
await nodeExecuteAfter(event);
const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0];
// Should only contain main connection, invalid_connection should be filtered out
expect(updateCall.data.data).toEqual({
main: [
Array.from({ length: 1 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }),
],
});
expect(updateCall.data.data?.invalid_connection).toBeUndefined();
});
});

View File

@@ -1,9 +1,10 @@
import type { NodeExecuteAfter } from '@n8n/api-types/push/execution';
import { useAssistantStore } from '@/stores/assistant.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ITaskData } from 'n8n-workflow';
import type { INodeExecutionData, ITaskData } from 'n8n-workflow';
import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow';
import type { PushPayload } from '@n8n/api-types';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
/**
* Handles the 'nodeExecuteAfter' event, which happens after a node is executed.
@@ -18,15 +19,23 @@ export async function nodeExecuteAfter({ data: pushData }: NodeExecuteAfter) {
* a placeholder object indicating that the data has been trimmed until the
* `nodeExecuteAfterData` event comes in.
*/
const placeholderOutputData: ITaskData['data'] = {
main: [],
};
if (typeof pushData.itemCount === 'number') {
const fillObject = { json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } };
const fillArray = new Array(pushData.itemCount).fill(fillObject);
placeholderOutputData.main = [fillArray];
if (
pushData.itemCountByConnectionType &&
typeof pushData.itemCountByConnectionType === 'object'
) {
const fillObject: INodeExecutionData = { json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } };
for (const [connectionType, outputs] of Object.entries(pushData.itemCountByConnectionType)) {
if (isValidNodeConnectionType(connectionType)) {
placeholderOutputData[connectionType] = outputs.map((count) =>
Array.from({ length: count }, () => fillObject),
);
}
}
}
const pushDataWithPlaceholderOutputData: PushPayload<'nodeExecuteAfterData'> = {

View File

@@ -21,7 +21,7 @@ describe('nodeExecuteAfterData', () => {
data: {
executionId: 'exec-1',
nodeName: 'Test Node',
itemCount: 1,
itemCountByConnectionType: { main: [1] },
data: {
executionTime: 0,
startTime: 0,

View File

@@ -330,7 +330,7 @@ describe('LogsPanel', () => {
workflowsStore.updateNodeExecutionData({
nodeName: 'AI Agent',
executionId: '567',
itemCount: 1,
itemCountByConnectionType: { ai_agent: [1] },
data: {
executionIndex: 0,
startTime: Date.parse('2025-04-20T12:34:51.000Z'),

View File

@@ -1409,7 +1409,7 @@ function generateMockExecutionEvents() {
const successEvent: PushPayload<'nodeExecuteAfter'> = {
executionId: '59',
nodeName: 'When clicking Execute workflow',
itemCount: 1,
itemCountByConnectionType: { main: [1] },
data: {
hints: [],
startTime: 1727867966633,