fix(editor): Reflect Wait node's execution status correctly in log view (#19898)

This commit is contained in:
Suguru Inoue 2025-09-24 16:38:08 +02:00 committed by GitHub
parent 7f2aaa7a7a
commit a6793593b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 237 additions and 27 deletions

View File

@ -65,7 +65,7 @@ export type NodeExecuteAfter = {
/**
* The data field for task data in `NodeExecuteAfter` is always trimmed (undefined).
*/
data: ITaskData;
data: Omit<ITaskData, 'data'>;
/**
* The number of items per output connection type. This is needed so that the frontend
* can know how many items to expect when receiving the `NodeExecuteAfterData` message.

View File

@ -37,14 +37,14 @@ describe('nodeExecuteAfter', () => {
await nodeExecuteAfter(event);
expect(workflowsStore.updateNodeExecutionData).toHaveBeenCalledTimes(1);
expect(workflowsStore.updateNodeExecutionStatus).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];
const updateCall = workflowsStore.updateNodeExecutionStatus.mock.calls[0][0];
expect(updateCall.data.data).toEqual({
main: [
Array.from({ length: 2 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }),
@ -77,7 +77,7 @@ describe('nodeExecuteAfter', () => {
await nodeExecuteAfter(event);
const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0];
const updateCall = workflowsStore.updateNodeExecutionStatus.mock.calls[0][0];
expect(updateCall.data.data).toEqual({
main: [
Array.from({ length: 3 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }),
@ -112,7 +112,7 @@ describe('nodeExecuteAfter', () => {
await nodeExecuteAfter(event);
const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0];
const updateCall = workflowsStore.updateNodeExecutionStatus.mock.calls[0][0];
expect(updateCall.data.data).toEqual({
main: [],
});
@ -138,7 +138,7 @@ describe('nodeExecuteAfter', () => {
await nodeExecuteAfter(event);
const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0];
const updateCall = workflowsStore.updateNodeExecutionStatus.mock.calls[0][0];
expect(updateCall.executionId).toBe('exec-1');
expect(updateCall.nodeName).toBe('Test Node');
expect(updateCall.data.executionTime).toBe(100);
@ -178,7 +178,7 @@ describe('nodeExecuteAfter', () => {
await nodeExecuteAfter(event);
const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0];
const updateCall = workflowsStore.updateNodeExecutionStatus.mock.calls[0][0];
// Should only contain main connection, invalid_connection should be filtered out
expect(updateCall.data.data).toEqual({
main: [

View File

@ -46,7 +46,7 @@ export async function nodeExecuteAfter({ data: pushData }: NodeExecuteAfter) {
},
};
workflowsStore.updateNodeExecutionData(pushDataWithPlaceholderOutputData);
workflowsStore.updateNodeExecutionStatus(pushDataWithPlaceholderOutputData);
workflowsStore.removeExecutingNode(pushData.nodeName);
void assistantStore.onNodeExecution(pushData);

View File

@ -36,7 +36,7 @@ describe('nodeExecuteAfterData', () => {
await nodeExecuteAfterData(event);
expect(workflowsStore.updateNodeExecutionData).toHaveBeenCalledTimes(1);
expect(workflowsStore.updateNodeExecutionData).toHaveBeenCalledWith(event.data);
expect(workflowsStore.updateNodeExecutionRunData).toHaveBeenCalledTimes(1);
expect(workflowsStore.updateNodeExecutionRunData).toHaveBeenCalledWith(event.data);
});
});

View File

@ -9,7 +9,7 @@ export async function nodeExecuteAfterData({ data: pushData }: NodeExecuteAfterD
const workflowsStore = useWorkflowsStore();
const schemaPreviewStore = useSchemaPreviewStore();
workflowsStore.updateNodeExecutionData(pushData);
workflowsStore.updateNodeExecutionRunData(pushData);
void schemaPreviewStore.trackSchemaPreviewExecution(pushData);
}

View File

@ -1,6 +1,7 @@
import type {
IExecutionPushResponse,
IExecutionResponse,
IExecutionsStopData,
IStartRunData,
IWorkflowDb,
} from '@/Interface';
@ -476,12 +477,14 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
async function stopCurrentExecution() {
const executionId = workflowsStore.activeExecutionId;
let stopData: IExecutionsStopData | undefined;
if (!executionId) {
return;
}
try {
await executionsStore.stopCurrentExecution(executionId);
stopData = await executionsStore.stopCurrentExecution(executionId);
} catch (error) {
// Execution stop might fail when the execution has already finished. Let's treat this here.
const execution = await workflowsStore.getExecution(executionId);
@ -518,7 +521,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
async () => {
const execution = await workflowsStore.getExecution(executionId);
if (!['running', 'waiting'].includes(execution?.status as string)) {
workflowsStore.markExecutionAsStopped();
workflowsStore.markExecutionAsStopped(stopData);
return true;
}
@ -529,7 +532,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
);
if (!markedAsStopped) {
workflowsStore.markExecutionAsStopped();
workflowsStore.markExecutionAsStopped(stopData);
}
}
}

View File

@ -327,7 +327,7 @@ describe('LogsPanel', () => {
expect(lastTreeItem.getByText('AI Agent')).toBeInTheDocument();
expect(lastTreeItem.getByText(/Running/)).toBeInTheDocument();
workflowsStore.updateNodeExecutionData({
workflowsStore.updateNodeExecutionStatus({
nodeName: 'AI Agent',
executionId: '567',
itemCountByConnectionType: { ai_agent: [1] },

View File

@ -31,7 +31,13 @@ import * as apiUtils from '@n8n/rest-api-client';
import { useSettingsStore } from '@/stores/settings.store';
import { useLocalStorage } from '@vueuse/core';
import { ref } from 'vue';
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
import {
createTestNode,
createTestTaskData,
createTestWorkflow,
createTestWorkflowExecutionResponse,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { waitFor } from '@testing-library/vue';
vi.mock('@/stores/ndv.store', () => ({
@ -657,7 +663,90 @@ describe('useWorkflowsStore', () => {
});
});
describe('updateNodeExecutionData', () => {
describe('updateNodeExecutionRunData', () => {
beforeEach(() => {
workflowsStore.workflowExecutionData = createTestWorkflowExecutionResponse({
id: 'test-execution',
data: {
resultData: {
runData: {
n0: [
createTestTaskData({
executionIndex: 0,
executionStatus: 'success',
executionTime: 33,
}),
createTestTaskData({
executionIndex: 1,
executionStatus: 'success',
executionTime: 44,
}),
createTestTaskData({
executionIndex: 2,
executionStatus: 'running',
executionTime: undefined,
}),
],
},
},
},
});
});
it('should replace run data at the matched index in the execution data', () => {
workflowsStore.updateNodeExecutionRunData({
executionId: 'test-execution',
nodeName: 'n0',
data: createTestTaskData({
executionIndex: 2,
executionStatus: 'success',
executionTime: 100,
}),
itemCountByConnectionType: { main: [1] },
});
const runData = workflowsStore.workflowExecutionData?.data?.resultData?.runData.n0;
expect(runData).toHaveLength(3);
expect(runData?.[0].executionIndex).toBe(0);
expect(runData?.[0].executionStatus).toBe('success');
expect(runData?.[0].executionTime).toBe(33);
expect(runData?.[1].executionIndex).toBe(1);
expect(runData?.[1].executionStatus).toBe('success');
expect(runData?.[1].executionTime).toBe(44);
expect(runData?.[2].executionIndex).toBe(2);
expect(runData?.[2].executionStatus).toBe('success');
expect(runData?.[2].executionTime).toBe(100);
});
it('should not modify execution data if there is no matched index in execution data', () => {
workflowsStore.updateNodeExecutionRunData({
executionId: 'test-execution',
nodeName: 'n0',
data: createTestTaskData({
executionIndex: 3,
executionStatus: 'success',
executionTime: 100,
}),
itemCountByConnectionType: { main: [1] },
});
const runData = workflowsStore.workflowExecutionData?.data?.resultData?.runData.n0;
expect(runData).toHaveLength(3);
expect(runData?.[0].executionIndex).toBe(0);
expect(runData?.[0].executionStatus).toBe('success');
expect(runData?.[0].executionTime).toBe(33);
expect(runData?.[1].executionIndex).toBe(1);
expect(runData?.[1].executionStatus).toBe('success');
expect(runData?.[1].executionTime).toBe(44);
expect(runData?.[2].executionIndex).toBe(2);
expect(runData?.[2].executionStatus).toBe('running');
expect(runData?.[2].executionTime).toBe(undefined);
});
});
describe('updateNodeExecutionStatus', () => {
let successEvent: ReturnType<typeof generateMockExecutionEvents>['successEvent'];
let errorEvent: ReturnType<typeof generateMockExecutionEvents>['errorEvent'];
let executionResponse: ReturnType<typeof generateMockExecutionEvents>['executionResponse'];
@ -670,7 +759,7 @@ describe('useWorkflowsStore', () => {
});
it('should throw error if not initialized', () => {
expect(() => workflowsStore.updateNodeExecutionData(successEvent)).toThrowError();
expect(() => workflowsStore.updateNodeExecutionStatus(successEvent)).toThrowError();
});
it('should add node success run data', () => {
@ -681,7 +770,7 @@ describe('useWorkflowsStore', () => {
});
// ACT
workflowsStore.updateNodeExecutionData(successEvent);
workflowsStore.updateNodeExecutionStatus(successEvent);
expect(workflowsStore.workflowExecutionData).toEqual({
...executionResponse,
@ -710,7 +799,7 @@ describe('useWorkflowsStore', () => {
getNodeType.mockReturnValue(getMockEditFieldsNode());
// ACT
workflowsStore.updateNodeExecutionData(errorEvent);
workflowsStore.updateNodeExecutionStatus(errorEvent);
await flushPromises();
expect(workflowsStore.workflowExecutionData).toEqual({
@ -793,7 +882,7 @@ describe('useWorkflowsStore', () => {
});
// ACT
workflowsStore.updateNodeExecutionData(successEvent);
workflowsStore.updateNodeExecutionStatus(successEvent);
expect(workflowsStore.workflowExecutionData).toEqual({
...runWithExistingRunData,
@ -806,6 +895,7 @@ describe('useWorkflowsStore', () => {
},
});
});
it('should replace existing placeholder task data in new log view', () => {
const successEventWithExecutionIndex = deepCopy(successEvent);
successEventWithExecutionIndex.data.executionIndex = 1;
@ -846,7 +936,7 @@ describe('useWorkflowsStore', () => {
});
// ACT
workflowsStore.updateNodeExecutionData(successEventWithExecutionIndex);
workflowsStore.updateNodeExecutionStatus(successEventWithExecutionIndex);
expect(workflowsStore.workflowExecutionData).toEqual({
...executionResponse,
@ -1497,6 +1587,68 @@ describe('useWorkflowsStore', () => {
await waitFor(() => expect(workflowsStore.selectedTriggerNodeName).toBe(undefined));
});
});
describe('markExecutionAsStopped', () => {
beforeEach(() => {
workflowsStore.workflowExecutionData = createTestWorkflowExecutionResponse({
status: 'running',
startedAt: new Date('2023-01-01T09:00:00Z'),
stoppedAt: undefined,
data: {
resultData: {
runData: {
node1: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'error' }),
createTestTaskData({ executionStatus: 'running' }),
],
node2: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'waiting' }),
],
},
},
},
});
});
it('should remove non successful node runs', () => {
workflowsStore.markExecutionAsStopped();
const runData = workflowsStore.workflowExecutionData?.data?.resultData?.runData;
expect(runData?.node1).toHaveLength(1);
expect(runData?.node1[0].executionStatus).toBe('success');
expect(runData?.node2).toHaveLength(1);
expect(runData?.node2[0].executionStatus).toBe('success');
});
it('should update execution status, startedAt and stoppedAt when data is provided', () => {
workflowsStore.markExecutionAsStopped({
status: 'canceled',
startedAt: new Date('2023-01-01T10:00:00Z'),
stoppedAt: new Date('2023-01-01T10:05:00Z'),
mode: 'manual',
});
expect(workflowsStore.workflowExecutionData?.status).toBe('canceled');
expect(workflowsStore.workflowExecutionData?.startedAt).toEqual(
new Date('2023-01-01T10:00:00Z'),
);
expect(workflowsStore.workflowExecutionData?.stoppedAt).toEqual(
new Date('2023-01-01T10:05:00Z'),
);
});
it('should not update execution data when stopData is not provided', () => {
workflowsStore.markExecutionAsStopped();
expect(workflowsStore.workflowExecutionData?.status).toBe('running');
expect(workflowsStore.workflowExecutionData?.startedAt).toEqual(
new Date('2023-01-01T09:00:00Z'),
);
expect(workflowsStore.workflowExecutionData?.stoppedAt).toBeUndefined();
});
});
});
function getMockEditFieldsNode(): Partial<INodeTypeDescription> {

View File

@ -28,6 +28,7 @@ import type {
NodeMetadataMap,
IExecutionFlattedResponse,
WorkflowListResource,
IExecutionsStopData,
} from '@/Interface';
import type { IWorkflowTemplateNode } from '@n8n/rest-api-client/api/templates';
import type {
@ -1556,7 +1557,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
];
}
function updateNodeExecutionData(pushData: PushPayload<'nodeExecuteAfterData'>): void {
function updateNodeExecutionStatus(pushData: PushPayload<'nodeExecuteAfterData'>): void {
if (!workflowExecutionData.value?.data) {
throw new Error('The "workflowExecutionData" is not initialized!');
}
@ -1583,7 +1584,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
openFormPopupWindow(testUrl);
}
} else {
// If we process items in paralell on subnodes we get several placeholder taskData items.
// If we process items in parallel on subnodes we get several placeholder taskData items.
// We need to find and replace the item with the matching executionIndex and only append if we don't find anything matching.
const existingRunIndex = tasksData.findIndex(
(item) => item.executionIndex === data.executionIndex,
@ -1607,6 +1608,17 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
}
}
function updateNodeExecutionRunData(pushData: PushPayload<'nodeExecuteAfterData'>): void {
const tasksData = workflowExecutionData.value?.data?.resultData.runData[pushData.nodeName];
const existingRunIndex =
tasksData?.findIndex((item) => item.executionIndex === pushData.data.executionIndex) ?? -1;
if (tasksData?.[existingRunIndex]) {
tasksData.splice(existingRunIndex, 1, pushData.data);
workflowExecutionResultDataLastUpdate.value = Date.now();
}
}
function clearNodeExecutionData(nodeName: string): void {
if (!workflowExecutionData.value?.data) {
return;
@ -1897,20 +1909,32 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// End Canvas V2 Functions
//
function markExecutionAsStopped() {
function markExecutionAsStopped(stopData?: IExecutionsStopData) {
setActiveExecutionId(undefined);
clearNodeExecutionQueue();
executionWaitingForWebhook.value = false;
workflowHelpers.setDocumentTitle(workflowName.value, 'IDLE');
workflowExecutionStartedData.value = undefined;
clearPopupWindowState();
const runData = workflowExecutionData.value?.data?.resultData.runData ?? {};
if (!workflowExecutionData.value) {
return;
}
const runData = workflowExecutionData.value.data?.resultData.runData ?? {};
for (const nodeName in runData) {
runData[nodeName] = runData[nodeName].filter(
({ executionStatus }) => executionStatus === 'success',
);
}
if (stopData) {
workflowExecutionData.value.status = stopData.status;
workflowExecutionData.value.startedAt = stopData.startedAt;
workflowExecutionData.value.stoppedAt = stopData.stoppedAt;
}
}
function setSelectedTriggerNodeName(value: string) {
@ -2077,7 +2101,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
setNodeValue,
setNodeParameters,
setLastNodeParameters,
updateNodeExecutionData,
updateNodeExecutionRunData,
updateNodeExecutionStatus,
clearNodeExecutionData,
pinDataByNodeName,
activeNode,

View File

@ -446,6 +446,10 @@ export class CanvasPage extends BasePage {
return this.page.getByTestId('toggle-focus-panel-button');
}
stopExecutionButton(): Locator {
return this.page.getByTestId('stop-execution-button');
}
// Actions
async addInitialNodeToCanvas(nodeName: string): Promise<void> {

View File

@ -250,6 +250,9 @@ test.describe('Logs', () => {
.getAttribute('href');
await n8n.ndv.clickBackToCanvasButton();
// [CAT-1454] Assert that no duplicate logs added at this point
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(2);
// Trigger the webhook
const response = await n8n.page.request.get(webhookUrl!);
expect(response.status()).toBe(200);
@ -263,4 +266,27 @@ test.describe('Logs', () => {
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText(NODES.WAIT_NODE);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText('Success');
});
test('should allow to cancel a workflow with a node that waits for webhook', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Workflow_wait_for_webhook.json');
await n8n.canvas.deselectAll();
await n8n.canvas.logsPanel.open();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getWaitingNodes()).toContainText(NODES.WAIT_NODE);
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(2);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(0)).toContainText(
'When clicking Test workflow',
);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText(NODES.WAIT_NODE);
await n8n.canvas.stopExecutionButton().click();
await expect(n8n.canvas.stopExecutionButton()).toBeHidden();
await expect(n8n.canvas.logsPanel.getOverviewStatus()).toContainText('Canceled in');
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(1);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(0)).toContainText(
'When clicking Test workflow',
);
});
});