fix: Show correct "canceled" node status for chat model nodes (#19366)

This commit is contained in:
Mutasem Aldmour
2025-09-12 10:21:35 +02:00
committed by GitHub
parent 6f4dcf1f58
commit b6abd1ef69
12 changed files with 943 additions and 11 deletions

View File

@@ -44,6 +44,7 @@ import {
NodeOperationError,
Workflow,
} from 'n8n-workflow';
import assert from 'node:assert';
import * as Helpers from '@test/helpers';
import { legacyWorkflowExecuteTests, v1WorkflowExecuteTests } from '@test/helpers/constants';
@@ -2485,4 +2486,87 @@ describe('WorkflowExecute', () => {
]);
});
});
describe('Cancellation', () => {
test('should update only running task statuses to cancelled when workflow is cancelled', () => {
// Arrange - create a workflow with some nodes
const startNode = createNodeData({ name: 'Start' });
const processingNode = createNodeData({ name: 'Processing' });
const completedNode = createNodeData({ name: 'Completed' });
const workflow = new Workflow({
id: 'test-workflow',
nodes: [startNode, processingNode, completedNode],
connections: {},
active: false,
nodeTypes,
});
const waitPromise = createDeferredPromise<IRun>();
const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise);
const workflowExecute = new WorkflowExecute(additionalData, 'manual');
// Create run execution data with tasks in various statuses
const runExecutionData: IRunExecutionData = {
startData: { startNodes: [{ name: 'Start', sourceData: null }] },
resultData: {
runData: {
Start: [toITaskData([{ data: { test: 'data' } }], { executionStatus: 'success' })],
Processing: [toITaskData([{ data: { test: 'data' } }], { executionStatus: 'running' })],
Completed: [
toITaskData([{ data: { test: 'data1' } }], { executionStatus: 'error' }),
toITaskData([{ data: { test: 'data2' } }], { executionStatus: 'running' }),
toITaskData([{ data: { test: 'data3' } }], { executionStatus: 'waiting' }),
],
},
lastNodeExecuted: 'Processing',
},
executionData: mock<IRunExecutionData['executionData']>({
nodeExecutionStack: [],
metadata: {},
}),
};
// Set the run execution data on the workflow execute instance
// @ts-expect-error private data
workflowExecute.runExecutionData = runExecutionData;
assert(additionalData.hooks);
const runHook = jest.fn();
additionalData.hooks.runHook = runHook;
const promise = workflowExecute.processRunExecutionData(workflow);
promise.cancel('reason');
const updatedExecutionData = {
data: {
startData: { startNodes: [{ name: 'Start', sourceData: null }] },
resultData: {
runData: {
Start: [toITaskData([{ data: { test: 'data' } }], { executionStatus: 'success' })],
Processing: [
toITaskData([{ data: { test: 'data' } }], { executionStatus: 'canceled' }),
],
Completed: [
toITaskData([{ data: { test: 'data1' } }], { executionStatus: 'error' }),
toITaskData([{ data: { test: 'data2' } }], { executionStatus: 'canceled' }),
toITaskData([{ data: { test: 'data3' } }], { executionStatus: 'waiting' }),
],
},
lastNodeExecuted: 'Processing',
},
executionData: mock<IRunExecutionData['executionData']>({
nodeExecutionStack: [],
metadata: {},
}),
},
};
expect(runHook.mock.lastCall[0]).toEqual('workflowExecuteAfter');
expect(JSON.stringify(runHook.mock.lastCall[1][0].data)).toBe(
JSON.stringify(updatedExecutionData.data),
);
expect(runHook.mock.lastCall[1][0].status).toEqual('canceled');
});
});
});

View File

@@ -16,7 +16,7 @@ import type {
NodeConnectionType,
IRunData,
} from 'n8n-workflow';
import { ApplicationError, NodeConnectionTypes } from 'n8n-workflow';
import { ApplicationError, ExecutionCancelledError, NodeConnectionTypes } from 'n8n-workflow';
import { describeCommonTests } from './shared-tests';
import { SupplyDataContext } from '../supply-data-context';
@@ -219,4 +219,70 @@ describe('SupplyDataContext', () => {
expect(latestRunIndex).toBe(2);
});
});
describe('addExecutionDataFunctions', () => {
it('should preserve canceled status when execution is aborted and output has error', async () => {
const errorData = new ExecutionCancelledError('Execution was aborted');
const abortedSignal = mock<AbortSignal>({ aborted: true });
const mockHooks = {
runHook: jest.fn().mockResolvedValue(undefined),
};
const testAdditionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper,
hooks: mockHooks,
currentNodeExecutionIndex: 0,
});
const testRunExecutionData = mock<IRunExecutionData>({
resultData: {
runData: {
[node.name]: [
{
executionStatus: 'canceled',
startTime: Date.now(),
executionTime: 0,
executionIndex: 0,
error: undefined,
},
],
},
error: undefined,
},
executionData: { metadata: {} },
});
const contextWithAbort = new SupplyDataContext(
workflow,
node,
testAdditionalData,
mode,
testRunExecutionData,
runIndex,
connectionInputData,
inputData,
'ai_agent',
executeData,
[closeFn],
abortedSignal,
);
await contextWithAbort.addExecutionDataFunctions(
'output',
errorData,
'ai_agent',
node.name,
0,
);
const taskData = testRunExecutionData.resultData.runData[node.name][0];
expect(taskData.executionStatus).toBe('canceled');
expect(taskData.error).toBeUndefined();
// Verify nodeExecuteAfter hook was called correctly
expect(mockHooks.runHook).toHaveBeenCalledWith('nodeExecuteAfter', [
node.name,
taskData,
testRunExecutionData,
]);
});
});
});

View File

@@ -280,8 +280,14 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
taskData = taskData!;
if (data instanceof Error) {
taskData.executionStatus = 'error';
taskData.error = data;
// if running node was already marked as "canceled" because execution was aborted
// leave as "canceled" instead of showing "This operation was aborted" error
if (
!(type === 'output' && this.abortSignal?.aborted && taskData.executionStatus === 'canceled')
) {
taskData.executionStatus = 'error';
taskData.error = data;
}
} else {
if (type === 'output') {
taskData.executionStatus = 'success';

View File

@@ -1584,6 +1584,7 @@ export class WorkflowExecute {
onCancel.shouldReject = false;
onCancel(() => {
this.status = 'canceled';
this.updateTaskStatusesToCancelled();
this.abortController.abort();
const fullRunData = this.getFullRunData(startedAt);
void hooks.runHook('workflowExecuteAfter', [fullRunData]);
@@ -2654,6 +2655,17 @@ export class WorkflowExecute {
return nodeSuccessData;
}
private updateTaskStatusesToCancelled(): void {
Object.keys(this.runExecutionData.resultData.runData).forEach((nodeName) => {
const taskDataArray = this.runExecutionData.resultData.runData[nodeName];
taskDataArray.forEach((taskData) => {
if (taskData.executionStatus === 'running') {
taskData.executionStatus = 'canceled';
}
});
});
}
private get isCancelled() {
return this.abortController.signal.aborted;
}

View File

@@ -1883,6 +1883,7 @@
"runData.editValue": "Edit Value",
"runData.executionStatus.success": "Executed successfully",
"runData.executionStatus.failed": "Execution failed",
"runData.executionStatus.canceled": "Execution canceled",
"runData.downloadBinaryData": "Download",
"runData.executeNode": "Test Node",
"runData.executionTime": "Execution Time",

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, vi } from 'vitest';
import type { ITaskData } from 'n8n-workflow';
import RunInfo from './RunInfo.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mock } from 'vitest-mock-extended';
vi.mock('@/utils/formatters/dateFormatter', () => ({
convertToDisplayDateComponents: vi.fn(() => ({
date: 'Jan 15',
time: '10:30:00',
})),
}));
vi.mock('@n8n/i18n', async (importOriginal) => {
return {
...(await importOriginal()),
useI18n: () => ({
baseText: vi.fn((key: string) => {
const translations: Record<string, string> = {
'runData.executionStatus.success': 'Success',
'runData.executionStatus.canceled': 'Canceled',
'runData.executionStatus.failed': 'Failed',
'runData.startTime': 'Start time',
'runData.executionTime': 'Execution time',
'runData.ms': 'ms',
};
return translations[key] || key;
}),
}),
};
});
const renderComponent = createComponentRenderer(RunInfo);
describe('RunInfo', () => {
it('should display success status when execution status is success', () => {
const successTaskData: ITaskData = mock<ITaskData>({
startTime: Date.now(),
executionTime: 1500,
executionStatus: 'success',
data: {},
error: undefined,
});
const { getByTestId, container } = renderComponent({
props: {
taskData: successTaskData,
hasStaleData: false,
hasPinData: false,
},
});
expect(getByTestId('node-run-status-success')).toBeInTheDocument();
expect(getByTestId('node-run-info')).toBeInTheDocument();
const tooltipDiv = container.querySelector('.tooltipRow');
expect(tooltipDiv).toBeInTheDocument();
const statusIcon = getByTestId('node-run-status-success');
expect(statusIcon).toHaveClass('success');
const infoIcon = getByTestId('node-run-info');
expect(infoIcon).toBeInTheDocument();
// Verify the component renders with success status
expect(statusIcon).toHaveAttribute('data-test-id', 'node-run-status-success');
// Check tooltip content exists in the DOM (even if hidden)
expect(document.body).toHaveTextContent('Success');
expect(document.body).toHaveTextContent('Start time:');
expect(document.body).toHaveTextContent('Jan 15 at 10:30:00');
expect(document.body).toHaveTextContent('Execution time:');
expect(document.body).toHaveTextContent('1500 ms');
});
it('should display cancelled status when execution status is canceled', () => {
const cancelledTaskData: ITaskData = mock<ITaskData>({
startTime: 1757506978099,
executionTime: 800,
executionStatus: 'canceled',
data: {},
error: undefined,
});
const { getByTestId, container, queryByTestId } = renderComponent({
props: {
taskData: cancelledTaskData,
hasStaleData: false,
hasPinData: false,
},
});
expect(queryByTestId('node-run-status-success')).not.toBeInTheDocument();
expect(getByTestId('node-run-info')).toBeInTheDocument();
const tooltipDiv = container.querySelector('.tooltipRow');
expect(tooltipDiv).toBeInTheDocument();
const infoIcon = getByTestId('node-run-info');
expect(infoIcon).toBeInTheDocument();
// For cancelled status, only info tooltip is shown (no status icon)
expect(infoIcon).toHaveAttribute('data-test-id', 'node-run-info');
// Check tooltip content exists in the DOM (even if hidden)
expect(document.body).toHaveTextContent('Canceled');
expect(document.body).toHaveTextContent('Start time:');
expect(document.body).toHaveTextContent('Jan 15 at 10:30:00');
expect(document.body).toHaveTextContent('Execution time:');
expect(document.body).toHaveTextContent('800 ms');
});
it('should display error status when there is an error', () => {
const errorTaskData: ITaskData = mock<ITaskData>({
startTime: 1757506978099,
executionTime: 1200,
executionStatus: 'success',
data: {},
error: {
message: 'Something went wrong',
name: 'Error',
},
});
const { getByTestId, container } = renderComponent({
props: {
taskData: errorTaskData,
hasStaleData: false,
hasPinData: false,
},
});
expect(getByTestId('node-run-status-danger')).toBeInTheDocument();
expect(getByTestId('node-run-info')).toBeInTheDocument();
const tooltipDiv = container.querySelector('.tooltipRow');
expect(tooltipDiv).toBeInTheDocument();
const statusIcon = getByTestId('node-run-status-danger');
expect(statusIcon).toHaveClass('danger');
const infoIcon = getByTestId('node-run-info');
expect(infoIcon).toBeInTheDocument();
// Verify the component renders with error status
expect(statusIcon).toHaveAttribute('data-test-id', 'node-run-status-danger');
// Check tooltip content exists in the DOM (even if hidden)
expect(document.body).toHaveTextContent('Failed');
expect(document.body).toHaveTextContent('Start time:');
expect(document.body).toHaveTextContent('Jan 15 at 10:30:00');
expect(document.body).toHaveTextContent('Execution time:');
expect(document.body).toHaveTextContent('1200 ms');
});
});

View File

@@ -53,6 +53,7 @@ const runMetadata = computed(() => {
</N8nInfoTip>
<div v-else-if="runMetadata" :class="$style.tooltipRow">
<N8nInfoTip
v-if="taskData?.executionStatus !== 'canceled'"
type="note"
:theme="theme"
:data-test-id="`node-run-status-${theme}`"
@@ -69,7 +70,9 @@ const runMetadata = computed(() => {
>{{
runTaskData?.error
? i18n.baseText('runData.executionStatus.failed')
: i18n.baseText('runData.executionStatus.success')
: runTaskData?.executionStatus === 'canceled'
? i18n.baseText('runData.executionStatus.canceled')
: i18n.baseText('runData.executionStatus.success')
}} </n8n-text
><br />
<n8n-text :bold="true" size="small">{{

View File

@@ -53,7 +53,7 @@ const classes = computed(() => {
[$style.node]: true,
[$style.selected]: isSelected.value,
[$style.disabled]: isDisabled.value,
[$style.success]: hasRunData.value,
[$style.success]: hasRunData.value && executionStatus.value === 'success',
[$style.error]: hasExecutionErrors.value,
[$style.pinned]: hasPinnedData.value,
[$style.waiting]: executionWaiting.value ?? executionStatus.value === 'waiting',

View File

@@ -75,7 +75,10 @@ describe('CanvasNodeStatusIcons', () => {
provide: {
...createCanvasProvide(),
...createCanvasNodeProvide({
data: { runData: { outputMap: {}, iterations: 15, visible: true } },
data: {
execution: { status: 'success', running: false },
runData: { outputMap: {}, iterations: 15, visible: true },
},
}),
},
},
@@ -84,6 +87,24 @@ describe('CanvasNodeStatusIcons', () => {
expect(getByTestId('canvas-node-status-success')).toHaveTextContent('15');
});
it('should not render success icon for a node that was canceled', () => {
const { queryByTestId } = renderComponent({
global: {
provide: {
...createCanvasProvide(),
...createCanvasNodeProvide({
data: {
execution: { status: 'canceled', running: false },
runData: { outputMap: {}, iterations: 15, visible: true },
},
}),
},
},
});
expect(queryByTestId('canvas-node-status-success')).not.toBeInTheDocument();
});
it('should render correctly for a dirty node that has run successfully', () => {
const { getByTestId } = renderComponent({
global: {

View File

@@ -131,7 +131,7 @@ const commonClasses = computed(() => [
</N8nTooltip>
</div>
<div
v-else-if="hasRunData"
v-else-if="hasRunData && executionStatus === 'success'"
data-test-id="canvas-node-status-success"
:class="[...commonClasses, $style.runData]"
>

View File

@@ -567,6 +567,116 @@ describe('useCanvasMapping', () => {
},
});
});
it('should not count canceled iterations but still count their data', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes = [createTestNode({ name: 'Node 1' })];
const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'success',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
executionIndex: 1,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }, { json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
executionIndex: 2,
source: [],
executionStatus: 'success',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
]);
const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({
[nodes[0].id]: {
[NodeConnectionTypes.Main]: {
0: {
iterations: 2, // Only 2 iterations counted (not the canceled one)
total: 6, // All data items still counted
},
},
},
});
});
it('should handle all canceled iterations correctly', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes = [createTestNode({ name: 'Node 1' })];
const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
executionIndex: 1,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
]);
const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({
[nodes[0].id]: {
[NodeConnectionTypes.Main]: {
0: {
iterations: 0, // No iterations counted since all are canceled
total: 3, // But data items still counted
},
},
},
});
});
});
describe('additionalNodePropertiesById', () => {
@@ -961,6 +1071,262 @@ describe('useCanvasMapping', () => {
});
});
describe('filterOutCanceled helper function', () => {
it('should return null for null input', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes = [createTestNode({ name: 'Node 1' })];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue(null);
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(0);
});
it('should filter out canceled tasks and return correct iteration count', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes = [createTestNode({ name: 'Node 1' })];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'success',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
executionIndex: 1,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
executionIndex: 2,
source: [],
executionStatus: 'error',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
]);
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(2);
expect(mappedNodes.value[0]?.data?.runData?.visible).toEqual(true);
});
it('should return 0 iterations when all tasks are canceled', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes = [createTestNode({ name: 'Node 1' })];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
executionIndex: 1,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
]);
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(0);
expect(mappedNodes.value[0]?.data?.runData?.visible).toEqual(true);
});
it('should return correct count when no canceled tasks', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodes = [createTestNode({ name: 'Node 1' })];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'success',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
executionIndex: 1,
source: [],
executionStatus: 'error',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
]);
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(2);
});
});
describe('nodeExecutionStatusById', () => {
it('should return last execution status when not canceled', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {
'Test Node': [
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'success',
},
],
};
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('success');
});
it('should return second-to-last status when last execution is canceled and multiple tasks exist', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {
'Test Node': [
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'success',
},
{
startTime: 0,
executionTime: 0,
executionIndex: 1,
source: [],
executionStatus: 'canceled',
},
],
};
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('success');
});
it('should return canceled status when only one task exists and it is canceled', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {
'Test Node': [
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'canceled',
},
],
};
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('canceled');
});
it('should return new status when no tasks exist', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {};
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('new');
});
});
describe('nodeHasIssuesById', () => {
it('should return false when node has no issues or errors', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
@@ -1503,5 +1869,203 @@ describe('useCanvasMapping', () => {
},
]);
});
describe('connection status with canceled tasks', () => {
it('should not set success status when last task is canceled', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const nodes = [manualTriggerNode, setNode];
const connections = {
[manualTriggerNode.name]: {
[NodeConnectionTypes.Main]: [
[{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }],
],
},
};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => {
if (nodeName === manualTriggerNode.name) {
return [
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
];
}
return null;
});
const { connections: mappedConnections } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedConnections.value[0]?.data?.status).toBeUndefined();
});
it('should set success status when last task is canceled but previous is success', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const nodes = [manualTriggerNode, setNode];
const connections = {
[manualTriggerNode.name]: {
[NodeConnectionTypes.Main]: [
[{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }],
],
},
};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => {
if (nodeName === manualTriggerNode.name) {
return [
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'success',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
executionIndex: 1,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
];
}
return null;
});
const { connections: mappedConnections } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedConnections.value[0]?.data?.status).toEqual('success');
});
it('should handle connection with only canceled tasks', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const nodes = [manualTriggerNode, setNode];
const connections = {
[manualTriggerNode.name]: {
[NodeConnectionTypes.Main]: [
[{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }],
],
},
};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => {
if (nodeName === manualTriggerNode.name) {
return [
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
{
startTime: 0,
executionTime: 0,
executionIndex: 1,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
];
}
return null;
});
const { connections: mappedConnections } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedConnections.value[0]?.data?.status).toBeUndefined();
});
it('should prioritize running status over canceled task handling', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const nodes = [manualTriggerNode, setNode];
const connections = {
[manualTriggerNode.name]: {
[NodeConnectionTypes.Main]: [
[{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }],
],
},
};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
workflowsStore.isNodeExecuting.mockImplementation((nodeName: string) => {
return nodeName === manualTriggerNode.name;
});
workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => {
if (nodeName === manualTriggerNode.name) {
return [
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
executionStatus: 'canceled',
data: {
[NodeConnectionTypes.Main]: [[{ json: {} }]],
},
},
];
}
return null;
});
const { connections: mappedConnections } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedConnections.value[0]?.data?.status).toEqual('running');
});
});
});
});

View File

@@ -345,7 +345,11 @@ export function useCanvasMapping({
nodes.value.reduce<Record<string, ExecutionStatus>>((acc, node) => {
const tasks = workflowsStore.getWorkflowRunData?.[node.name] ?? [];
acc[node.id] = tasks.at(-1)?.executionStatus ?? 'new';
let lastExecutionStatus = tasks.at(-1)?.executionStatus;
if (tasks.length > 1 && lastExecutionStatus === 'canceled') {
lastExecutionStatus = tasks.at(-2)?.executionStatus;
}
acc[node.id] = lastExecutionStatus ?? 'new';
return acc;
}, {}),
);
@@ -382,7 +386,9 @@ export function useCanvasMapping({
acc[nodeId][connectionType][outputIndex] = acc[nodeId][connectionType][
outputIndex
] ?? { ...outputData };
acc[nodeId][connectionType][outputIndex].iterations += 1;
if (runIteration.executionStatus !== 'canceled') {
acc[nodeId][connectionType][outputIndex].iterations += 1;
}
acc[nodeId][connectionType][outputIndex].total +=
connectionTypeOutputIndexData.length;
}
@@ -593,6 +599,14 @@ export function useCanvasMapping({
}, {});
});
function filterOutCanceled(tasks: ITaskData[] | null): ITaskData[] | null {
if (!tasks) {
return null;
}
return tasks.filter((task) => task.executionStatus !== 'canceled');
}
const mappedNodes = computed<CanvasNode[]>(() => {
const connectionsBySourceNode = connections.value;
const connectionsByDestinationNode =
@@ -635,7 +649,7 @@ export function useCanvasMapping({
},
runData: {
outputMap: nodeExecutionRunDataOutputMapById.value[node.id],
iterations: nodeExecutionRunDataById.value[node.id]?.length ?? 0,
iterations: filterOutCanceled(nodeExecutionRunDataById.value[node.id])?.length ?? 0,
visible: !!nodeExecutionRunDataById.value[node.id],
},
render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} },
@@ -677,6 +691,12 @@ export function useCanvasMapping({
const runDataTotal =
nodeExecutionRunDataOutputMapById.value[connection.source]?.[type]?.[index]?.total ?? 0;
const sourceTasks = nodeExecutionRunDataById.value[connection.source] ?? [];
let lastSourceTask: ITaskData | undefined = sourceTasks[sourceTasks.length - 1];
if (lastSourceTask?.executionStatus === 'canceled' && sourceTasks.length > 1) {
lastSourceTask = sourceTasks[sourceTasks.length - 2];
}
let status: CanvasConnectionData['status'];
if (nodeExecutionRunningById.value[connection.source]) {
status = 'running';
@@ -687,7 +707,7 @@ export function useCanvasMapping({
status = 'pinned';
} else if (nodeHasIssuesById.value[connection.source]) {
status = 'error';
} else if (runDataTotal > 0) {
} else if (runDataTotal > 0 && lastSourceTask?.executionStatus !== 'canceled') {
status = 'success';
}