mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
fix: Show correct "canceled" node status for chat model nodes (#19366)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
155
packages/frontend/editor-ui/src/components/RunInfo.test.ts
Normal file
155
packages/frontend/editor-ui/src/components/RunInfo.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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">{{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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]"
|
||||
>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user