mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 21:07:28 -08:00
feat(core): Use node IDs instead of names for data mocking during test runs (no-changelog) (#12348)
This commit is contained in:
parent
1d5c9bd466
commit
04c1182a1e
|
@ -9,7 +9,8 @@ import { jsonColumnType, WithTimestampsAndStringId } from './abstract-entity';
|
||||||
|
|
||||||
// Entity representing a node in a workflow under test, for which data should be mocked during test execution
|
// Entity representing a node in a workflow under test, for which data should be mocked during test execution
|
||||||
export type MockedNodeItem = {
|
export type MockedNodeItem = {
|
||||||
name: string;
|
name?: string;
|
||||||
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -16,6 +16,6 @@ export const testDefinitionPatchRequestBodySchema = z
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
evaluationWorkflowId: z.string().min(1).optional(),
|
evaluationWorkflowId: z.string().min(1).optional(),
|
||||||
annotationTagId: z.string().min(1).optional(),
|
annotationTagId: z.string().min(1).optional(),
|
||||||
mockedNodes: z.array(z.object({ name: z.string() })).optional(),
|
mockedNodes: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
|
@ -121,13 +121,26 @@ export class TestDefinitionService {
|
||||||
relations: ['workflow'],
|
relations: ['workflow'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingNodeNames = new Set(existingTestDefinition.workflow.nodes.map((n) => n.name));
|
const existingNodeNames = new Map(
|
||||||
|
existingTestDefinition.workflow.nodes.map((n) => [n.name, n]),
|
||||||
|
);
|
||||||
|
const existingNodeIds = new Map(existingTestDefinition.workflow.nodes.map((n) => [n.id, n]));
|
||||||
|
|
||||||
attrs.mockedNodes.forEach((node) => {
|
attrs.mockedNodes.forEach((node) => {
|
||||||
if (!existingNodeNames.has(node.name)) {
|
if (!existingNodeIds.has(node.id) || (node.name && !existingNodeNames.has(node.name))) {
|
||||||
throw new BadRequestError(`Pinned node not found in the workflow: ${node.name}`);
|
throw new BadRequestError(
|
||||||
|
`Pinned node not found in the workflow: ${node.id} (${node.name})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update the node names OR node ids if they are not provided
|
||||||
|
attrs.mockedNodes = attrs.mockedNodes.map((node) => {
|
||||||
|
return {
|
||||||
|
id: node.id ?? (node.name && existingNodeNames.get(node.name)?.id),
|
||||||
|
name: node.name ?? (node.id && existingNodeIds.get(node.id)?.name),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the test definition
|
// Update the test definition
|
||||||
|
|
|
@ -7,13 +7,24 @@ const wfUnderTestJson = JSON.parse(
|
||||||
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
|
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const wfUnderTestRenamedNodesJson = JSON.parse(
|
||||||
|
readFileSync(path.join(__dirname, './mock-data/workflow.under-test-renamed-nodes.json'), {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const executionDataJson = JSON.parse(
|
const executionDataJson = JSON.parse(
|
||||||
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
|
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('createPinData', () => {
|
describe('createPinData', () => {
|
||||||
test('should create pin data from past execution data', () => {
|
test('should create pin data from past execution data', () => {
|
||||||
const mockedNodes = ['When clicking ‘Test workflow’'].map((name) => ({ name }));
|
const mockedNodes = [
|
||||||
|
{
|
||||||
|
id: '72256d90-3a67-4e29-b032-47df4e5768af',
|
||||||
|
name: 'When clicking ‘Test workflow’',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
|
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
|
||||||
|
|
||||||
|
@ -25,7 +36,7 @@ describe('createPinData', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not create pin data for non-existing mocked nodes', () => {
|
test('should not create pin data for non-existing mocked nodes', () => {
|
||||||
const mockedNodes = ['Non-existing node'].map((name) => ({ name }));
|
const mockedNodes = ['non-existing-ID'].map((id) => ({ id }));
|
||||||
|
|
||||||
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
|
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
|
||||||
|
|
||||||
|
@ -33,9 +44,17 @@ describe('createPinData', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create pin data for all mocked nodes', () => {
|
test('should create pin data for all mocked nodes', () => {
|
||||||
const mockedNodes = ['When clicking ‘Test workflow’', 'Edit Fields', 'Code'].map((name) => ({
|
const mockedNodes = [
|
||||||
name,
|
{
|
||||||
}));
|
id: '72256d90-3a67-4e29-b032-47df4e5768af', // 'When clicking ‘Test workflow’'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '319f29bc-1dd4-4122-b223-c584752151a4', // 'Edit Fields'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'd2474215-63af-40a4-a51e-0ea30d762621', // 'Code'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
|
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
|
||||||
|
|
||||||
|
@ -53,4 +72,33 @@ describe('createPinData', () => {
|
||||||
|
|
||||||
expect(pinData).toEqual({});
|
expect(pinData).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should create pin data for all mocked nodes with renamed nodes', () => {
|
||||||
|
const mockedNodes = [
|
||||||
|
{
|
||||||
|
id: '72256d90-3a67-4e29-b032-47df4e5768af', // 'Manual Run'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '319f29bc-1dd4-4122-b223-c584752151a4', // 'Set Attribute'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'd2474215-63af-40a4-a51e-0ea30d762621', // 'Code'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pinData = createPinData(
|
||||||
|
wfUnderTestRenamedNodesJson,
|
||||||
|
mockedNodes,
|
||||||
|
executionDataJson,
|
||||||
|
wfUnderTestJson, // Pass original workflow JSON as pastWorkflowData
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pinData).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
'Manual Run': expect.anything(),
|
||||||
|
'Set Attribute': expect.anything(),
|
||||||
|
Code: expect.anything(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
{
|
||||||
|
"name": "Workflow Under Test",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {},
|
||||||
|
"type": "n8n-nodes-base.manualTrigger",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [-80, 0],
|
||||||
|
"id": "72256d90-3a67-4e29-b032-47df4e5768af",
|
||||||
|
"name": "Manual Run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "acfeecbe-443c-4220-b63b-d44d69216902",
|
||||||
|
"name": "foo",
|
||||||
|
"value": "bar",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [140, 0],
|
||||||
|
"id": "319f29bc-1dd4-4122-b223-c584752151a4",
|
||||||
|
"name": "Set Attribute"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "for (const item of $input.all()) {\n item.json.random = Math.random();\n}\n\nreturn $input.all();"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [380, 0],
|
||||||
|
"id": "d2474215-63af-40a4-a51e-0ea30d762621",
|
||||||
|
"name": "Code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Manual Run": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Set attribute",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Set attribute": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Wait",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Wait": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Code",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -27,6 +27,12 @@ const wfUnderTestJson = JSON.parse(
|
||||||
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
|
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const wfUnderTestRenamedNodesJson = JSON.parse(
|
||||||
|
readFileSync(path.join(__dirname, './mock-data/workflow.under-test-renamed-nodes.json'), {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const wfEvaluationJson = JSON.parse(
|
const wfEvaluationJson = JSON.parse(
|
||||||
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
|
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
|
||||||
);
|
);
|
||||||
|
@ -60,6 +66,7 @@ const executionMocks = [
|
||||||
status: 'success',
|
status: 'success',
|
||||||
executionData: {
|
executionData: {
|
||||||
data: stringify(executionDataJson),
|
data: stringify(executionDataJson),
|
||||||
|
workflowData: wfUnderTestJson,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
mock<ExecutionEntity>({
|
mock<ExecutionEntity>({
|
||||||
|
@ -68,6 +75,7 @@ const executionMocks = [
|
||||||
status: 'success',
|
status: 'success',
|
||||||
executionData: {
|
executionData: {
|
||||||
data: stringify(executionDataJson),
|
data: stringify(executionDataJson),
|
||||||
|
workflowData: wfUnderTestRenamedNodesJson,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
@ -250,7 +258,7 @@ describe('TestRunnerService', () => {
|
||||||
mock<TestDefinition>({
|
mock<TestDefinition>({
|
||||||
workflowId: 'workflow-under-test-id',
|
workflowId: 'workflow-under-test-id',
|
||||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||||
mockedNodes: [{ name: 'When clicking ‘Test workflow’' }],
|
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -347,7 +355,7 @@ describe('TestRunnerService', () => {
|
||||||
mock<TestDefinition>({
|
mock<TestDefinition>({
|
||||||
workflowId: 'workflow-under-test-id',
|
workflowId: 'workflow-under-test-id',
|
||||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||||
mockedNodes: [{ name: 'When clicking ‘Test workflow’' }],
|
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import { parse } from 'flatted';
|
import { parse } from 'flatted';
|
||||||
|
import { NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
IDataObject,
|
IDataObject,
|
||||||
IRun,
|
IRun,
|
||||||
IRunData,
|
IRunData,
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
|
IWorkflowBase,
|
||||||
IWorkflowExecutionDataProcess,
|
IWorkflowExecutionDataProcess,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionType, Workflow } from 'n8n-workflow';
|
|
||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
|
|
||||||
import { ActiveExecutions } from '@/active-executions';
|
import { ActiveExecutions } from '@/active-executions';
|
||||||
|
@ -94,11 +95,17 @@ export class TestRunnerService {
|
||||||
private async runTestCase(
|
private async runTestCase(
|
||||||
workflow: WorkflowEntity,
|
workflow: WorkflowEntity,
|
||||||
pastExecutionData: IRunExecutionData,
|
pastExecutionData: IRunExecutionData,
|
||||||
|
pastExecutionWorkflowData: IWorkflowBase,
|
||||||
mockedNodes: MockedNodeItem[],
|
mockedNodes: MockedNodeItem[],
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<IRun | undefined> {
|
): Promise<IRun | undefined> {
|
||||||
// Create pin data from the past execution data
|
// Create pin data from the past execution data
|
||||||
const pinData = createPinData(workflow, mockedNodes, pastExecutionData);
|
const pinData = createPinData(
|
||||||
|
workflow,
|
||||||
|
mockedNodes,
|
||||||
|
pastExecutionData,
|
||||||
|
pastExecutionWorkflowData,
|
||||||
|
);
|
||||||
|
|
||||||
// Prepare the data to run the workflow
|
// Prepare the data to run the workflow
|
||||||
const data: IWorkflowExecutionDataProcess = {
|
const data: IWorkflowExecutionDataProcess = {
|
||||||
|
@ -235,6 +242,7 @@ export class TestRunnerService {
|
||||||
const testCaseExecution = await this.runTestCase(
|
const testCaseExecution = await this.runTestCase(
|
||||||
workflow,
|
workflow,
|
||||||
executionData,
|
executionData,
|
||||||
|
pastExecution.executionData.workflowData,
|
||||||
test.mockedNodes,
|
test.mockedNodes,
|
||||||
user.id,
|
user.id,
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { IRunExecutionData, IPinData } from 'n8n-workflow';
|
import assert from 'assert';
|
||||||
|
import type { IRunExecutionData, IPinData, IWorkflowBase } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { MockedNodeItem } from '@/databases/entities/test-definition.ee';
|
import type { MockedNodeItem } from '@/databases/entities/test-definition.ee';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
|
@ -13,16 +14,33 @@ export function createPinData(
|
||||||
workflow: WorkflowEntity,
|
workflow: WorkflowEntity,
|
||||||
mockedNodes: MockedNodeItem[],
|
mockedNodes: MockedNodeItem[],
|
||||||
executionData: IRunExecutionData,
|
executionData: IRunExecutionData,
|
||||||
|
pastWorkflowData?: IWorkflowBase,
|
||||||
) {
|
) {
|
||||||
const pinData = {} as IPinData;
|
const pinData = {} as IPinData;
|
||||||
|
|
||||||
const workflowNodeNames = new Set(workflow.nodes.map((node) => node.name));
|
const workflowNodeIds = new Map(workflow.nodes.map((node) => [node.id, node.name]));
|
||||||
|
|
||||||
|
// If the past workflow data is provided, use it to create a map between node IDs and node names
|
||||||
|
const pastWorkflowNodeIds = new Map<string, string>();
|
||||||
|
if (pastWorkflowData) {
|
||||||
|
for (const node of pastWorkflowData.nodes) {
|
||||||
|
pastWorkflowNodeIds.set(node.id, node.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const mockedNode of mockedNodes) {
|
for (const mockedNode of mockedNodes) {
|
||||||
if (workflowNodeNames.has(mockedNode.name)) {
|
assert(mockedNode.id, 'Mocked node ID is missing');
|
||||||
const nodeData = executionData.resultData.runData[mockedNode.name];
|
|
||||||
|
const nodeName = workflowNodeIds.get(mockedNode.id);
|
||||||
|
|
||||||
|
// If mocked node is still present in the workflow
|
||||||
|
if (nodeName) {
|
||||||
|
// Try to restore node name from past execution data (it might have been renamed between past execution and up-to-date workflow)
|
||||||
|
const pastNodeName = pastWorkflowNodeIds.get(mockedNode.id) ?? nodeName;
|
||||||
|
const nodeData = executionData.resultData.runData[pastNodeName];
|
||||||
|
|
||||||
if (nodeData?.[0]?.data?.main?.[0]) {
|
if (nodeData?.[0]?.data?.main?.[0]) {
|
||||||
pinData[mockedNode.name] = nodeData[0]?.data?.main?.[0];
|
pinData[nodeName] = nodeData[0]?.data?.main?.[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -405,13 +405,14 @@ describe('PATCH /evaluation/test-definitions/:id', () => {
|
||||||
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
mockedNodes: [
|
mockedNodes: [
|
||||||
{
|
{
|
||||||
|
id: 'uuid-1234',
|
||||||
name: 'Schedule Trigger',
|
name: 'Schedule Trigger',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resp.statusCode).toBe(200);
|
expect(resp.statusCode).toBe(200);
|
||||||
expect(resp.body.data.mockedNodes).toEqual([{ name: 'Schedule Trigger' }]);
|
expect(resp.body.data.mockedNodes).toEqual([{ id: 'uuid-1234', name: 'Schedule Trigger' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return error if pinned nodes are invalid', async () => {
|
test('should return error if pinned nodes are invalid', async () => {
|
||||||
|
|
Loading…
Reference in a new issue