fix(editor): Open failed node in failed execution from sub-workflow node (#17076)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Csaba Tuncsik
2025-07-10 09:30:27 +02:00
committed by GitHub
parent bd6d954253
commit 8fff83032c
11 changed files with 284 additions and 49 deletions

View File

@@ -1523,6 +1523,7 @@
"nodeView.showError.mounted2.message": "There was a problem initializing the workflow",
"nodeView.showError.mounted2.title": "Init Problem",
"nodeView.showError.openExecution.title": "Problem loading execution",
"nodeView.showError.openExecution.node": "Problem opening node in execution",
"nodeView.showError.openWorkflow.title": "Problem opening workflow",
"nodeView.showError.stopExecution.title": "Problem stopping execution",
"nodeView.showError.stopWaitingForWebhook.title": "Problem deleting test webhook",

View File

@@ -1,30 +1,51 @@
import { createComponentRenderer } from '@/__tests__/render';
import NodeErrorView from '@/components/Error/NodeErrorView.vue';
import { reactive } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import type { NodeError } from 'n8n-workflow';
import { mockedStore } from '@/__tests__/utils';
import { createComponentRenderer } from '@/__tests__/render';
import type { IExecutionResponse } from '@/Interface';
import NodeErrorView from '@/components/Error/NodeErrorView.vue';
import { useAssistantStore } from '@/stores/assistant.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { mockedStore } from '@/__tests__/utils';
import userEvent from '@testing-library/user-event';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
const renderComponent = createComponentRenderer(NodeErrorView);
const mockRouterResolve = vi.fn(() => ({
href: '',
}));
vi.mock('vue-router', () => ({
useRouter: () => ({
resolve: mockRouterResolve,
}),
useRoute: () => reactive({ meta: {} }),
RouterLink: vi.fn(),
}));
// Mock window.open
Object.defineProperty(window, 'open', {
value: vi.fn(),
writable: true,
});
let mockAiAssistantStore: ReturnType<typeof mockedStore<typeof useAssistantStore>>;
let mockNodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let mockNdvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
let mockNDVStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
let mockWorkflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
const renderComponent = createComponentRenderer(NodeErrorView);
describe('NodeErrorView.vue', () => {
let error: NodeError;
beforeEach(() => {
createTestingPinia();
mockAiAssistantStore = mockedStore(useAssistantStore);
mockNodeTypeStore = mockedStore(useNodeTypesStore);
mockNdvStore = mockedStore(useNDVStore);
mockNDVStore = mockedStore(useNDVStore);
mockWorkflowsStore = mockedStore(useWorkflowsStore);
//@ts-expect-error
error = {
name: 'NodeOperationError',
@@ -59,7 +80,7 @@ describe('NodeErrorView.vue', () => {
vi.clearAllMocks();
});
it('renders an Error with a messages array', async () => {
it('renders an Error with a messages array', () => {
const { getByTestId } = renderComponent({
props: {
error: {
@@ -74,7 +95,7 @@ describe('NodeErrorView.vue', () => {
expect(errorMessage).toHaveTextContent('Unexpected identifier [line 1]');
});
it('renders an Error with a message string', async () => {
it('renders an Error with a message string', () => {
const { getByTestId } = renderComponent({
props: {
error: {
@@ -89,8 +110,8 @@ describe('NodeErrorView.vue', () => {
expect(errorMessage).toHaveTextContent('Unexpected identifier [line 1]');
});
it('should not render AI assistant button when error happens in deprecated function node', async () => {
//@ts-expect-error
it('should not render AI assistant button when error happens in deprecated function node', () => {
// @ts-expect-error - Mock node type store method
mockNodeTypeStore.getNodeType = vi.fn(() => ({
type: 'n8n-nodes-base.function',
typeVersion: 1,
@@ -168,20 +189,111 @@ describe('NodeErrorView.vue', () => {
expect(getByTestId('ask-assistant-button')).toBeInTheDocument();
});
it('open error node details when open error node is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
props: {
error: {
...error,
name: 'NodeOperationError',
functionality: 'configuration-node',
describe('onOpenErrorNodeDetailClick', () => {
it('does nothing when error has no node', async () => {
const errorWithoutNode = {
name: 'NodeOperationError',
functionality: 'configuration-node',
message: 'Error without node',
node: undefined,
};
const { queryByTestId } = renderComponent({
props: {
error: errorWithoutNode,
},
},
});
const button = queryByTestId('node-error-view-open-node-button');
// If there's no node, button should not render or if it does, clicking it should do nothing
if (button) {
await userEvent.click(button);
}
expect(window.open).not.toHaveBeenCalled();
expect(mockNDVStore.activeNodeName).toBeNull();
});
await userEvent.click(getByTestId('node-error-view-open-node-button'));
it('opens new window when error has different workflow and execution IDs', async () => {
mockWorkflowsStore.workflowId = 'current-workflow-id';
mockWorkflowsStore.getWorkflowExecution = {
id: 'current-execution-id',
} as IExecutionResponse;
expect(emitted().click).toHaveLength(1);
expect(mockNdvStore.activeNodeName).toBe(error.node.name);
const testError = {
...error,
name: 'NodeOperationError',
functionality: 'configuration-node',
workflowId: 'different-workflow-id',
executionId: 'different-execution-id',
};
const { getByTestId } = renderComponent({
props: {
error: testError,
},
});
const button = getByTestId('node-error-view-open-node-button');
await userEvent.click(button);
expect(mockRouterResolve).toHaveBeenCalledWith({
name: 'ExecutionPreview',
params: {
name: 'different-workflow-id',
executionId: 'different-execution-id',
nodeId: 'd1ce5dc9-f9ae-4ac6-84e5-0696ba175dd9',
},
});
expect(window.open).toHaveBeenCalled();
});
it('sets active node name when error is in current workflow/execution', async () => {
mockWorkflowsStore.workflowId = 'current-workflow-id';
mockWorkflowsStore.getWorkflowExecution = {
id: 'current-execution-id',
} as IExecutionResponse;
const testError = {
...error,
name: 'NodeOperationError',
functionality: 'configuration-node',
workflowId: 'current-workflow-id',
executionId: 'current-execution-id',
};
const { getByTestId } = renderComponent({
props: {
error: testError,
},
});
const button = getByTestId('node-error-view-open-node-button');
await userEvent.click(button);
expect(window.open).not.toHaveBeenCalled();
expect(mockNDVStore.activeNodeName).toBe('ErrorCode');
});
it('sets active node name when error has no workflow/execution IDs', async () => {
const testError = {
...error,
name: 'NodeOperationError',
functionality: 'configuration-node',
};
const { getByTestId } = renderComponent({
props: {
error: testError,
},
});
const button = getByTestId('node-error-view-open-node-button');
await userEvent.click(button);
expect(window.open).not.toHaveBeenCalled();
expect(mockNDVStore.activeNodeName).toBe('ErrorCode');
});
});
});

View File

@@ -1,10 +1,12 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { useClipboard } from '@/composables/useClipboard';
import { useToast } from '@/composables/useToast';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import type {
IDataObject,
@@ -16,7 +18,7 @@ import type {
NodeOperationError,
} from 'n8n-workflow';
import { sanitizeHtml } from '@/utils/htmlUtils';
import { MAX_DISPLAY_DATA_SIZE, NEW_ASSISTANT_SESSION_MODAL } from '@/constants';
import { MAX_DISPLAY_DATA_SIZE, NEW_ASSISTANT_SESSION_MODAL, VIEWS } from '@/constants';
import type { BaseTextKey } from '@n8n/i18n';
import { useAssistantStore } from '@/stores/assistant.store';
import type { ChatRequest } from '@/types/assistant.types';
@@ -34,6 +36,8 @@ type Props = {
};
const props = defineProps<Props>();
const router = useRouter();
const clipboard = useClipboard();
const toast = useToast();
const i18n = useI18n();
@@ -41,10 +45,14 @@ const assistantHelpers = useAIAssistantHelpers();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const rootStore = useRootStore();
const assistantStore = useAssistantStore();
const uiStore = useUIStore();
const workflowId = computed(() => workflowsStore.workflowId);
const executionId = computed(() => workflowsStore.getWorkflowExecution?.id);
const displayCause = computed(() => {
return JSON.stringify(props.error.cause ?? '').length < MAX_DISPLAY_DATA_SIZE;
});
@@ -206,7 +214,7 @@ function getErrorMessage(): string {
if (isSubNodeError.value) {
message = i18n.baseText('nodeErrorView.errorSubNode', {
interpolate: { node: props.error.node.name },
interpolate: { node: props.error.node?.name ?? '' },
});
} else if (
isNonEmptyString(props.error.message) &&
@@ -384,7 +392,32 @@ function nodeIsHidden() {
}
const onOpenErrorNodeDetailClick = () => {
ndvStore.activeNodeName = props.error.node.name;
if (!props.error.node) {
return;
}
if (
'workflowId' in props.error &&
workflowId.value &&
typeof props.error.workflowId === 'string' &&
workflowId.value !== props.error.workflowId &&
'executionId' in props.error &&
executionId.value &&
typeof props.error.executionId === 'string' &&
executionId.value !== props.error.executionId
) {
const link = router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: {
name: props.error.workflowId,
executionId: props.error.executionId,
nodeId: props.error.node.id,
},
});
window.open(link.href, '_blank');
} else {
ndvStore.activeNodeName = props.error.node.name;
}
};
async function onAskAssistantClick() {

View File

@@ -1,6 +1,7 @@
import { reactive } from 'vue';
import { createTestWorkflowObject, defaultNodeDescriptions } from '@/__tests__/mocks';
import { createComponentRenderer } from '@/__tests__/render';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { type MockedStore, mockedStore, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import RunData from '@/components/RunData.vue';
import { STORES } from '@n8n/stores';
import { SET_NODE_TYPE } from '@/constants';
@@ -11,7 +12,8 @@ import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import type { INodeExecutionData, ITaskData, ITaskMetadata } from 'n8n-workflow';
import { setActivePinia } from 'pinia';
import { useNodeTypesStore } from '../stores/nodeTypes.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSchemaPreviewStore } from '@/stores/schemaPreview.store';
const MOCK_EXECUTION_URL = 'execution.url/123';
@@ -22,8 +24,12 @@ const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = vi.hoisted(
vi.mock('vue-router', () => {
return {
useRouter: () => ({}),
useRoute: () => ({ meta: {} }),
useRouter: () => ({
resolve: vi.fn(() => ({
href: '',
})),
}),
useRoute: () => reactive({ meta: {} }),
RouterLink: vi.fn(),
};
});
@@ -41,6 +47,10 @@ vi.mock('@/composables/useWorkflowHelpers', async (importOriginal) => {
});
describe('RunData', () => {
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let nodeTypesStore: MockedStore<typeof useNodeTypesStore>;
let schemaPreviewStore: MockedStore<typeof useSchemaPreviewStore>;
beforeAll(() => {
resolveRelatedExecutionUrl.mockReturnValue('execution.url/123');
});
@@ -611,14 +621,16 @@ describe('RunData', () => {
const render = ({
defaultRunItems,
workflowId,
workflowNodes = nodes,
displayMode,
displayMode = 'html',
pinnedData,
paneType = 'output',
metadata,
runs,
}: {
defaultRunItems?: INodeExecutionData[];
workflowId?: string;
workflowNodes?: INodeUi[];
displayMode: IRunDataDisplayMode;
pinnedData?: INodeExecutionData[];
@@ -677,24 +689,27 @@ describe('RunData', () => {
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore = mockedStore(useNodeTypesStore);
workflowsStore = mockedStore(useWorkflowsStore);
schemaPreviewStore = mockedStore(useSchemaPreviewStore);
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
vi.mocked(workflowsStore).getNodeByName.mockReturnValue(workflowNodes[0]);
workflowsStore.getNodeByName.mockReturnValue(workflowNodes[0]);
if (pinnedData) {
vi.mocked(workflowsStore).pinDataByNodeName.mockReturnValue(pinnedData);
workflowsStore.pinDataByNodeName.mockReturnValue(pinnedData);
}
schemaPreviewStore.getSchemaPreview = vi.fn().mockResolvedValue({});
return createComponentRenderer(RunData, {
props: {
node: {
name: 'Test Node',
},
workflow: createTestWorkflowObject({
// @ts-expect-error allow missing properties in test
workflowNodes,
id: workflowId,
nodes: workflowNodes,
}),
displayMode,
},
@@ -710,6 +725,7 @@ describe('RunData', () => {
name: 'Test Node',
type: SET_NODE_TYPE,
position: [0, 0],
parameters: {},
},
nodes: [{ name: 'Test Node', indicies: [], depth: 1 }],
runIndex: 0,
@@ -717,6 +733,9 @@ describe('RunData', () => {
isExecuting: false,
mappingEnabled: true,
distanceFromActive: 0,
tooMuchDataTitle: '',
executingMessage: '',
noDataInBranchMessage: '',
},
pinia,
});

View File

@@ -311,9 +311,7 @@ const subworkflowExecutionError = computed(() => {
} as NodeError;
});
const hasSubworkflowExecutionError = computed(() =>
Boolean(workflowsStore.subWorkflowExecutionError),
);
const hasSubworkflowExecutionError = computed(() => !!workflowsStore.subWorkflowExecutionError);
// Sub-nodes may wish to display the parent node error as it can contain additional metadata
const parentNodeError = computed(() => {
@@ -1775,7 +1773,7 @@ defineExpose({ enterEditMode });
v-else-if="hasNodeRun && !inputData.length && !displaysMultipleNodes && !search"
:class="$style.center"
>
<slot name="no-output-data">xxx</slot>
<slot name="no-output-data"></slot>
</div>
<div

View File

@@ -13,6 +13,7 @@ const props = withDefaults(
workflow?: IWorkflowDb | IWorkflowTemplate['workflow'];
executionId?: string;
executionMode?: string;
nodeId?: string;
loaderType?: 'image' | 'spinner';
canOpenNDV?: boolean;
hideNodeIssues?: boolean;
@@ -24,6 +25,7 @@ const props = withDefaults(
workflow: undefined,
executionId: undefined,
executionMode: undefined,
nodeId: undefined,
loaderType: 'image',
canOpenNDV: true,
hideNodeIssues: false,
@@ -95,6 +97,7 @@ const loadExecution = () => {
command: 'openExecution',
executionId: props.executionId,
executionMode: props.executionMode ?? '',
nodeId: props.nodeId,
canOpenNDV: props.canOpenNDV,
}),
'*',

View File

@@ -45,6 +45,7 @@ const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
);
const executionId = computed(() => route.params.executionId as string);
const nodeId = computed(() => route.params.nodeId as string);
const executionUIDetails = computed<IExecutionUIData | null>(() =>
props.execution ? executionHelpers.getUIDetails(props.execution) : null,
);
@@ -327,6 +328,7 @@ const onVoteClick = async (voteValue: AnnotationVote) => {
loader-type="spinner"
:execution-id="executionId"
:execution-mode="execution?.mode || ''"
:node-id="nodeId"
/>
</div>
</template>

View File

@@ -3081,6 +3081,61 @@ describe('useCanvasOperations', () => {
type: 'error',
});
});
it('should set active node when nodeId is provided and node exists', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const ndvStore = mockedStore(useNDVStore);
const { openExecution } = useCanvasOperations();
const executionId = '123';
const nodeId = 'node-123';
const mockNode = { id: nodeId, name: 'Test Node', type: 'test' } as INodeUi;
const executionData: IExecutionResponse = {
id: executionId,
finished: true,
status: 'success',
startedAt: new Date(),
createdAt: new Date(),
workflowData: createTestWorkflow(),
mode: 'manual' as WorkflowExecuteMode,
};
workflowsStore.getExecution.mockResolvedValue(executionData);
workflowsStore.getNodeById.mockReturnValue(mockNode);
await openExecution(executionId, nodeId);
expect(workflowsStore.getNodeById).toHaveBeenCalledWith(nodeId);
expect(ndvStore.activeNodeName).toBe(mockNode.name);
});
it('should show error when nodeId is provided but node does not exist', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const { openExecution } = useCanvasOperations();
const toast = useToast();
const executionId = '123';
const nodeId = 'non-existent-node';
const executionData: IExecutionResponse = {
id: executionId,
finished: true,
status: 'success',
startedAt: new Date(),
createdAt: new Date(),
workflowData: createTestWorkflow(),
mode: 'manual' as WorkflowExecuteMode,
};
workflowsStore.getExecution.mockResolvedValue(executionData);
workflowsStore.getNodeById.mockReturnValue(undefined);
await openExecution(executionId, nodeId);
expect(workflowsStore.getNodeById).toHaveBeenCalledWith(nodeId);
expect(toast.showError).toHaveBeenCalledWith(
new Error(`Node with id "${nodeId}" could not be found!`),
'Problem opening node in execution',
);
});
});
describe('connectAdjacentNodes', () => {

View File

@@ -2130,7 +2130,7 @@ export function useCanvasOperations() {
deleteNodes(ids);
}
async function openExecution(executionId: string) {
async function openExecution(executionId: string, nodeId?: string) {
let data: IExecutionResponse | undefined;
try {
data = await workflowsStore.getExecution(executionId);
@@ -2159,6 +2159,18 @@ export function useCanvasOperations() {
workflowsStore.setWorkflowPinData({});
}
if (nodeId) {
const node = workflowsStore.getNodeById(nodeId);
if (node) {
ndvStore.activeNodeName = node.name;
} else {
toast.showError(
new Error(`Node with id "${nodeId}" could not be found!`),
i18n.baseText('nodeView.showError.openExecution.node'),
);
}
}
uiStore.stateIsDirty = false;
return data;

View File

@@ -247,7 +247,7 @@ export const routes: RouteRecordRaw[] = [
},
},
{
path: ':executionId',
path: ':executionId/:nodeId?',
name: VIEWS.EXECUTION_PREVIEW,
components: {
executionPreview: WorkflowExecutionsPreview,

View File

@@ -1322,13 +1322,13 @@ function trackRunWorkflowToNode(node: INodeUi) {
void externalHooks.run('nodeView.onRunNode', telemetryPayload);
}
async function onOpenExecution(executionId: string) {
async function onOpenExecution(executionId: string, nodeId?: string) {
canvasStore.startLoading();
resetWorkspace();
await initializeData();
const data = await openExecution(executionId);
const data = await openExecution(executionId, nodeId);
if (!data) {
return;
}
@@ -1581,7 +1581,7 @@ async function onPostMessageReceived(messageEvent: MessageEvent) {
isProductionExecutionPreview.value =
json.executionMode !== 'manual' && json.executionMode !== 'evaluation';
await onOpenExecution(json.executionId);
await onOpenExecution(json.executionId, json.nodeId);
canOpenNDV.value = json.canOpenNDV ?? true;
hideNodeIssues.value = json.hideNodeIssues ?? false;
isExecutionPreview.value = true;