fix(editor): Stop a new node from showing a deleted node's execution data (#36915)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel Moreno
2026-08-28 07:56:27 +00:00
committed by GitHub
parent 662a64de83
commit 8bf95ac2b6
23 changed files with 789 additions and 160 deletions
@@ -133,6 +133,50 @@ describe(useNodeDirtiness, () => {
});
});
describe('replacing a node with one that reuses its name', () => {
it('should not mark the replacement as dirty', async () => {
useNodeTypesStore().setNodeTypes(defaultNodeDescriptions);
setupTestWorkflow('a\u{1F6A8}\u2705 -> b\u2705');
// Delete and replace through the canvas the way the editor does, so the
// history records the replacement after the run. The new node reuses the
// freed name but never ran.
canvasOperations.deleteNodes([workflowDocumentStore.nodesByName.b.id], {
trackHistory: true,
});
await canvasOperations.addNodes([createTestNode({ name: 'b', type: SET_NODE_TYPE })], {
trackHistory: true,
});
canvasOperations.createConnection(
{
source: workflowDocumentStore.nodesByName.a.id,
target: workflowDocumentStore.nodesByName.b.id,
},
{ trackHistory: true },
);
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value.b).toBeUndefined();
});
it('should still mark the node that ran as dirty when its parameters change', () => {
useNodeTypesStore().setNodeTypes(defaultNodeDescriptions);
setupTestWorkflow('a\u{1F6A8}\u2705 -> b\u2705');
workflowDocumentStore.setNodeParameters({
name: 'b',
value: { param: 'changed' },
});
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.PARAMETERS_UPDATED,
});
});
});
describe('updating node parameters', () => {
it('should mark a node as dirty if its parameter has changed', () => {
setupTestWorkflow('a🚨✅, b✅, c✅');
@@ -19,7 +19,6 @@ import type {
INodeInputConfiguration,
INodeExecutionData,
ITaskDataConnections,
IRunData,
IBinaryKeyData,
INode,
INodeCredentialsDetails,
@@ -747,19 +746,10 @@ export function useNodeHelpers() {
}
function getBinaryData(
workflowRunData: IRunData | null,
node: string | null,
runIndex: number,
runDataOfNode: ITaskDataConnections | undefined,
outputIndex: number,
connectionType: NodeConnectionType = NodeConnectionTypes.Main,
): IBinaryKeyData[] {
if (node === null) {
return [];
}
const runData: IRunData | null = workflowRunData;
const runDataOfNode = runData?.[node]?.[runIndex]?.data;
if (!runDataOfNode) {
return [];
}
@@ -318,10 +318,10 @@ describe('useRunWorkflow({ router })', () => {
// Production reads run data from the execution-state store (keyed by document
// id), not the workflows store, so seed it to drive `activeExecutionRunData`.
function seedActiveRunData(runData: IRunData) {
function seedActiveRunData(runData: IRunData, executedNodes: INode[] = []) {
executionStateStore.setWorkflowExecutionData({
id: 'seeded-execution',
workflowData: { id: '123', nodes: [], connections: {} },
workflowData: { id: '123', nodes: executedNodes, connections: {} },
finished: true,
mode: 'manual',
status: 'success',
@@ -932,6 +932,58 @@ describe('useRunWorkflow({ router })', () => {
expect(dataCaptor.value).toMatchObject({ data: { resultData: { runData: mockRunData } } });
});
describe('run data of replaced nodes', () => {
async function runPartialExecutionWith({
executedNodeId,
currentNodeId,
}: {
executedNodeId: string;
currentNodeId: string;
}) {
const { runWorkflow } = useRunWorkflow({ router });
const runData = { 'Test node': [] };
vi.mocked(mockDocumentStore.getNodeByName).mockImplementation((name: string) =>
name === 'Test node' ? createTestNode({ id: currentNodeId, name: 'Test node' }) : null,
);
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue({ executionId: '123' });
mockDocumentStore.hasNodeValidationIssues = false;
mockDocumentStore.serialize.mockReturnValue({
id: 'workflowId',
nodes: [createTestNode({ id: currentNodeId, name: 'Test node' })],
connections: {},
});
seedActiveRunData(runData, [createTestNode({ id: executedNodeId, name: 'Test node' })]);
await runWorkflow({
destinationNode: { nodeName: 'Test node', mode: 'inclusive' },
});
return vi.mocked(workflowsStore).runWorkflow.mock.calls.at(-1)?.[0];
}
it('drops the entry when the name now belongs to a different node', async () => {
const startRunData = await runPartialExecutionWith({
executedNodeId: 'executed-id',
currentNodeId: 'added-after-the-run',
});
expect(startRunData?.runData).toEqual({});
});
it('keeps the entry for the node that recorded it', async () => {
const startRunData = await runPartialExecutionWith({
executedNodeId: 'same-id',
currentNodeId: 'same-id',
});
expect(startRunData?.runData).toEqual({ 'Test node': [] });
});
});
it('retains the original run data', async () => {
// ARRANGE
const mockExecutionResponse = { executionId: '123' };
@@ -1430,6 +1430,100 @@ describe('executionData.store', () => {
expect(outputData?.byTarget?.['vector-1']).toEqual({ iterations: 1, total: 3 });
});
});
describe('executionIssuesByNodeId', () => {
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
it('returns the issues recorded under the node name', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
setExecutionWithSnapshot(store, {
nodes: [node],
runData: {
'Node 1': [{ error: { message: 'Boom' } }],
},
});
await flushPromises();
expect(store.executionIssuesByNodeId.get('node-1')?.value).toEqual(['Boom']);
});
it('has no entry for an id the execution never ran', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
setExecutionWithSnapshot(store, {
nodes: [node],
runData: {
'Node 1': [{ error: { message: 'Boom' } }],
},
});
await flushPromises();
// A node added after the run: its name may match, its id cannot.
expect(store.executionIssuesByNodeId.get('node-added-later')).toBeUndefined();
});
it('follows a renamed node, because the snapshot is renamed with it', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
setExecutionWithSnapshot(store, {
nodes: [node],
runData: {
'Node 1': [{ error: { message: 'Boom' } }],
},
});
await flushPromises();
store.renameExecutionDataNode('Node 1', 'Renamed');
await flushPromises();
expect(store.executionIssuesByNodeId.get('node-1')?.value).toEqual(['Boom']);
});
});
describe('executionPinDataByNodeId', () => {
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
it('returns the pin data recorded under the node name', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
store.setExecution(
createTestExecution({
workflowData: createTestWorkflow({ nodes: [node] }),
data: {
resultData: {
runData: {},
pinData: { 'Node 1': [{ json: { pinned: true } }] },
},
} as never,
}),
);
await flushPromises();
expect(store.executionPinDataByNodeId.get('node-1')?.value).toEqual([
{ json: { pinned: true } },
]);
});
it('has no entry for an id the execution never ran', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
store.setExecution(
createTestExecution({
workflowData: createTestWorkflow({ nodes: [node] }),
data: {
resultData: {
runData: {},
pinData: { 'Node 1': [{ json: { pinned: true } }] },
},
} as never,
}),
);
await flushPromises();
expect(store.executionPinDataByNodeId.get('node-added-later')).toBeUndefined();
});
});
});
async function flushPromises() {
@@ -188,10 +188,9 @@ export function useExecutionDataStore(id: ExecutionDataId) {
// per-entry projections below — collapses id → node resolution from
// O(N) per lookup to O(1).
const executionNodeById = computed(() => {
const map = new Map<string, INode>();
const nodes = execution.value?.workflowData?.nodes;
if (nodes) for (const n of nodes) map.set(n.id, n);
return map;
const snapshotNodes = execution.value?.workflowData?.nodes ?? [];
const kvPairs = snapshotNodes.map((n) => [n.id, n] as const);
return new Map(kvPairs);
});
function getExecutionNodeById(nodeId: string): INode | undefined {
@@ -212,10 +211,17 @@ export function useExecutionDataStore(id: ExecutionDataId) {
const executionWaitingByNodeId = shallowReactive(
new Map<string, ComputedRef<string | undefined>>(),
);
const executionIssuesByNodeId = shallowReactive(new Map<string, ComputedRef<string[]>>());
const executionPinDataByNodeId = shallowReactive(
new Map<string, ComputedRef<IPinData[string] | undefined>>(),
);
function computeExecutionStatus(nodeId: string): ExecutionStatus {
const node = getExecutionNodeById(nodeId);
if (!node) return 'new';
if (!node) {
return 'new';
}
const tasks = executionRunData.value?.[node.name] ?? [];
// A canceled top-of-stack tends to mask the prior "real" status — peek
// one task back so the UI shows the meaningful state.
@@ -223,16 +229,38 @@ export function useExecutionDataStore(id: ExecutionDataId) {
if (tasks.length > 1 && status === 'canceled') {
status = tasks.at(-2)?.executionStatus;
}
return status ?? 'new';
}
function computeExecutionRunData(nodeId: string): ITaskData[] | null {
const node = getExecutionNodeById(nodeId);
if (!node) return null;
if (!node) {
return null;
}
const tasks = executionRunData.value?.[node.name];
return tasks ?? null;
}
function computeNodeExecutionIssuesById(nodeId: string): string[] {
const node = getExecutionNodeById(nodeId);
if (!node) {
return [];
}
return computeNodeExecutionIssues(node.name);
}
function computeExecutionPinData(nodeId: string): IPinData[string] | undefined {
const node = getExecutionNodeById(nodeId);
if (!node) {
return undefined;
}
return executionPinDataByNodeName.value[node.name];
}
function computeExecutionWaiting(nodeId: string): string | undefined {
const node = getExecutionNodeById(nodeId);
if (!node) return undefined;
@@ -270,13 +298,17 @@ export function useExecutionDataStore(id: ExecutionDataId) {
}
function applyAddByIdEntry(nodeId: string) {
if (byIdScopes.has(nodeId)) return;
if (byIdScopes.has(nodeId)) {
return;
}
const scope = effectScope();
scope.run(() => {
executionStatusByNodeId.set(
nodeId,
structuralComputed(() => computeExecutionStatus(nodeId)),
);
// Plain `computed` (Object.is) rather than `structuralComputed(..., isEqual)`:
// per-task data can be megabytes for nodes with large outputs, and
// every push replaces the runData array reference with new content
@@ -286,11 +318,27 @@ export function useExecutionDataStore(id: ExecutionDataId) {
nodeId,
computed(() => computeExecutionRunData(nodeId)),
);
executionWaitingByNodeId.set(
nodeId,
structuralComputed(() => computeExecutionWaiting(nodeId)),
);
executionIssuesByNodeId.set(
nodeId,
structuralComputed(() => computeNodeExecutionIssuesById(nodeId), isEqual),
);
// Plain `computed` for the same reason as `executionRunDataByNodeId`
// above: this returns a reference out of the payload, so `Object.is`
// already short-circuits and `isEqual` would deep-compare pin data
// that can reach megabytes.
executionPinDataByNodeId.set(
nodeId,
computed(() => computeExecutionPinData(nodeId)),
);
});
byIdScopes.set(nodeId, () => scope.stop());
}
@@ -300,6 +348,8 @@ export function useExecutionDataStore(id: ExecutionDataId) {
executionStatusByNodeId.delete(nodeId);
executionRunDataByNodeId.delete(nodeId);
executionWaitingByNodeId.delete(nodeId);
executionIssuesByNodeId.delete(nodeId);
executionPinDataByNodeId.delete(nodeId);
}
function applyReconcileByIdEntries(nodeIds: string[]) {
@@ -722,6 +772,8 @@ export function useExecutionDataStore(id: ExecutionDataId) {
executionStatusByNodeId,
executionRunDataByNodeId,
executionWaitingByNodeId,
executionIssuesByNodeId,
executionPinDataByNodeId,
executionRunDataOutputMapByNodeId,
executionStartedData: readonly(executionStartedData),
executionPairedItemMappings: readonly(executionPairedItemMappings),
@@ -233,7 +233,7 @@ export function useWorkflowDocumentRenderData(workflowDocumentId: WorkflowDocume
if (validationErrors.length > 0) return true;
const executionIssues =
executionStateStore.activeExecutionIssuesByNodeName.get(node.name)?.value ?? [];
executionStateStore.activeExecutionIssuesByNodeId.get(nodeId)?.value ?? [];
if (executionIssues.length > 0) return true;
const tasks = executionStateStore.activeExecutionRunDataByNodeId.get(nodeId)?.value ?? null;
@@ -242,10 +242,14 @@ export function useWorkflowDocumentRenderData(workflowDocumentId: WorkflowDocume
function getVisiblePinData(nodeId: string) {
const node = getNode(nodeId);
if (!node) return undefined;
if (executionStateStore.isExecutionDataDisplayed) {
return executionStateStore.activeExecutionPinDataByNodeName[node.name];
if (!node) {
return undefined;
}
if (executionStateStore.isExecutionDataDisplayed) {
return executionStateStore.activeExecutionPinDataByNodeId.get(nodeId)?.value;
}
return workflowDocumentStore.pinnedDataByNodeId.get(nodeId)?.value;
}
@@ -554,6 +558,12 @@ export function useWorkflowDocumentRenderData(workflowDocumentId: WorkflowDocume
get executionPinDataByNodeName() {
return executionStateStore.activeExecutionPinDataByNodeName;
},
get executionIssuesByNodeId() {
return executionStateStore.activeExecutionIssuesByNodeId;
},
get executionPinDataByNodeId() {
return executionStateStore.activeExecutionPinDataByNodeId;
},
get isExecutionDataDisplayed() {
return executionStateStore.isExecutionDataDisplayed;
},
@@ -15,6 +15,7 @@ import type {
ExecutionStatus,
ExecutionSummary,
IPinData,
IRunData,
IRunExecutionData,
ITaskData,
ITaskStartedData,
@@ -64,6 +65,11 @@ const EMPTY_EXECUTION_STATUS_BY_NODE_ID = new Map<string, ComputedRef<ExecutionS
const EMPTY_EXECUTION_RUN_DATA_BY_NODE_ID = new Map<string, ComputedRef<ITaskData[] | null>>();
const EMPTY_EXECUTION_RUN_DATA_OUTPUT_MAP_BY_NODE_ID = new Map<string, ExecutionOutputMap>();
const EMPTY_EXECUTION_WAITING_BY_NODE_ID = new Map<string, ComputedRef<string | undefined>>();
const EMPTY_EXECUTION_ISSUES_BY_NODE_ID = new Map<string, ComputedRef<string[]>>();
const EMPTY_EXECUTION_PIN_DATA_BY_NODE_ID = new Map<
string,
ComputedRef<IPinData[string] | undefined>
>();
export type WorkflowExecutionStateChangePayload = {
documentId: WorkflowDocumentId;
@@ -338,14 +344,45 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
typeof displayedExecutionId.value === 'string',
);
// Drops the entries whose name now belongs to a different node than the one
// that produced them.
function dropRunDataOfReplacedNodes(
runData: IRunData | null,
executedNodes: ReadonlyArray<{ id: string; name: string }> | undefined,
): IRunData | null {
if (!runData || !executedNodes?.length) {
return runData;
}
const executedIdByName = new Map(executedNodes.map((node) => [node.name, node.id]));
const entries = Object.entries(runData);
const kept = entries.filter(([nodeName]) => {
const executedId = executedIdByName.get(nodeName);
const currentId = documentStore.getNodeByName(nodeName)?.id;
return !executedId || !currentId || executedId === currentId;
});
// Same object when nothing was dropped: this runs on every execution
// push and consumers downstream gate on reference identity.
return kept.length === entries.length ? runData : Object.fromEntries(kept);
}
const activeExecutionRunData = computed(() => {
const executionId = getResolvedActiveExecutionId();
if (!executionId) return null;
if (!executionId) {
return null;
}
const executionDataStore = useExecutionDataStore(createExecutionDataId(executionId));
// Track the timestamp so in-place mutations to runData (which keep
// the runData object reference) still propagate.
void executionDataStore.executionResultDataLastUpdate;
return executionDataStore.executionRunData;
return dropRunDataOfReplacedNodes(
executionDataStore.executionRunData,
executionDataStore.execution?.workflowData?.nodes,
);
});
const activeExecutionExecutedNode = computed(() => {
@@ -442,6 +479,18 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
return useExecutionDataStore(createExecutionDataId(executionId)).executionWaitingByNodeId;
});
const activeExecutionIssuesByNodeId = computed(() => {
const executionId = getResolvedActiveExecutionId();
if (!executionId) return EMPTY_EXECUTION_ISSUES_BY_NODE_ID;
return useExecutionDataStore(createExecutionDataId(executionId)).executionIssuesByNodeId;
});
const activeExecutionPinDataByNodeId = computed(() => {
const executionId = getResolvedActiveExecutionId();
if (!executionId) return EMPTY_EXECUTION_PIN_DATA_BY_NODE_ID;
return useExecutionDataStore(createExecutionDataId(executionId)).executionPinDataByNodeId;
});
const lastSuccessfulExecution = computed<IExecutionResponse | null>(() => {
const lid = lastSuccessfulExecutionId.value;
if (!lid) return null;
@@ -1024,6 +1073,8 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
activeExecutionRunDataByNodeId,
activeExecutionRunDataOutputMapByNodeId,
activeExecutionWaitingByNodeId,
activeExecutionIssuesByNodeId,
activeExecutionPinDataByNodeId,
activeAgentCapabilityKeysByNodeId,
executionRunningByNodeId,
executionWaitingForNextByNodeId,
@@ -0,0 +1,97 @@
import { computed } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import {
createTestNode,
createTestWorkflow,
createTestWorkflowExecutionResponse,
} from '@/__tests__/mocks';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import type { INode, IRunData, ITaskData } from 'n8n-workflow';
import { useExecutionData } from './useExecutionData';
vi.mock('vue-router', () => ({
useRouter: () => ({}),
useRoute: () => ({ meta: {} }),
RouterLink: vi.fn(),
}));
const successfulTask = { executionStatus: 'success' } as ITaskData;
describe('useExecutionData()', () => {
// The composable falls back to the workflows store's (empty) workflow id when
// nothing is provided, so seed both stores keyed by that id and let the real
// `activeExecutionRunData` filter run.
function seedExecution({
executedNodes,
documentNodes,
runData,
}: {
executedNodes: INode[];
documentNodes: INode[];
runData: IRunData;
}) {
const documentId = createWorkflowDocumentId(useWorkflowsStore().workflowId);
useWorkflowDocumentStore(documentId).setNodes(documentNodes);
useWorkflowExecutionStateStore(documentId).setWorkflowExecutionData(
createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({ nodes: executedNodes }),
data: { resultData: { runData } } as never,
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
createTestingPinia({ stubActions: false });
});
it('returns the run data of the node that the execution ran', () => {
const node = createTestNode({ id: 'executed-node', name: 'Message a model' });
seedExecution({
executedNodes: [node],
documentNodes: [node],
runData: { 'Message a model': [successfulTask] },
});
const { nodeRunData, hasNodeRun } = useExecutionData({ node: computed(() => node) });
expect(nodeRunData.value).toEqual([successfulTask]);
expect(hasNodeRun.value).toBe(true);
});
it('returns nothing for a node that reuses the name of a node the execution ran', () => {
const executed = createTestNode({ id: 'executed-node', name: 'Message a model' });
// Same name, different node: added after the run.
const replacement = createTestNode({ id: 'added-later', name: 'Message a model' });
seedExecution({
executedNodes: [executed],
documentNodes: [replacement],
runData: { 'Message a model': [successfulTask] },
});
const { nodeRunData, hasNodeRun } = useExecutionData({ node: computed(() => replacement) });
expect(nodeRunData.value).toBeNull();
expect(hasNodeRun.value).toBe(false);
});
it('returns nothing when there is no node', () => {
const node = createTestNode({ id: 'executed-node', name: 'Message a model' });
seedExecution({
executedNodes: [node],
documentNodes: [node],
runData: { 'Message a model': [successfulTask] },
});
const { nodeRunData, hasNodeRun } = useExecutionData({ node: computed(() => undefined) });
expect(nodeRunData.value).toBeNull();
expect(hasNodeRun.value).toBe(false);
});
});
@@ -9,17 +9,18 @@ export function useExecutionData({ node }: { node: ComputedRef<INode | undefined
const workflowRunData = computed(() => workflowExecutionStateStore.value.activeExecutionRunData);
const hasNodeRun = computed(() => {
return Boolean(
node.value &&
workflowRunData.value &&
Object.prototype.hasOwnProperty.bind(workflowRunData.value)(node.value.name),
);
});
// The store already dropped the entries of replaced nodes, so looking up by
// name cannot return a deleted node's data.
const nodeRunData = computed(() =>
node.value ? (workflowRunData.value?.[node.value.name] ?? null) : null,
);
const hasNodeRun = computed(() => nodeRunData.value !== null);
return {
workflowExecution,
workflowRunData,
nodeRunData,
hasNodeRun,
};
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { NodeConnectionTypes, type IRunData } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import RunData from '@/features/ndv/runData/components/RunData.vue';
import RunInfo from '@/features/ndv/runData/components/RunInfo.vue';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
@@ -115,7 +115,9 @@ const workflowObject = computed(() =>
const node = computed(() => {
return ndvStore.value.activeNode ?? undefined;
});
const { hasNodeRun, workflowExecution, workflowRunData } = useExecutionData({ node });
const { hasNodeRun, workflowExecution, workflowRunData, nodeRunData } = useExecutionData({ node });
const { canReveal, isDynamicCredentials, revealData } = useExecutionRedaction();
const isTriggerNode = computed(() => {
@@ -141,13 +143,7 @@ const hasAiMetadata = computed(() => {
return false;
});
const hasError = computed(() =>
Boolean(
workflowRunData.value &&
node.value &&
workflowRunData.value[node.value.name]?.[props.runIndex]?.error,
),
);
const hasError = computed(() => Boolean(nodeRunData.value?.[props.runIndex]?.error));
// Determine the initial output mode to logs if the node has an error and the logs are available
const defaultOutputMode = computed<OutputType>(() => {
@@ -169,36 +165,10 @@ const runTaskData = computed(() => {
return null;
}
const runData = workflowRunData.value;
if (!runData?.hasOwnProperty(node.value.name)) {
return null;
}
if (runData[node.value.name].length <= props.runIndex) {
return null;
}
return runData[node.value.name][props.runIndex];
return nodeRunData.value?.[props.runIndex] ?? null;
});
const runsCount = computed(() => {
if (node.value === null) {
return 0;
}
const runData: IRunData | null = workflowRunData.value;
if (runData === null || (node.value && !runData.hasOwnProperty(node.value.name))) {
return 0;
}
if (node.value && runData[node.value.name].length) {
return runData[node.value.name].length;
}
return 0;
});
const runsCount = computed(() => nodeRunData.value?.length ?? 0);
const staleData = computed(() => {
if (!node.value) {
@@ -26,36 +26,29 @@ const workflowRunData = computed<IRunData | null>(
);
const binaryData = computed<IBinaryData | null>(() => {
const { index, key, node: nodeName, outputIndex, runIndex } = props.displayData;
if (
typeof props.displayData.node !== 'string' ||
typeof props.displayData.key !== 'string' ||
typeof props.displayData.runIndex !== 'number' ||
typeof props.displayData.index !== 'number' ||
typeof props.displayData.outputIndex !== 'number'
typeof nodeName !== 'string' ||
typeof key !== 'string' ||
typeof runIndex !== 'number' ||
typeof index !== 'number' ||
typeof outputIndex !== 'number'
) {
return null;
}
const binaryDataLocal = nodeHelpers.getBinaryData(
workflowRunData.value,
props.displayData.node,
props.displayData.runIndex,
props.displayData.outputIndex,
);
const runDataOfNode = workflowRunData.value?.[nodeName]?.[runIndex]?.data;
const binaryDataLocal = nodeHelpers.getBinaryData(runDataOfNode, outputIndex);
if (binaryDataLocal.length === 0) {
return null;
}
if (
props.displayData.index >= binaryDataLocal.length ||
binaryDataLocal[props.displayData.index][props.displayData.key] === undefined
) {
if (index >= binaryDataLocal.length || binaryDataLocal[index][key] === undefined) {
return null;
}
const binaryDataItem: IBinaryData =
binaryDataLocal[props.displayData.index][props.displayData.key];
const binaryDataItem: IBinaryData = binaryDataLocal[index][key];
return binaryDataItem;
});
@@ -1,6 +1,7 @@
import { reactive, computed } from 'vue';
import {
createTestNode,
createTestWorkflow,
createTestWorkflowObject,
createTestWorkflowExecutionResponse,
defaultNodeDescriptions,
@@ -1216,6 +1217,136 @@ describe('RunData', () => {
});
});
describe('run data ownership', () => {
const erroringRun: ITaskData = {
startTime: Date.now(),
executionIndex: 0,
executionTime: 1,
data: { main: [[{ json: { test: 'data' } }]] },
source: [null],
error: {
level: 'error',
message: 'Test error message',
node: {
name: 'Test Node',
type: SET_NODE_TYPE,
typeVersion: 3,
position: [0, 0],
id: 'executed-node',
parameters: {},
},
timestamp: Date.now(),
functionality: 'regular',
description: null,
context: {},
cause: undefined,
messages: [],
name: 'NodeOperationError',
} as unknown as ITaskData['error'],
};
it('shows the error for the node that recorded it', async () => {
const { getByTestId } = render({
displayMode: 'table',
paneType: 'output',
nodeId: 'executed-node',
executionNodes: [createTestNode({ id: 'executed-node', name: 'Test Node' }) as INodeUi],
runs: [erroringRun],
});
await waitFor(() => {
expect(getByTestId('node-error-view')).toBeInTheDocument();
});
});
it('does not show the error on a different node that reuses the name', async () => {
const { queryByTestId } = render({
displayMode: 'table',
paneType: 'output',
// Same name as the executed node, but a node the execution never ran.
nodeId: 'added-after-the-run',
executionNodes: [createTestNode({ id: 'executed-node', name: 'Test Node' }) as INodeUi],
runs: [erroringRun],
});
await waitFor(() => {
expect(queryByTestId('node-error-view')).not.toBeInTheDocument();
});
});
it('falls back to the name when the execution recorded no nodes', async () => {
const { getByTestId } = render({
displayMode: 'table',
paneType: 'output',
nodeId: 'any-id',
// No `executionNodes`: nothing to resolve an id against.
runs: [erroringRun],
});
await waitFor(() => {
expect(getByTestId('node-error-view')).toBeInTheDocument();
});
});
it('falls back to the name for a supplied historical execution', async () => {
const { getByTestId } = render({
displayMode: 'table',
paneType: 'output',
nodeId: 'added-after-the-run',
executionNodes: [createTestNode({ id: 'executed-node', name: 'Test Node' }) as INodeUi],
workflowExecutionProp: createRunExecutionData({
resultData: { runData: { 'Test Node': [erroringRun] } },
}),
});
await waitFor(() => {
expect(getByTestId('node-error-view')).toBeInTheDocument();
});
});
const binaryItems: INodeExecutionData[] = [
{
json: {},
binary: {
data: {
fileName: 'test.pdf',
fileType: 'pdf',
mimeType: 'application/pdf',
data: '',
},
},
},
];
it('shows the binary data for the node that recorded it', async () => {
const { getByTestId } = render({
displayMode: 'binary',
paneType: 'output',
nodeId: 'executed-node',
executionNodes: [createTestNode({ id: 'executed-node', name: 'Test Node' }) as INodeUi],
defaultRunItems: binaryItems,
});
await waitFor(() => {
expect(getByTestId('ndv-binary-data_0')).toBeInTheDocument();
});
});
it('does not show the binary data on a different node that reuses the name', async () => {
const { queryByTestId } = render({
displayMode: 'binary',
paneType: 'output',
nodeId: 'added-after-the-run',
executionNodes: [createTestNode({ id: 'executed-node', name: 'Test Node' }) as INodeUi],
defaultRunItems: binaryItems,
});
await waitFor(() => {
expect(queryByTestId('ndv-binary-data_0')).not.toBeInTheDocument();
});
});
});
describe('schema view with mixed execution states', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -1622,6 +1753,8 @@ describe('RunData', () => {
nodeTypeHints,
withRunData = true,
workflowExecutionProp,
nodeId,
executionNodes,
}: {
defaultRunItems?: INodeExecutionData[];
workflowId?: string;
@@ -1638,6 +1771,10 @@ describe('RunData', () => {
withRunData?: boolean;
/** Supplied historical execution data, as standalone hosts pass it. */
workflowExecutionProp?: IRunExecutionData;
/** Id of the node under test, to make it differ from the executed one. */
nodeId?: string;
/** Nodes the execution recorded running. Omit for an execution with no snapshot. */
executionNodes?: INodeUi[];
lastSuccessfulExecution?: {
id: string;
finished: boolean;
@@ -1707,6 +1844,7 @@ describe('RunData', () => {
createTestWorkflowExecutionResponse({
mode: 'trigger',
status: executionStatus ?? 'success',
...(executionNodes ? { workflowData: createTestWorkflow({ nodes: executionNodes }) } : {}),
data: createRunExecutionData({
resultData: {
runData: withRunData ? { 'Test Node': runs ?? [defaultRun] } : {},
@@ -1730,9 +1868,6 @@ describe('RunData', () => {
return createComponentRenderer(RunData, {
props: {
node: createTestNode({
name: 'Test Node',
}),
workflowObject: createTestWorkflowObject({
id: workflowId,
nodes: workflowNodes,
@@ -1781,7 +1916,7 @@ describe('RunData', () => {
})({
props: {
node: createTestNode({
id: '1',
id: nodeId ?? '1',
name: 'Test Node',
type: SET_NODE_TYPE,
position: [0, 0],
@@ -270,9 +270,7 @@ const isArchivedWorkflow = computed(() => workflowDocumentStore?.value?.isArchiv
const isReadOnlyRoute = computed(() => route.meta.readOnlyCanvas === true);
const isWaitNodeWaiting = computed(() => {
return (
node.value?.name &&
workflowExecution.value?.resultData?.runData?.[node.value?.name]?.[props.runIndex]
?.executionStatus === 'waiting'
node.value?.name && currentNodeRunData.value?.[props.runIndex]?.executionStatus === 'waiting'
);
});
@@ -325,9 +323,29 @@ const shouldShowSchemaView = computed(() => {
);
});
const hasExecutionNodeSnapshot = computed(
() => (currentExecution.value?.workflowData?.nodes?.length ?? 0) > 0,
);
// Helper: Get run data for current node (returns null if not available)
const currentNodeRunData = computed(() => {
if (!node.value || !workflowRunData.value) return null;
if (!node.value) {
return null;
}
// Only the active execution with a node snapshot can be resolved by id.
if (props.workflowExecution === undefined && hasExecutionNodeSnapshot.value) {
return (
workflowExecutionStateStore.value.activeExecutionRunDataByNodeId.get(node.value.id)?.value ??
null
);
}
if (!workflowRunData.value) {
return null;
}
// try by name otherwise
const nodeName = node.value.name;
return workflowRunData.value.hasOwnProperty(nodeName) ? workflowRunData.value[nodeName] : null;
});
@@ -378,8 +396,7 @@ const hasNodeRun = computed(() =>
Boolean(
!props.isExecuting &&
node.value &&
((workflowRunData.value && workflowRunData.value.hasOwnProperty(node.value.name)) ||
pinnedData.hasData.value),
(currentNodeRunData.value !== null || pinnedData.hasData.value),
),
);
@@ -411,9 +428,11 @@ const parentNodeError = computed(() => {
});
const workflowRunErrorAsNodeError = computed(() => {
if (!node.value) return null;
if (!node.value) {
return null;
}
const selfTaskData = workflowRunData.value?.[node.value.name]?.[props.runIndex];
const selfTaskData = currentNodeRunData.value?.[props.runIndex];
if (!selfTaskData && isSubNodeType.value && isPaneTypeInput.value) {
return parentNodeError.value;
@@ -423,7 +442,7 @@ const workflowRunErrorAsNodeError = computed(() => {
const hasRedactedError = computed(() => {
if (!node.value) return false;
const selfTaskData = workflowRunData.value?.[node.value.name]?.[props.runIndex];
const selfTaskData = currentNodeRunData.value?.[props.runIndex];
return !!selfTaskData?.redactedError;
});
@@ -436,7 +455,7 @@ const hasRunError = computed(
const executionHints = computed(() => {
if (hasNodeRun.value) {
const hints = node.value && workflowRunData.value?.[node.value.name]?.[props.runIndex]?.hints;
const hints = node.value && currentNodeRunData.value?.[props.runIndex]?.hints;
if (hints) return hints;
}
@@ -527,12 +546,10 @@ const inputDataPage = computed(() => {
});
const jsonData = computed(() => executionDataToJson(inputData.value));
const binaryData = computed(() => {
if (!node.value) {
return [];
}
const runDataOfNode = currentNodeRunData.value?.[props.runIndex]?.data;
return nodeHelpers
.getBinaryData(workflowRunData.value, node.value.name, props.runIndex, currentOutputIndex.value)
.getBinaryData(runDataOfNode, currentOutputIndex.value)
.filter((data) => Boolean(data && Object.keys(data).length));
});
const inputHtml = computed(() => String(inputData.value[0]?.json?.html ?? ''));
@@ -675,7 +692,7 @@ const activeTaskMetadata = computed((): ITaskMetadata | null => {
}
}
return workflowRunData.value?.[node.value.name]?.[props.runIndex]?.metadata ?? null;
return currentNodeRunData.value?.[props.runIndex]?.metadata ?? null;
});
const hasInputOverwrite = computed((): boolean => {
@@ -820,7 +837,7 @@ onMounted(() => {
}
if (hasRunError.value && node.value) {
const error = workflowRunData.value?.[node.value.name]?.[props.runIndex]?.error;
const error = currentNodeRunData.value?.[props.runIndex]?.error;
const errorsToTrack = ['unknown error'];
if (error && errorsToTrack.some((e) => error.message?.toLowerCase().includes(e))) {
@@ -900,8 +917,7 @@ const nodeHints = computed<NodeHint[]>(() => {
const hasMultipleInputItems =
parentNodeOutputData.value.length > 1 || parentNodePinnedData.value.length > 1;
const nodeOutputData =
workflowRunData.value?.[node.value.name]?.[props.runIndex]?.data?.main?.[0] ?? [];
const nodeOutputData = currentNodeRunData.value?.[props.runIndex]?.data?.main?.[0] ?? [];
const genericHints = getGenericHints({
workflowNode,
@@ -1273,7 +1289,7 @@ function getRunLabel(option: number) {
interpolate: { count: itemsCount },
});
const metadata = workflowRunData.value?.[node.value.name]?.[option - 1]?.metadata ?? null;
const metadata = currentNodeRunData.value?.[option - 1]?.metadata ?? null;
const subexecutions = metadata?.subExecutionsCount
? i18n.baseText('ndv.output.andSubExecutions', {
adjustToNumber: metadata.subExecutionsCount,
@@ -1345,7 +1361,7 @@ function getDataCount(
return 0;
}
if (workflowRunData.value?.[node.value.name]?.[runIndex]?.hasOwnProperty('error')) {
if (currentNodeRunData.value?.[runIndex]?.hasOwnProperty('error')) {
return 1;
}
@@ -72,15 +72,16 @@ export function useTypescript(
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
const schema = getSchemaForExecutionData(executionDataToJson(inputData), true);
const execution = workflowExecutionStateStore.value.activeExecution;
const runIndex =
toValue(targetNodeParameterContext) === undefined
? (ndvStore.value.ndvInputRunIndex ?? 0)
: 0;
const runDataOfNode = execution?.data?.resultData?.runData?.[node.name]?.[runIndex]?.data;
const binaryData = useNodeHelpers()
.getBinaryData(
execution?.data?.resultData?.runData ?? null,
node.name,
toValue(targetNodeParameterContext) === undefined
? (ndvStore.value.ndvInputRunIndex ?? 0)
: 0,
0,
)
.getBinaryData(runDataOfNode, 0)
.filter((data) => Boolean(data && Object.keys(data).length));
return {
@@ -109,6 +109,8 @@ export function createEmptyCanvasRenderData(
validationErrorsByNodeId: shallowReactive(new Map()),
executionIssuesByNodeName: shallowReactive(new Map()),
executionPinDataByNodeName: {},
executionIssuesByNodeId: shallowReactive(new Map()),
executionPinDataByNodeId: shallowReactive(new Map()),
isExecutionDataDisplayed: false,
executionStatusByNodeId: shallowReactive(new Map()),
executionRunDataByNodeId: shallowReactive(new Map()),
@@ -31,7 +31,7 @@ vi.mock('vue-router', async (importOriginal) => {
const renderNodeInputsMap = new Map<string, ComputedRef<CanvasConnectionPort[]>>();
const renderNodeOutputsMap = new Map<string, ComputedRef<CanvasConnectionPort[]>>();
const pinnedDataByNodeName: IPinData = {};
const executionPinDataByNodeName: IPinData = {};
const executionPinDataByNodeId = new Map<string, ComputedRef<IPinData[string] | undefined>>();
let isExecutionDataDisplayed = false;
vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => {
@@ -43,7 +43,7 @@ vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => {
nodeInputsByNodeId: renderNodeInputsMap,
nodeOutputsByNodeId: renderNodeOutputsMap,
pinnedDataByNodeName,
executionPinDataByNodeName,
executionPinDataByNodeId,
isExecutionDataDisplayed,
}),
})),
@@ -83,9 +83,7 @@ beforeEach(() => {
for (const key of Object.keys(pinnedDataByNodeName)) {
delete pinnedDataByNodeName[key];
}
for (const key of Object.keys(executionPinDataByNodeName)) {
delete executionPinDataByNodeName[key];
}
executionPinDataByNodeId.clear();
isExecutionDataDisplayed = false;
const pinia = createTestingPinia();
setActivePinia(pinia);
@@ -343,7 +341,10 @@ describe('CanvasNodeDefault', () => {
describe('execution pin data', () => {
it('should apply pinned styling instead of success styling when node output used execution pin data', () => {
executionPinDataByNodeName['Test Node'] = [{ json: { ok: true } }];
executionPinDataByNodeId.set(
'node',
computed(() => [{ json: { ok: true } }]),
);
isExecutionDataDisplayed = true;
const { getByText } = renderComponent({
@@ -389,7 +390,10 @@ describe('CanvasNodeDefault', () => {
});
it('should ignore execution pin data outside execution preview mode', () => {
executionPinDataByNodeName['Test Node'] = [{ json: { ok: true } }];
executionPinDataByNodeId.set(
'node',
computed(() => [{ json: { ok: true } }]),
);
const { getByText } = renderComponent({
global: {
@@ -55,7 +55,7 @@ const renderData = injectCanvasRenderData();
const inputs = computed(() => renderData.value.nodeInputsByNodeId.get(id.value)?.value ?? []);
const outputs = computed(() => renderData.value.nodeOutputsByNodeId.get(id.value)?.value ?? []);
const hasExecutionErrors = computed(
() => (renderData.value.executionIssuesByNodeName.get(name.value)?.value?.length ?? 0) > 0,
() => (renderData.value.executionIssuesByNodeId.get(id.value)?.value?.length ?? 0) > 0,
);
const hasPinnedData = computed(
() =>
@@ -65,7 +65,7 @@ const hasPinnedData = computed(
const hasExecutionPinData = computed(
() =>
renderData.value.isExecutionDataDisplayed &&
!!renderData.value.executionPinDataByNodeName[name.value],
!!renderData.value.executionPinDataByNodeId.get(id.value)?.value,
);
const hasSubstitutedOutput = computed(() => hasPinnedData.value || hasExecutionPinData.value);
const { mainOutputs, mainOutputConnections, mainInputs, mainInputConnections, nonMainInputs } =
@@ -8,6 +8,7 @@ import { VIEWS } from '@/app/constants';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { CanvasNodeDirtiness, CanvasNodeRenderType } from '../../../../../canvas.types';
import { createTestingPinia } from '@pinia/testing';
import { computed, type ComputedRef } from 'vue';
import type { IPinData } from 'n8n-workflow';
import type * as actualVueRouter from 'vue-router';
import { type RouteLocationNormalizedLoadedGeneric, useRoute } from 'vue-router';
@@ -22,7 +23,7 @@ vi.mock('vue-router', async (importOriginal) => {
});
const pinnedDataByNodeName: IPinData = {};
const executionPinDataByNodeName: IPinData = {};
const executionPinDataByNodeId = new Map<string, ComputedRef<IPinData[string] | undefined>>();
let isExecutionDataDisplayed = false;
vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => {
@@ -32,7 +33,7 @@ vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => {
injectCanvasRenderData: vi.fn(() => ({
value: actual.createEmptyCanvasRenderData({
pinnedDataByNodeName,
executionPinDataByNodeName,
executionPinDataByNodeId,
isExecutionDataDisplayed,
}),
})),
@@ -54,9 +55,7 @@ describe('CanvasNodeStatusIcons', () => {
for (const key of Object.keys(pinnedDataByNodeName)) {
delete pinnedDataByNodeName[key];
}
for (const key of Object.keys(executionPinDataByNodeName)) {
delete executionPinDataByNodeName[key];
}
executionPinDataByNodeId.clear();
isExecutionDataDisplayed = false;
});
@@ -93,7 +92,10 @@ describe('CanvasNodeStatusIcons', () => {
});
it('should render the pinned icon for a node with execution pin data', () => {
executionPinDataByNodeName['Test Node'] = [{ json: { key: 'value' } }];
executionPinDataByNodeId.set(
'node',
computed(() => [{ json: { key: 'value' } }]),
);
isExecutionDataDisplayed = true;
const { getByTestId } = renderComponent({
@@ -114,7 +116,10 @@ describe('CanvasNodeStatusIcons', () => {
});
it('should not render the pinned icon for execution pin data outside execution preview mode', () => {
executionPinDataByNodeName['Test Node'] = [{ json: { key: 'value' } }];
executionPinDataByNodeId.set(
'node',
computed(() => [{ json: { key: 'value' } }]),
);
const { queryByTestId, getByTestId } = renderComponent({
global: {
@@ -157,7 +162,10 @@ describe('CanvasNodeStatusIcons', () => {
});
it('should use the pinned icon when both workflow and execution pin data are present', () => {
executionPinDataByNodeName['Test Node'] = [{ json: { source: 'execution' } }];
executionPinDataByNodeId.set(
'node',
computed(() => [{ json: { source: 'execution' } }]),
);
pinnedDataByNodeName['Test Node'] = [{ json: { key: 'value' } }];
const { getByTestId } = renderComponent({
@@ -173,7 +181,10 @@ describe('CanvasNodeStatusIcons', () => {
});
it('should keep validation issues ahead of execution pin data', () => {
executionPinDataByNodeName['Test Node'] = [{ json: { key: 'value' } }];
executionPinDataByNodeId.set(
'node',
computed(() => [{ json: { key: 'value' } }]),
);
const { getByTestId, queryByTestId } = renderComponent({
global: {
@@ -26,6 +26,7 @@ const i18n = useI18n();
const $style = useCssModule();
const {
id,
name,
validationErrors,
hasValidationErrors,
@@ -38,7 +39,7 @@ const {
} = useCanvasNode();
const renderData = injectCanvasRenderData();
const executionErrors = computed(
() => renderData.value.executionIssuesByNodeName.get(name.value)?.value ?? [],
() => renderData.value.executionIssuesByNodeId.get(id.value)?.value ?? [],
);
const hasExecutionErrors = computed(() => executionErrors.value.length > 0);
const hasPinnedData = computed(
@@ -49,7 +50,7 @@ const hasPinnedData = computed(
const hasExecutionPinData = computed(
() =>
renderData.value.isExecutionDataDisplayed &&
!!renderData.value.executionPinDataByNodeName[name.value],
!!renderData.value.executionPinDataByNodeId.get(id.value)?.value,
);
const hasVisiblePinData = computed(() => hasPinnedData.value || hasExecutionPinData.value);
const route = useRoute();
@@ -317,11 +317,11 @@ describe('useCanvasMapping — node display sizes', () => {
});
describe('useCanvasMapping — getNodeExecutionSnapshot', () => {
it('reads hasExecutionError from executionIssuesByNodeName (single-node parity)', () => {
it('reads hasExecutionError from executionIssuesByNodeId (single-node parity)', () => {
const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi;
const rd = createEmptyCanvasRenderData();
rd.executionIssuesByNodeName.set(
'Alpha',
rd.executionIssuesByNodeId.set(
'a',
computed(() => ['Boom']),
);
@@ -385,6 +385,49 @@ describe('useCanvasMapping — getNodeExecutionSnapshot', () => {
expect(getNodeExecutionSnapshot('a').hasExecutionError).toBe(true);
});
describe('iterations', () => {
function getIterations(tasks: ITaskData[] | null | undefined) {
const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi;
const rd = createEmptyCanvasRenderData();
if (tasks !== undefined) {
setRunData(rd, 'a', tasks);
}
const { getNodeExecutionSnapshot } = useCanvasMapping({
nodes: ref([node]),
connections: ref({}),
renderData: shallowRef(rd),
});
return getNodeExecutionSnapshot('a').iterations;
}
function task(executionStatus: ITaskData['executionStatus']) {
return { executionStatus } as ITaskData;
}
it('is 0 when the node has no run data', () => {
expect(getIterations(undefined)).toBe(0);
expect(getIterations(null)).toBe(0);
});
it('is 0 for an empty task list', () => {
expect(getIterations([])).toBe(0);
});
it('counts every task when none was canceled', () => {
expect(getIterations([task('success'), task('success'), task('error')])).toBe(3);
});
it('is 0 when every task was canceled', () => {
expect(getIterations([task('canceled'), task('canceled'), task('canceled')])).toBe(0);
});
it('skips only the canceled tasks', () => {
expect(getIterations([task('success'), task('canceled'), task('error')])).toBe(2);
});
});
});
describe('useCanvasMapping — mapped connections', () => {
@@ -474,7 +517,10 @@ describe('useCanvasMapping — mapped connections', () => {
Alpha: { main: [[{ node: 'Beta', type: 'main', index: 0 }]] },
});
const rd = createEmptyCanvasRenderData({ isExecutionDataDisplayed: true });
rd.executionPinDataByNodeName.Alpha = [{ json: { ok: true } }];
rd.executionPinDataByNodeId.set(
'a',
computed(() => [{ json: { ok: true } }]),
);
setRunData(rd, 'a', [{ executionStatus: 'success' } as ITaskData]);
const { connections: mapped } = useCanvasMapping({
@@ -65,32 +65,19 @@ export function useCanvasMapping({
}) {
const i18n = useI18n();
// `executionIssuesByNodeName` is keyed by name; groups address nodes by id.
const nodeNameById = computed(() => {
const map = new Map<string, string>();
for (const node of nodes.value) map.set(node.id, node.name);
return map;
});
function countNonCanceledIterations(tasks: ITaskData[] | null | undefined): number {
if (!tasks) return 0;
let count = 0;
for (const task of tasks) {
if (task.executionStatus !== 'canceled') count++;
}
return count;
return tasks?.filter((task) => task.executionStatus !== 'canceled').length ?? 0;
}
// Per-node execution projection feeding the group-status aggregation.
function getNodeExecutionSnapshot(id: string): NodeExecutionSnapshot {
const rd = renderData.value;
const render = rd.renderTypeByNodeId.get(id)?.value;
const name = nodeNameById.value.get(id);
const status = rd.executionStatusByNodeId.get(id)?.value;
const tasks = rd.executionRunDataByNodeId.get(id)?.value;
// Mirror the single-node `computeHasIssues`
const executionIssues = name ? rd.executionIssuesByNodeName.get(name)?.value : undefined;
const executionIssues = rd.executionIssuesByNodeId.get(id)?.value;
const hasExecutionError =
status === 'error' ||
status === 'crashed' ||
@@ -145,9 +132,8 @@ export function useCanvasMapping({
function getVisiblePinDataByNodeId(id: string) {
const rd = renderData.value;
const nodeName = nodeNameById.value.get(id);
if (rd.isExecutionDataDisplayed) {
return nodeName ? rd.executionPinDataByNodeName[nodeName] : undefined;
return rd.executionPinDataByNodeId.get(id)?.value;
}
return rd.pinnedDataByNodeId.get(id)?.value;
}
@@ -0,0 +1,31 @@
import { CODE_NODE_DISPLAY_NAME, CODE_NODE_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
test.describe(
'ADO-5808 Node error reappears on replacing with different node',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test('should not show the deleted node execution data on a new node reusing its name', async ({
n8n,
}) => {
await n8n.start.fromImportedWorkflow('ADO-5808-erroring-code-node.json');
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getNodeIssuesByName(CODE_NODE_DISPLAY_NAME)).toBeVisible();
await n8n.canvas.deleteNodeByName(CODE_NODE_DISPLAY_NAME);
// The freed name is handed back to the replacement node, which must not
// inherit the deleted node's run data.
await n8n.canvas.addNode(CODE_NODE_NAME, { action: CODE_NODE_DISPLAY_NAME });
await n8n.ndv.close();
await expect(n8n.canvas.getNodeIssuesByName(CODE_NODE_DISPLAY_NAME)).toBeHidden();
await n8n.canvas.openNode(CODE_NODE_DISPLAY_NAME);
await expect(n8n.ndv.getNodeRunErrorMessage()).toBeHidden();
});
},
);
@@ -0,0 +1,42 @@
{
"name": "ADO-5808 erroring code node",
"nodes": [
{
"parameters": {},
"id": "0f3a1c0e-6d21-4a1e-9c4f-2b1d7e5a9c11",
"name": "When clicking 'Execute workflow'",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [220, 260]
},
{
"parameters": {
"jsCode": "throw new Error('boom');"
},
"id": "7c9d2b44-51ae-4f0c-8b3d-6e2f4a8c1d02",
"name": "Code in JavaScript",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [440, 260]
}
],
"pinData": {},
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "Code in JavaScript",
"type": "main",
"index": 0
}
]
]
},
"Code in JavaScript": {
"main": [[]]
}
},
"active": false,
"settings": {}
}