mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(core): Initial TestRunner service with basic test execution (no-changelog) (#11735)
This commit is contained in:
parent
6b23ad0c12
commit
845ba6c917
|
@ -163,7 +163,13 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
if (!queryParams.relations) {
|
if (!queryParams.relations) {
|
||||||
queryParams.relations = [];
|
queryParams.relations = [];
|
||||||
}
|
}
|
||||||
(queryParams.relations as string[]).push('executionData', 'metadata');
|
|
||||||
|
if (Array.isArray(queryParams.relations)) {
|
||||||
|
queryParams.relations.push('executionData', 'metadata');
|
||||||
|
} else {
|
||||||
|
queryParams.relations.executionData = true;
|
||||||
|
queryParams.relations.metadata = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const executions = await this.find(queryParams);
|
const executions = await this.find(queryParams);
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
testDefinitionCreateRequestBodySchema,
|
testDefinitionCreateRequestBodySchema,
|
||||||
testDefinitionPatchRequestBodySchema,
|
testDefinitionPatchRequestBodySchema,
|
||||||
} from '@/evaluation/test-definition.schema';
|
} from '@/evaluation/test-definition.schema';
|
||||||
|
import { TestRunnerService } from '@/evaluation/test-runner/test-runner.service.ee';
|
||||||
import { listQueryMiddleware } from '@/middlewares';
|
import { listQueryMiddleware } from '@/middlewares';
|
||||||
import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service';
|
import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service';
|
||||||
|
|
||||||
|
@ -16,7 +17,10 @@ import { TestDefinitionsRequest } from './test-definitions.types.ee';
|
||||||
|
|
||||||
@RestController('/evaluation/test-definitions')
|
@RestController('/evaluation/test-definitions')
|
||||||
export class TestDefinitionsController {
|
export class TestDefinitionsController {
|
||||||
constructor(private readonly testDefinitionService: TestDefinitionService) {}
|
constructor(
|
||||||
|
private readonly testDefinitionService: TestDefinitionService,
|
||||||
|
private readonly testRunnerService: TestRunnerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('/', { middlewares: listQueryMiddleware })
|
@Get('/', { middlewares: listQueryMiddleware })
|
||||||
async getMany(req: TestDefinitionsRequest.GetMany) {
|
async getMany(req: TestDefinitionsRequest.GetMany) {
|
||||||
|
@ -125,4 +129,20 @@ export class TestDefinitionsController {
|
||||||
|
|
||||||
return testDefinition;
|
return testDefinition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/:id/run')
|
||||||
|
async runTest(req: TestDefinitionsRequest.Run, res: express.Response) {
|
||||||
|
const { id: testDefinitionId } = req.params;
|
||||||
|
|
||||||
|
const workflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||||
|
|
||||||
|
// Check test definition exists
|
||||||
|
const testDefinition = await this.testDefinitionService.findOne(testDefinitionId, workflowIds);
|
||||||
|
if (!testDefinition) throw new NotFoundError('Test definition not found');
|
||||||
|
|
||||||
|
// We do not await for the test run to complete
|
||||||
|
void this.testRunnerService.runTest(req.user, testDefinition);
|
||||||
|
|
||||||
|
res.status(202).json({ success: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,4 +30,6 @@ export declare namespace TestDefinitionsRequest {
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type Delete = AuthenticatedRequest<RouteParams.TestId>;
|
type Delete = AuthenticatedRequest<RouteParams.TestId>;
|
||||||
|
|
||||||
|
type Run = AuthenticatedRequest<RouteParams.TestId>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,171 @@
|
||||||
|
{
|
||||||
|
"startData": {},
|
||||||
|
"resultData": {
|
||||||
|
"runData": {
|
||||||
|
"When clicking ‘Test workflow’": [
|
||||||
|
{
|
||||||
|
"hints": [],
|
||||||
|
"startTime": 1731079118048,
|
||||||
|
"executionTime": 0,
|
||||||
|
"source": [],
|
||||||
|
"executionStatus": "success",
|
||||||
|
"data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"query": "First item"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"query": "Second item"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"query": "Third item"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Edit Fields": [
|
||||||
|
{
|
||||||
|
"hints": [],
|
||||||
|
"startTime": 1731079118049,
|
||||||
|
"executionTime": 0,
|
||||||
|
"source": [
|
||||||
|
{
|
||||||
|
"previousNode": "When clicking ‘Test workflow’"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"executionStatus": "success",
|
||||||
|
"data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"foo": "bar"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"foo": "bar"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"foo": "bar"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Code": [
|
||||||
|
{
|
||||||
|
"hints": [],
|
||||||
|
"startTime": 1731079118049,
|
||||||
|
"executionTime": 3,
|
||||||
|
"source": [
|
||||||
|
{
|
||||||
|
"previousNode": "Edit Fields"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"executionStatus": "success",
|
||||||
|
"data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"foo": "bar",
|
||||||
|
"random": 0.6315509336851373
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"foo": "bar",
|
||||||
|
"random": 0.3336315687359024
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"foo": "bar",
|
||||||
|
"random": 0.4241870158917733
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pinData": {
|
||||||
|
"When clicking ‘Test workflow’": [
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"query": "First item"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"query": "Second item"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"query": "Third item"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lastNodeExecuted": "Code"
|
||||||
|
},
|
||||||
|
"executionData": {
|
||||||
|
"contextData": {},
|
||||||
|
"nodeExecutionStack": [],
|
||||||
|
"metadata": {},
|
||||||
|
"waitingExecution": {},
|
||||||
|
"waitingExecutionSource": {}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,124 @@
|
||||||
|
{
|
||||||
|
"name": "Evaluation Workflow",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {},
|
||||||
|
"id": "285ac92b-256f-4bb2-a450-6486b01593cb",
|
||||||
|
"name": "Execute Workflow Trigger",
|
||||||
|
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [520, 340]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "9d3abc8d-3270-4bec-9a59-82622d5dbb5a",
|
||||||
|
"leftValue": "={{ $json.actual.Code[0].data.main[0].length }}",
|
||||||
|
"rightValue": 3,
|
||||||
|
"operator": {
|
||||||
|
"type": "number",
|
||||||
|
"operation": "gte"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "894ce84b-13a4-4415-99c0-0c25182903bb",
|
||||||
|
"leftValue": "={{ $json.actual.Code[0].data.main[0][0].json.random }}",
|
||||||
|
"rightValue": 0.7,
|
||||||
|
"operator": {
|
||||||
|
"type": "number",
|
||||||
|
"operation": "lt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "320b0355-3886-41df-b039-4666bf28e47b",
|
||||||
|
"name": "If",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [740, 340]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "3b65d55a-158f-40c6-9853-a1c44b7ba1e5",
|
||||||
|
"name": "success",
|
||||||
|
"value": true,
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "0c7a1ee8-0cf0-4d7f-99a3-186bbcd8815a",
|
||||||
|
"name": "Success",
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [980, 220]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "6cc8b402-4a30-4873-b825-963a1f1b8b82",
|
||||||
|
"name": "success",
|
||||||
|
"value": false,
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "50d3f84a-d99f-4e04-bdbd-3e8c2668e708",
|
||||||
|
"name": "Fail",
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [980, 420]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Execute Workflow Trigger": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "If",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"If": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Success",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fail",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {}
|
||||||
|
}
|
|
@ -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": "When clicking ‘Test workflow’"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "Edit Fields"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"When clicking ‘Test workflow’": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Edit Fields",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Edit Fields": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Wait",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Wait": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Code",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,102 @@
|
||||||
|
import type { SelectQueryBuilder } from '@n8n/typeorm';
|
||||||
|
import { stringify } from 'flatted';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { mock, mockDeep } from 'jest-mock-extended';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import type { ActiveExecutions } from '@/active-executions';
|
||||||
|
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||||
|
import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
|
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
import type { WorkflowRunner } from '@/workflow-runner';
|
||||||
|
|
||||||
|
import { TestRunnerService } from '../test-runner.service.ee';
|
||||||
|
|
||||||
|
const wfUnderTestJson = JSON.parse(
|
||||||
|
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const executionDataJson = JSON.parse(
|
||||||
|
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const executionMocks = [
|
||||||
|
mock<ExecutionEntity>({
|
||||||
|
id: 'some-execution-id',
|
||||||
|
workflowId: 'workflow-under-test-id',
|
||||||
|
status: 'success',
|
||||||
|
executionData: {
|
||||||
|
data: stringify(executionDataJson),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<ExecutionEntity>({
|
||||||
|
id: 'some-execution-id-2',
|
||||||
|
workflowId: 'workflow-under-test-id',
|
||||||
|
status: 'success',
|
||||||
|
executionData: {
|
||||||
|
data: stringify(executionDataJson),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('TestRunnerService', () => {
|
||||||
|
const executionRepository = mock<ExecutionRepository>();
|
||||||
|
const workflowRepository = mock<WorkflowRepository>();
|
||||||
|
const workflowRunner = mock<WorkflowRunner>();
|
||||||
|
const activeExecutions = mock<ActiveExecutions>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const executionsQbMock = mockDeep<SelectQueryBuilder<ExecutionEntity>>({
|
||||||
|
fallbackMockImplementation: jest.fn().mockReturnThis(),
|
||||||
|
});
|
||||||
|
|
||||||
|
executionsQbMock.getMany.mockResolvedValueOnce(executionMocks);
|
||||||
|
executionRepository.createQueryBuilder.mockReturnValueOnce(executionsQbMock);
|
||||||
|
executionRepository.findOne
|
||||||
|
.calledWith(expect.objectContaining({ where: { id: 'some-execution-id' } }))
|
||||||
|
.mockResolvedValueOnce(executionMocks[0]);
|
||||||
|
executionRepository.findOne
|
||||||
|
.calledWith(expect.objectContaining({ where: { id: 'some-execution-id-2' } }))
|
||||||
|
.mockResolvedValueOnce(executionMocks[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create an instance of TestRunnerService', async () => {
|
||||||
|
const testRunnerService = new TestRunnerService(
|
||||||
|
workflowRepository,
|
||||||
|
workflowRunner,
|
||||||
|
executionRepository,
|
||||||
|
activeExecutions,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create and run test cases from past executions', async () => {
|
||||||
|
const testRunnerService = new TestRunnerService(
|
||||||
|
workflowRepository,
|
||||||
|
workflowRunner,
|
||||||
|
executionRepository,
|
||||||
|
activeExecutions,
|
||||||
|
);
|
||||||
|
|
||||||
|
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||||
|
id: 'workflow-under-test-id',
|
||||||
|
...wfUnderTestJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowRunner.run.mockResolvedValue('test-execution-id');
|
||||||
|
|
||||||
|
await testRunnerService.runTest(
|
||||||
|
mock<User>(),
|
||||||
|
mock<TestDefinition>({
|
||||||
|
workflowId: 'workflow-under-test-id',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(executionRepository.createQueryBuilder).toHaveBeenCalledTimes(1);
|
||||||
|
expect(executionRepository.findOne).toHaveBeenCalledTimes(2);
|
||||||
|
expect(workflowRunner.run).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { parse } from 'flatted';
|
||||||
|
import type { IPinData, IRun, IWorkflowExecutionDataProcess } from 'n8n-workflow';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
|
import { ActiveExecutions } from '@/active-executions';
|
||||||
|
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||||
|
import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
import type { IExecutionResponse } from '@/interfaces';
|
||||||
|
import { WorkflowRunner } from '@/workflow-runner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This service orchestrates the running of test cases.
|
||||||
|
* It uses the test definitions to find
|
||||||
|
* past executions, creates pin data from them,
|
||||||
|
* and runs the workflow-under-test with the pin data.
|
||||||
|
* TODO: Evaluation workflows
|
||||||
|
* TODO: Node pinning
|
||||||
|
* TODO: Collect metrics
|
||||||
|
*/
|
||||||
|
@Service()
|
||||||
|
export class TestRunnerService {
|
||||||
|
constructor(
|
||||||
|
private readonly workflowRepository: WorkflowRepository,
|
||||||
|
private readonly workflowRunner: WorkflowRunner,
|
||||||
|
private readonly executionRepository: ExecutionRepository,
|
||||||
|
private readonly activeExecutions: ActiveExecutions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a pin data object from the past execution data
|
||||||
|
* for the given workflow.
|
||||||
|
* For now, it only pins trigger nodes.
|
||||||
|
*/
|
||||||
|
private createPinDataFromExecution(
|
||||||
|
workflow: WorkflowEntity,
|
||||||
|
execution: ExecutionEntity,
|
||||||
|
): IPinData {
|
||||||
|
const executionData = parse(execution.executionData.data) as IExecutionResponse['data'];
|
||||||
|
|
||||||
|
const triggerNodes = workflow.nodes.filter((node) => /trigger$/i.test(node.type));
|
||||||
|
|
||||||
|
const pinData = {} as IPinData;
|
||||||
|
|
||||||
|
for (const triggerNode of triggerNodes) {
|
||||||
|
const triggerData = executionData.resultData.runData[triggerNode.name];
|
||||||
|
if (triggerData?.[0]?.data?.main?.[0]) {
|
||||||
|
pinData[triggerNode.name] = triggerData[0]?.data?.main?.[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pinData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a test case with the given pin data.
|
||||||
|
* Waits for the workflow under test to finish execution.
|
||||||
|
*/
|
||||||
|
private async runTestCase(
|
||||||
|
workflow: WorkflowEntity,
|
||||||
|
testCasePinData: IPinData,
|
||||||
|
userId: string,
|
||||||
|
): Promise<IRun | undefined> {
|
||||||
|
const data: IWorkflowExecutionDataProcess = {
|
||||||
|
executionMode: 'evaluation',
|
||||||
|
runData: {},
|
||||||
|
pinData: testCasePinData,
|
||||||
|
workflowData: workflow,
|
||||||
|
partialExecutionVersion: '-1',
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger the workflow under test with mocked data
|
||||||
|
const executionId = await this.workflowRunner.run(data);
|
||||||
|
assert(executionId);
|
||||||
|
|
||||||
|
// Wait for the workflow to finish execution
|
||||||
|
const executePromise = this.activeExecutions.getPostExecutePromise(executionId);
|
||||||
|
|
||||||
|
return await executePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new test run for the given test definition.
|
||||||
|
*/
|
||||||
|
public async runTest(user: User, test: TestDefinition): Promise<void> {
|
||||||
|
const workflow = await this.workflowRepository.findById(test.workflowId);
|
||||||
|
assert(workflow, 'Workflow not found');
|
||||||
|
|
||||||
|
// 1. Make test cases from previous executions
|
||||||
|
|
||||||
|
// Select executions with the annotation tag and workflow ID of the test.
|
||||||
|
// Fetch only ids to reduce the data transfer.
|
||||||
|
const pastExecutions: ReadonlyArray<Pick<ExecutionEntity, 'id'>> =
|
||||||
|
await this.executionRepository
|
||||||
|
.createQueryBuilder('execution')
|
||||||
|
.select('execution.id')
|
||||||
|
.leftJoin('execution.annotation', 'annotation')
|
||||||
|
.leftJoin('annotation.tags', 'annotationTag')
|
||||||
|
.where('annotationTag.id = :tagId', { tagId: test.annotationTagId })
|
||||||
|
.andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// 2. Run the test cases
|
||||||
|
|
||||||
|
for (const { id: pastExecutionId } of pastExecutions) {
|
||||||
|
const pastExecution = await this.executionRepository.findOne({
|
||||||
|
where: { id: pastExecutionId },
|
||||||
|
relations: ['executionData', 'metadata'],
|
||||||
|
});
|
||||||
|
assert(pastExecution, 'Execution not found');
|
||||||
|
|
||||||
|
const pinData = this.createPinDataFromExecution(workflow, pastExecution);
|
||||||
|
|
||||||
|
// Run the test case and wait for it to finish
|
||||||
|
const execution = await this.runTestCase(workflow, pinData, user.id);
|
||||||
|
|
||||||
|
if (!execution) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 2.3 Collect the run data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,11 @@
|
||||||
|
import { mockInstance } from 'n8n-core/test/utils';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee';
|
import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee';
|
||||||
|
import { TestRunnerService } from '@/evaluation/test-runner/test-runner.service.ee';
|
||||||
import { createAnnotationTags } from '@test-integration/db/executions';
|
import { createAnnotationTags } from '@test-integration/db/executions';
|
||||||
|
|
||||||
import { createUserShell } from './../shared/db/users';
|
import { createUserShell } from './../shared/db/users';
|
||||||
|
@ -12,6 +14,8 @@ import * as testDb from './../shared/test-db';
|
||||||
import type { SuperAgentTest } from './../shared/types';
|
import type { SuperAgentTest } from './../shared/types';
|
||||||
import * as utils from './../shared/utils/';
|
import * as utils from './../shared/utils/';
|
||||||
|
|
||||||
|
const testRunner = mockInstance(TestRunnerService);
|
||||||
|
|
||||||
let authOwnerAgent: SuperAgentTest;
|
let authOwnerAgent: SuperAgentTest;
|
||||||
let workflowUnderTest: WorkflowEntity;
|
let workflowUnderTest: WorkflowEntity;
|
||||||
let workflowUnderTest2: WorkflowEntity;
|
let workflowUnderTest2: WorkflowEntity;
|
||||||
|
@ -426,3 +430,24 @@ describe('DELETE /evaluation/test-definitions/:id', () => {
|
||||||
expect(resp.body.message).toBe('Test definition not found');
|
expect(resp.body.message).toBe('Test definition not found');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /evaluation/test-definitions/:id/run', () => {
|
||||||
|
test('should trigger the test run', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.post(`/evaluation/test-definitions/${newTest.id}/run`);
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(202);
|
||||||
|
expect(resp.body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
success: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(testRunner.runTest).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -2371,7 +2371,8 @@ export type WorkflowExecuteMode =
|
||||||
| 'manual'
|
| 'manual'
|
||||||
| 'retry'
|
| 'retry'
|
||||||
| 'trigger'
|
| 'trigger'
|
||||||
| 'webhook';
|
| 'webhook'
|
||||||
|
| 'evaluation';
|
||||||
|
|
||||||
export type WorkflowActivateMode =
|
export type WorkflowActivateMode =
|
||||||
| 'init'
|
| 'init'
|
||||||
|
|
Loading…
Reference in a new issue