mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
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:
committed by
GitHub
parent
662a64de83
commit
8bf95ac2b6
@@ -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),
|
||||
|
||||
+14
-4
@@ -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,
|
||||
|
||||
+97
@@ -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);
|
||||
});
|
||||
});
|
||||
+8
-7
@@ -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) {
|
||||
|
||||
+10
-17
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+9
-8
@@ -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()),
|
||||
|
||||
+11
-7
@@ -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: {
|
||||
|
||||
+2
-2
@@ -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 } =
|
||||
|
||||
+20
-9
@@ -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: {
|
||||
|
||||
+3
-2
@@ -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();
|
||||
|
||||
+50
-4
@@ -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({
|
||||
|
||||
+3
-17
@@ -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;
|
||||
}
|
||||
|
||||
+31
@@ -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": {}
|
||||
}
|
||||
Reference in New Issue
Block a user