feat(core): Update data model for Evaluations (no-changelog) (#15520)

Co-authored-by: Yiorgis Gozadinos <yiorgis@n8n.io>
Co-authored-by: JP van Oosten <jp@n8n.io>
This commit is contained in:
Eugene
2025-05-22 12:55:31 +02:00
committed by GitHub
parent 07d526e9d6
commit 8152f8c6a7
42 changed files with 512 additions and 3327 deletions

View File

@@ -22,8 +22,6 @@ import { SharedCredentials } from './shared-credentials';
import { SharedWorkflow } from './shared-workflow'; import { SharedWorkflow } from './shared-workflow';
import { TagEntity } from './tag-entity'; import { TagEntity } from './tag-entity';
import { TestCaseExecution } from './test-case-execution.ee'; import { TestCaseExecution } from './test-case-execution.ee';
import { TestDefinition } from './test-definition.ee';
import { TestMetric } from './test-metric.ee';
import { TestRun } from './test-run.ee'; import { TestRun } from './test-run.ee';
import { User } from './user'; import { User } from './user';
import { Variables } from './variables'; import { Variables } from './variables';
@@ -63,8 +61,6 @@ export {
AnnotationTagEntity, AnnotationTagEntity,
ExecutionAnnotation, ExecutionAnnotation,
AnnotationTagMapping, AnnotationTagMapping,
TestDefinition,
TestMetric,
TestRun, TestRun,
TestCaseExecution, TestCaseExecution,
ExecutionEntity, ExecutionEntity,
@@ -100,8 +96,6 @@ export const entities = {
AnnotationTagEntity, AnnotationTagEntity,
ExecutionAnnotation, ExecutionAnnotation,
AnnotationTagMapping, AnnotationTagMapping,
TestDefinition,
TestMetric,
TestRun, TestRun,
TestCaseExecution, TestCaseExecution,
ExecutionEntity, ExecutionEntity,

View File

@@ -19,7 +19,7 @@ export type TestCaseExecutionStatus =
/** /**
* This entity represents the linking between the test runs and individual executions. * This entity represents the linking between the test runs and individual executions.
* It stores status, links to past, new and evaluation executions, and metrics produced by individual evaluation wf executions * It stores status, link to the evaluation execution, and metrics produced by individual test case
* Entries in this table are meant to outlive the execution entities, which might be pruned over time. * Entries in this table are meant to outlive the execution entities, which might be pruned over time.
* This allows us to keep track of the details of test runs' status and metrics even after the executions are deleted. * This allows us to keep track of the details of test runs' status and metrics even after the executions are deleted.
*/ */
@@ -28,15 +28,6 @@ export class TestCaseExecution extends WithStringId {
@ManyToOne('TestRun') @ManyToOne('TestRun')
testRun: TestRun; testRun: TestRun;
@ManyToOne('ExecutionEntity', {
onDelete: 'SET NULL',
nullable: true,
})
pastExecution: ExecutionEntity | null;
@Column({ type: 'varchar', nullable: true })
pastExecutionId: string | null;
@OneToOne('ExecutionEntity', { @OneToOne('ExecutionEntity', {
onDelete: 'SET NULL', onDelete: 'SET NULL',
nullable: true, nullable: true,
@@ -46,15 +37,6 @@ export class TestCaseExecution extends WithStringId {
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
executionId: string | null; executionId: string | null;
@OneToOne('ExecutionEntity', {
onDelete: 'SET NULL',
nullable: true,
})
evaluationExecution: ExecutionEntity | null;
@Column({ type: 'varchar', nullable: true })
evaluationExecutionId: string | null;
@Column() @Column()
status: TestCaseExecutionStatus; status: TestCaseExecutionStatus;

View File

@@ -1,63 +0,0 @@
import { Column, Entity, Index, ManyToOne, OneToMany, RelationId } from '@n8n/typeorm';
import { Length } from 'class-validator';
import { JsonColumn, WithTimestampsAndStringId } from './abstract-entity';
import { AnnotationTagEntity } from './annotation-tag-entity.ee';
import type { TestMetric } from './test-metric.ee';
import type { MockedNodeItem } from './types-db';
import { WorkflowEntity } from './workflow-entity';
/**
* Entity representing a Test Definition
* It combines:
* - the workflow under test
* - the workflow used to evaluate the results of test execution
* - the filter used to select test cases from previous executions of the workflow under test - annotation tag
*/
@Entity()
@Index(['workflow'])
@Index(['evaluationWorkflow'])
export class TestDefinition extends WithTimestampsAndStringId {
@Column({ length: 255 })
@Length(1, 255, {
message: 'Test definition name must be $constraint1 to $constraint2 characters long.',
})
name: string;
@Column('text')
description: string;
@JsonColumn({ default: '[]' })
mockedNodes: MockedNodeItem[];
/**
* Relation to the workflow under test
*/
@ManyToOne('WorkflowEntity', 'tests')
workflow: WorkflowEntity;
@RelationId((test: TestDefinition) => test.workflow)
workflowId: string;
/**
* Relation to the workflow used to evaluate the results of test execution
*/
@ManyToOne('WorkflowEntity', 'evaluationTests')
evaluationWorkflow: WorkflowEntity;
@RelationId((test: TestDefinition) => test.evaluationWorkflow)
evaluationWorkflowId: string;
/**
* Relation to the annotation tag associated with the test
* This tag will be used to select the test cases to run from previous executions
*/
@ManyToOne('AnnotationTagEntity', 'test')
annotationTag: AnnotationTagEntity;
@RelationId((test: TestDefinition) => test.annotationTag)
annotationTagId: string;
@OneToMany('TestMetric', 'testDefinition')
metrics: TestMetric[];
}

View File

@@ -1,29 +0,0 @@
import { Column, Entity, Index, ManyToOne } from '@n8n/typeorm';
import { Length } from 'class-validator';
import { WithTimestampsAndStringId } from './abstract-entity';
import { TestDefinition } from './test-definition.ee';
/**
* Entity representing a Test Metric
* It represents a single metric that can be retrieved from evaluation workflow execution result
*/
@Entity()
@Index(['testDefinition'])
export class TestMetric extends WithTimestampsAndStringId {
/**
* Name of the metric.
* This will be used as a property name to extract metric value from the evaluation workflow execution result object
*/
@Column({ length: 255 })
@Length(1, 255, {
message: 'Metric name must be $constraint1 to $constraint2 characters long.',
})
name: string;
/**
* Relation to test definition
*/
@ManyToOne('TestDefinition', 'metrics')
testDefinition: TestDefinition;
}

View File

@@ -1,27 +1,20 @@
import { Column, Entity, Index, ManyToOne, OneToMany, RelationId } from '@n8n/typeorm'; import { Column, Entity, OneToMany, ManyToOne } from '@n8n/typeorm';
import type { IDataObject } from 'n8n-workflow'; import type { IDataObject } from 'n8n-workflow';
import { DateTimeColumn, JsonColumn, WithTimestampsAndStringId } from './abstract-entity'; import { DateTimeColumn, JsonColumn, WithTimestampsAndStringId } from './abstract-entity';
import type { TestCaseExecution } from './test-case-execution.ee'; import type { TestCaseExecution } from './test-case-execution.ee';
import { TestDefinition } from './test-definition.ee';
import { AggregatedTestRunMetrics } from './types-db'; import { AggregatedTestRunMetrics } from './types-db';
import type { TestRunErrorCode, TestRunFinalResult } from './types-db'; import type { TestRunErrorCode, TestRunFinalResult } from './types-db';
import { WorkflowEntity } from './workflow-entity';
export type TestRunStatus = 'new' | 'running' | 'completed' | 'error' | 'cancelled'; export type TestRunStatus = 'new' | 'running' | 'completed' | 'error' | 'cancelled';
/** /**
* Entity representing a Test Run. * Entity representing a Test Run.
* It stores info about a specific run of a test, combining the test definition with the status and collected metrics * It stores info about a specific run of a test, including the status and collected metrics
*/ */
@Entity() @Entity()
@Index(['testDefinition'])
export class TestRun extends WithTimestampsAndStringId { export class TestRun extends WithTimestampsAndStringId {
@ManyToOne('TestDefinition', 'runs')
testDefinition: TestDefinition;
@RelationId((testRun: TestRun) => testRun.testDefinition)
testDefinitionId: string;
@Column('varchar') @Column('varchar')
status: TestRunStatus; status: TestRunStatus;
@@ -34,25 +27,6 @@ export class TestRun extends WithTimestampsAndStringId {
@JsonColumn({ nullable: true }) @JsonColumn({ nullable: true })
metrics: AggregatedTestRunMetrics; metrics: AggregatedTestRunMetrics;
/**
* Total number of the test cases, matching the filter condition of the test definition (specified annotationTag)
*/
@Column('integer', { nullable: true })
totalCases: number;
/**
* Number of test cases that passed (evaluation workflow was executed successfully)
*/
@Column('integer', { nullable: true })
passedCases: number;
/**
* Number of failed test cases
* (any unexpected exception happened during the execution or evaluation workflow ended with an error)
*/
@Column('integer', { nullable: true })
failedCases: number;
/** /**
* This will contain the error code if the test run failed. * This will contain the error code if the test run failed.
* This is used for test run level errors, not for individual test case errors. * This is used for test run level errors, not for individual test case errors.
@@ -69,6 +43,12 @@ export class TestRun extends WithTimestampsAndStringId {
@OneToMany('TestCaseExecution', 'testRun') @OneToMany('TestCaseExecution', 'testRun')
testCaseExecutions: TestCaseExecution[]; testCaseExecutions: TestCaseExecution[];
@ManyToOne('WorkflowEntity')
workflow: WorkflowEntity;
@Column('varchar', { length: 255 })
workflowId: string;
/** /**
* Calculated property to determine the final result of the test run * Calculated property to determine the final result of the test run
* depending on the statuses of test case executions * depending on the statuses of test case executions

View File

@@ -16,6 +16,7 @@ import { JsonColumn, WithTimestampsAndStringId, dbType } from './abstract-entity
import { type Folder } from './folder'; import { type Folder } from './folder';
import type { SharedWorkflow } from './shared-workflow'; import type { SharedWorkflow } from './shared-workflow';
import type { TagEntity } from './tag-entity'; import type { TagEntity } from './tag-entity';
import type { TestRun } from './test-run.ee';
import type { IWorkflowDb } from './types-db'; import type { IWorkflowDb } from './types-db';
import type { WorkflowStatistics } from './workflow-statistics'; import type { WorkflowStatistics } from './workflow-statistics';
import type { WorkflowTagMapping } from './workflow-tag-mapping'; import type { WorkflowTagMapping } from './workflow-tag-mapping';
@@ -108,6 +109,9 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl
}) })
@JoinColumn({ name: 'parentFolderId' }) @JoinColumn({ name: 'parentFolderId' })
parentFolder: Folder | null; parentFolder: Folder | null;
@OneToMany('TestRun', 'workflow')
testRuns: TestRun[];
} }
/** /**

View File

@@ -0,0 +1,68 @@
import type { MigrationContext, IrreversibleMigration } from '../migration-types';
const testRunTableName = 'test_run';
const testCaseExecutionTableName = 'test_case_execution';
export class ClearEvaluation1745322634000 implements IrreversibleMigration {
async up({
schemaBuilder: { dropTable, column, createTable },
queryRunner,
tablePrefix,
isSqlite,
isPostgres,
isMysql,
}: MigrationContext) {
// Drop test_metric, test_definition
await dropTable(testCaseExecutionTableName);
await dropTable(testRunTableName);
await dropTable('test_metric');
if (isSqlite) {
await queryRunner.query(`DROP TABLE IF EXISTS ${tablePrefix}test_definition;`);
} else if (isPostgres) {
await queryRunner.query(`DROP TABLE IF EXISTS ${tablePrefix}test_definition CASCADE;`);
} else if (isMysql) {
await queryRunner.query(`DROP TABLE IF EXISTS ${tablePrefix}test_definition CASCADE;`);
}
await createTable(testRunTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('workflowId').varchar(36).notNull,
column('status').varchar().notNull,
column('errorCode').varchar(),
column('errorDetails').json,
column('runAt').timestamp(),
column('completedAt').timestamp(),
column('metrics').json,
)
.withIndexOn('workflowId')
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await createTable(testCaseExecutionTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('testRunId').varchar(36).notNull,
column('executionId').int, // Execution of the workflow under test. Might be null if execution was deleted after the test run
column('status').varchar().notNull,
column('runAt').timestamp(),
column('completedAt').timestamp(),
column('errorCode').varchar(),
column('errorDetails').json,
column('metrics').json,
)
.withIndexOn('testRunId')
.withForeignKey('testRunId', {
tableName: 'test_run',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
}
}

View File

@@ -82,6 +82,7 @@ import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFo
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys'; import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
import { ClearEvaluation1745322634000 } from '../common/1745322634000-CleanEvaluations';
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount'; import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn'; import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
@@ -177,4 +178,5 @@ export const mysqlMigrations: Migration[] = [
AddWorkflowStatisticsRootCount1745587087521, AddWorkflowStatisticsRootCount1745587087521,
AddWorkflowArchivedColumn1745934666076, AddWorkflowArchivedColumn1745934666076,
DropRoleTable1745934666077, DropRoleTable1745934666077,
ClearEvaluation1745322634000,
]; ];

View File

@@ -82,6 +82,7 @@ import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFo
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys'; import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
import { ClearEvaluation1745322634000 } from '../common/1745322634000-CleanEvaluations';
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount'; import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn'; import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
@@ -175,4 +176,5 @@ export const postgresMigrations: Migration[] = [
AddWorkflowStatisticsRootCount1745587087521, AddWorkflowStatisticsRootCount1745587087521,
AddWorkflowArchivedColumn1745934666076, AddWorkflowArchivedColumn1745934666076,
DropRoleTable1745934666077, DropRoleTable1745934666077,
ClearEvaluation1745322634000,
]; ];

View File

@@ -79,6 +79,7 @@ import { CreateTestCaseExecutionTable1736947513045 } from '../common/17369475130
import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns'; import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns';
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
import { ClearEvaluation1745322634000 } from '../common/1745322634000-CleanEvaluations';
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount'; import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn'; import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
@@ -169,6 +170,7 @@ const sqliteMigrations: Migration[] = [
AddWorkflowStatisticsRootCount1745587087521, AddWorkflowStatisticsRootCount1745587087521,
AddWorkflowArchivedColumn1745934666076, AddWorkflowArchivedColumn1745934666076,
DropRoleTable1745934666077, DropRoleTable1745934666077,
ClearEvaluation1745322634000,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View File

@@ -21,8 +21,6 @@ export { ProcessedDataRepository } from './processed-data.repository';
export { SettingsRepository } from './settings.repository'; export { SettingsRepository } from './settings.repository';
export { TagRepository } from './tag.repository'; export { TagRepository } from './tag.repository';
export { TestCaseExecutionRepository } from './test-case-execution.repository.ee'; export { TestCaseExecutionRepository } from './test-case-execution.repository.ee';
export { TestDefinitionRepository } from './test-definition.repository.ee';
export { TestMetricRepository } from './test-metric.repository.ee';
export { TestRunRepository } from './test-run.repository.ee'; export { TestRunRepository } from './test-run.repository.ee';
export { VariablesRepository } from './variables.repository'; export { VariablesRepository } from './variables.repository';
export { WorkflowHistoryRepository } from './workflow-history.repository'; export { WorkflowHistoryRepository } from './workflow-history.repository';

View File

@@ -18,16 +18,10 @@ type MarkAsFailedOptions = StatusUpdateOptions & {
errorDetails?: IDataObject; errorDetails?: IDataObject;
}; };
type MarkAsWarningOptions = MarkAsFailedOptions;
type MarkAsRunningOptions = StatusUpdateOptions & { type MarkAsRunningOptions = StatusUpdateOptions & {
executionId: string; executionId: string;
}; };
type MarkAsEvaluationRunningOptions = StatusUpdateOptions & {
evaluationExecutionId: string;
};
type MarkAsCompletedOptions = StatusUpdateOptions & { type MarkAsCompletedOptions = StatusUpdateOptions & {
metrics: Record<string, number>; metrics: Record<string, number>;
}; };
@@ -38,15 +32,12 @@ export class TestCaseExecutionRepository extends Repository<TestCaseExecution> {
super(TestCaseExecution, dataSource.manager); super(TestCaseExecution, dataSource.manager);
} }
async createBatch(testRunId: string, pastExecutionIds: string[]) { async createBatch(testRunId: string, testCases: string[]) {
const mappings = this.create( const mappings = this.create(
pastExecutionIds.map<DeepPartial<TestCaseExecution>>((id) => ({ testCases.map<DeepPartial<TestCaseExecution>>(() => ({
testRun: { testRun: {
id: testRunId, id: testRunId,
}, },
pastExecution: {
id,
},
status: 'new', status: 'new',
})), })),
); );
@@ -68,24 +59,6 @@ export class TestCaseExecutionRepository extends Repository<TestCaseExecution> {
); );
} }
async markAsEvaluationRunning({
testRunId,
pastExecutionId,
evaluationExecutionId,
trx,
}: MarkAsEvaluationRunningOptions) {
trx = trx ?? this.manager;
return await trx.update(
TestCaseExecution,
{ testRun: { id: testRunId }, pastExecutionId },
{
status: 'evaluation_running',
evaluationExecutionId,
},
);
}
async markAsCompleted({ testRunId, pastExecutionId, metrics, trx }: MarkAsCompletedOptions) { async markAsCompleted({ testRunId, pastExecutionId, metrics, trx }: MarkAsCompletedOptions) {
trx = trx ?? this.manager; trx = trx ?? this.manager;
@@ -133,21 +106,4 @@ export class TestCaseExecutionRepository extends Repository<TestCaseExecution> {
}, },
); );
} }
async markAsWarning({
testRunId,
pastExecutionId,
errorCode,
errorDetails,
}: MarkAsWarningOptions) {
return await this.update(
{ testRun: { id: testRunId }, pastExecutionId },
{
status: 'warning',
completedAt: new Date(),
errorCode,
errorDetails,
},
);
}
} }

View File

@@ -1,70 +0,0 @@
import { Service } from '@n8n/di';
import type { FindManyOptions, FindOptionsWhere } from '@n8n/typeorm';
import { DataSource, In, Repository } from '@n8n/typeorm';
import { UserError } from 'n8n-workflow';
import { TestDefinition } from '../entities';
import type { ListQuery } from '../entities/types-db';
@Service()
export class TestDefinitionRepository extends Repository<TestDefinition> {
constructor(dataSource: DataSource) {
super(TestDefinition, dataSource.manager);
}
async getMany(accessibleWorkflowIds: string[], options?: ListQuery.Options) {
if (accessibleWorkflowIds.length === 0) return { tests: [], count: 0 };
const where: FindOptionsWhere<TestDefinition> = {};
if (options?.filter?.workflowId) {
if (!accessibleWorkflowIds.includes(options.filter.workflowId as string)) {
throw new UserError('User does not have access to the workflow');
}
where.workflow = {
id: options.filter.workflowId as string,
};
} else {
where.workflow = {
id: In(accessibleWorkflowIds),
};
}
const findManyOptions: FindManyOptions<TestDefinition> = {
where,
relations: ['annotationTag'],
order: { createdAt: 'DESC' },
};
if (options?.take) {
findManyOptions.skip = options.skip;
findManyOptions.take = options.take;
}
const [testDefinitions, count] = await this.findAndCount(findManyOptions);
return { testDefinitions, count };
}
async getOne(id: string, accessibleWorkflowIds: string[]) {
return await this.findOne({
where: {
id,
workflow: {
id: In(accessibleWorkflowIds),
},
},
relations: ['annotationTag', 'metrics'],
});
}
async deleteById(id: string, accessibleWorkflowIds: string[]) {
return await this.delete({
id,
workflow: {
id: In(accessibleWorkflowIds),
},
});
}
}

View File

@@ -1,11 +0,0 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { TestMetric } from '../entities';
@Service()
export class TestMetricRepository extends Repository<TestMetric> {
constructor(dataSource: DataSource) {
super(TestMetric, dataSource.manager);
}
}

View File

@@ -22,22 +22,21 @@ export class TestRunRepository extends Repository<TestRun> {
super(TestRun, dataSource.manager); super(TestRun, dataSource.manager);
} }
async createTestRun(testDefinitionId: string) { async createTestRun(workflowId: string) {
const testRun = this.create({ const testRun = this.create({
status: 'new', status: 'new',
testDefinition: { id: testDefinitionId }, workflow: {
id: workflowId,
},
}); });
return await this.save(testRun); return await this.save(testRun);
} }
async markAsRunning(id: string, totalCases: number) { async markAsRunning(id: string) {
return await this.update(id, { return await this.update(id, {
status: 'running', status: 'running',
runAt: new Date(), runAt: new Date(),
totalCases,
passedCases: 0,
failedCases: 0,
}); });
} }
@@ -65,20 +64,10 @@ export class TestRunRepository extends Repository<TestRun> {
); );
} }
async incrementPassed(id: string, trx?: EntityManager) { async getMany(workflowId: string, options: ListQuery.Options) {
trx = trx ?? this.manager;
return await trx.increment(TestRun, { id }, 'passedCases', 1);
}
async incrementFailed(id: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.increment(TestRun, { id }, 'failedCases', 1);
}
async getMany(testDefinitionId: string, options: ListQuery.Options) {
// FIXME: optimize fetching final result of each test run // FIXME: optimize fetching final result of each test run
const findManyOptions: FindManyOptions<TestRun> = { const findManyOptions: FindManyOptions<TestRun> = {
where: { testDefinition: { id: testDefinitionId } }, where: { workflow: { id: workflowId } },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
relations: ['testCaseExecutions'], relations: ['testCaseExecutions'],
}; };
@@ -103,12 +92,9 @@ export class TestRunRepository extends Repository<TestRun> {
* E.g. Test Run is considered successful if all test case executions are successful. * E.g. Test Run is considered successful if all test case executions are successful.
* Test Run is considered failed if at least one test case execution is failed. * Test Run is considered failed if at least one test case execution is failed.
*/ */
async getTestRunSummaryById( async getTestRunSummaryById(testRunId: string): Promise<TestRunSummary> {
testDefinitionId: string,
testRunId: string,
): Promise<TestRunSummary> {
const testRun = await this.findOne({ const testRun = await this.findOne({
where: { id: testRunId, testDefinition: { id: testDefinitionId } }, where: { id: testRunId },
relations: ['testCaseExecutions'], relations: ['testCaseExecutions'],
}); });

View File

@@ -22,7 +22,7 @@ import * as CrashJournal from '@/crash-journal';
import { DbConnection } from '@/databases/db-connection'; import { DbConnection } from '@/databases/db-connection';
import { getDataDeduplicationService } from '@/deduplication'; import { getDataDeduplicationService } from '@/deduplication';
import { DeprecationService } from '@/deprecation/deprecation.service'; import { DeprecationService } from '@/deprecation/deprecation.service';
import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { TestRunCleanupService } from '@/evaluation.ee/test-runner/test-run-cleanup.service.ee';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
@@ -286,7 +286,7 @@ export abstract class BaseCommand extends Command {
} }
async cleanupTestRunner() { async cleanupTestRunner() {
await Container.get(TestRunnerService).cleanupIncompleteRuns(); await Container.get(TestRunCleanupService).cleanupIncompleteRuns();
} }
async finally(error: Error | undefined) { async finally(error: Error | undefined) {

View File

@@ -0,0 +1,155 @@
import type { TestCaseExecutionRepository, TestRun, TestRunRepository, User } from '@n8n/db';
import type { InstanceSettings } from 'n8n-core';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee';
import { TestRunsController } from '@/evaluation.ee/test-runs.controller.ee';
import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service';
import type { Telemetry } from '@/telemetry';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
// Mock dependencies
jest.mock('@/public-api/v1/handlers/workflows/workflows.service');
jest.mock('@/evaluation.ee/test-runner/test-runner.service.ee');
describe('TestRunsController', () => {
let testRunsController: TestRunsController;
let mockTestRunRepository: jest.Mocked<TestRunRepository>;
let mockWorkflowFinderService: jest.Mocked<WorkflowFinderService>;
let mockTestCaseExecutionRepository: jest.Mocked<TestCaseExecutionRepository>;
let mockTestRunnerService: jest.Mocked<TestRunnerService>;
let mockInstanceSettings: jest.Mocked<InstanceSettings>;
let mockTelemetry: jest.Mocked<Telemetry>;
let mockGetSharedWorkflowIds: jest.MockedFunction<typeof getSharedWorkflowIds>;
let mockUser: User;
let mockWorkflowId: string;
let mockTestRunId: string;
beforeEach(() => {
// Setup mocks
mockTestRunRepository = {
findOne: jest.fn(),
getMany: jest.fn(),
delete: jest.fn(),
createTestRun: jest.fn(),
} as unknown as jest.Mocked<TestRunRepository>;
mockWorkflowFinderService = {
findWorkflowForUser: jest.fn(),
} as unknown as jest.Mocked<WorkflowFinderService>;
mockTestCaseExecutionRepository = {
find: jest.fn(),
markAllPendingAsCancelled: jest.fn(),
} as unknown as jest.Mocked<TestCaseExecutionRepository>;
mockTestRunnerService = {
runTest: jest.fn(),
canBeCancelled: jest.fn(),
cancelTestRun: jest.fn(),
} as unknown as jest.Mocked<TestRunnerService>;
mockInstanceSettings = {
isMultiMain: false,
} as unknown as jest.Mocked<InstanceSettings>;
mockTelemetry = {
track: jest.fn(),
} as unknown as jest.Mocked<Telemetry>;
mockGetSharedWorkflowIds = getSharedWorkflowIds as jest.MockedFunction<
typeof getSharedWorkflowIds
>;
// Create test instance
testRunsController = new TestRunsController(
mockTestRunRepository,
mockWorkflowFinderService,
mockTestCaseExecutionRepository,
mockTestRunnerService,
mockInstanceSettings,
mockTelemetry,
);
// Common test data
mockUser = { id: 'user123' } as User;
mockWorkflowId = 'workflow123';
mockTestRunId = 'testrun123';
// Default mock behavior
mockGetSharedWorkflowIds.mockResolvedValue([mockWorkflowId]);
mockTestRunRepository.findOne.mockResolvedValue({
id: mockTestRunId,
status: 'running',
} as TestRun);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getTestRun', () => {
it('should return test run when it exists and user has access', async () => {
// Arrange
const mockTestRun = {
id: mockTestRunId,
status: 'running',
} as TestRun;
mockGetSharedWorkflowIds.mockResolvedValue([mockWorkflowId]);
mockTestRunRepository.findOne.mockResolvedValue(mockTestRun);
// Act
const result = await (testRunsController as any).getTestRun(
mockTestRunId,
mockWorkflowId,
mockUser,
);
// Assert
expect(mockGetSharedWorkflowIds).toHaveBeenCalledWith(mockUser, ['workflow:read']);
expect(mockTestRunRepository.findOne).toHaveBeenCalledWith({
where: { id: mockTestRunId },
});
expect(result).toEqual(mockTestRun);
});
it('should throw NotFoundError when user has no access to workflow', async () => {
// Arrange
mockGetSharedWorkflowIds.mockResolvedValue([]); // No access to any workflow
// Act & Assert
await expect(
(testRunsController as any).getTestRun(mockTestRunId, mockWorkflowId, mockUser),
).rejects.toThrow(NotFoundError);
expect(mockGetSharedWorkflowIds).toHaveBeenCalledWith(mockUser, ['workflow:read']);
expect(mockTestRunRepository.findOne).not.toHaveBeenCalled();
});
it('should throw NotFoundError when workflowId does not match any shared workflows', async () => {
// Arrange
mockGetSharedWorkflowIds.mockResolvedValue(['different-workflow-id']);
// Act & Assert
await expect(
(testRunsController as any).getTestRun(mockTestRunId, mockWorkflowId, mockUser),
).rejects.toThrow(NotFoundError);
expect(mockGetSharedWorkflowIds).toHaveBeenCalledWith(mockUser, ['workflow:read']);
expect(mockTestRunRepository.findOne).not.toHaveBeenCalled();
});
it('should throw NotFoundError when test run does not exist', async () => {
// Arrange
mockGetSharedWorkflowIds.mockResolvedValue([mockWorkflowId]);
mockTestRunRepository.findOne.mockResolvedValue(null); // Test run not found
// Act & Assert
await expect(
(testRunsController as any).getTestRun(mockTestRunId, mockWorkflowId, mockUser),
).rejects.toThrow(NotFoundError);
expect(mockGetSharedWorkflowIds).toHaveBeenCalledWith(mockUser, ['workflow:read']);
expect(mockTestRunRepository.findOne).toHaveBeenCalledWith({
where: { id: mockTestRunId },
});
});
});
});

View File

@@ -1,13 +0,0 @@
import { z } from 'zod';
export const testMetricCreateRequestBodySchema = z
.object({
name: z.string().min(1).max(255),
})
.strict();
export const testMetricPatchRequestBodySchema = z
.object({
name: z.string().min(1).max(255),
})
.strict();

View File

@@ -1,21 +0,0 @@
import { z } from 'zod';
export const testDefinitionCreateRequestBodySchema = z
.object({
name: z.string().min(1).max(255),
workflowId: z.string().min(1),
description: z.string().optional(),
evaluationWorkflowId: z.string().min(1).optional(),
annotationTagId: z.string().min(1).optional(),
})
.strict();
export const testDefinitionPatchRequestBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
evaluationWorkflowId: z.string().min(1).optional(),
annotationTagId: z.string().min(1).optional(),
mockedNodes: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
})
.strict();

View File

@@ -1,183 +0,0 @@
import type { MockedNodeItem, TestDefinition } from '@n8n/db';
import { AnnotationTagRepository, TestDefinitionRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { validateEntity } from '@/generic-helpers';
import type { ListQuery } from '@/requests';
import { Telemetry } from '@/telemetry';
type TestDefinitionLike = Omit<
Partial<TestDefinition>,
'workflow' | 'evaluationWorkflow' | 'annotationTag' | 'metrics'
> & {
workflow?: { id: string };
evaluationWorkflow?: { id: string };
annotationTag?: { id: string };
};
@Service()
export class TestDefinitionService {
constructor(
private testDefinitionRepository: TestDefinitionRepository,
private annotationTagRepository: AnnotationTagRepository,
private telemetry: Telemetry,
) {}
private toEntityLike(attrs: {
name?: string;
description?: string;
workflowId?: string;
evaluationWorkflowId?: string;
annotationTagId?: string;
id?: string;
mockedNodes?: MockedNodeItem[];
}) {
const entity: TestDefinitionLike = {};
if (attrs.id) {
entity.id = attrs.id;
}
if (attrs.name) {
entity.name = attrs.name?.trim();
}
if (attrs.description) {
entity.description = attrs.description.trim();
}
if (attrs.workflowId) {
entity.workflow = {
id: attrs.workflowId,
};
}
if (attrs.evaluationWorkflowId) {
entity.evaluationWorkflow = {
id: attrs.evaluationWorkflowId,
};
}
if (attrs.annotationTagId) {
entity.annotationTag = {
id: attrs.annotationTagId,
};
}
if (attrs.mockedNodes) {
entity.mockedNodes = attrs.mockedNodes;
}
return entity;
}
toEntity(attrs: {
name?: string;
workflowId?: string;
evaluationWorkflowId?: string;
annotationTagId?: string;
id?: string;
}) {
const entity = this.toEntityLike(attrs);
return this.testDefinitionRepository.create(entity);
}
async findOne(id: string, accessibleWorkflowIds: string[]) {
return await this.testDefinitionRepository.getOne(id, accessibleWorkflowIds);
}
async save(test: TestDefinition) {
await validateEntity(test);
return await this.testDefinitionRepository.save(test);
}
async update(id: string, attrs: TestDefinitionLike) {
const existingTestDefinition = await this.testDefinitionRepository.findOneOrFail({
where: {
id,
},
relations: ['workflow'],
});
if (attrs.name) {
const updatedTest = this.toEntity(attrs);
await validateEntity(updatedTest);
}
// Check if the annotation tag exists
if (attrs.annotationTagId) {
const annotationTagExists = await this.annotationTagRepository.exists({
where: {
id: attrs.annotationTagId,
},
});
if (!annotationTagExists) {
throw new BadRequestError('Annotation tag not found');
}
}
// If there are mocked nodes, validate them
if (attrs.mockedNodes && attrs.mockedNodes.length > 0) {
const existingNodeNames = new Map(
existingTestDefinition.workflow.nodes.map((n) => [n.name, n]),
);
const existingNodeIds = new Map(existingTestDefinition.workflow.nodes.map((n) => [n.id, n]));
// If some node was previously mocked and then removed from the workflow, it should be removed from the mocked nodes
attrs.mockedNodes = attrs.mockedNodes.filter(
(node) => existingNodeIds.has(node.id) || (node.name && existingNodeNames.has(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
const queryResult = await this.testDefinitionRepository.update(id, this.toEntityLike(attrs));
if (queryResult.affected === 0) {
throw new NotFoundError('Test definition not found');
}
// Send the telemetry events
if (attrs.annotationTagId && attrs.annotationTagId !== existingTestDefinition.annotationTagId) {
this.telemetry.track('User added tag to test', {
test_id: id,
tag_id: attrs.annotationTagId,
});
}
if (
attrs.evaluationWorkflowId &&
existingTestDefinition.evaluationWorkflowId !== attrs.evaluationWorkflowId
) {
this.telemetry.track('User added evaluation workflow to test', {
test_id: id,
subworkflow_id: attrs.evaluationWorkflowId,
});
}
}
async delete(id: string, accessibleWorkflowIds: string[]) {
const deleteResult = await this.testDefinitionRepository.deleteById(id, accessibleWorkflowIds);
if (deleteResult.affected === 0) {
throw new NotFoundError('Test definition not found');
}
this.telemetry.track('User deleted a test', { test_id: id });
}
async getMany(options: ListQuery.Options, accessibleWorkflowIds: string[] = []) {
return await this.testDefinitionRepository.getMany(accessibleWorkflowIds, options);
}
}

View File

@@ -1,170 +0,0 @@
import { Get, Post, Patch, RestController, Delete } from '@n8n/decorators';
import express from 'express';
import { UserError } from 'n8n-workflow';
import assert from 'node:assert';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import {
testDefinitionCreateRequestBodySchema,
testDefinitionPatchRequestBodySchema,
} from '@/evaluation.ee/test-definition.schema';
import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee';
import { listQueryMiddleware } from '@/middlewares';
import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service';
import { TestDefinitionService } from './test-definition.service.ee';
import { TestDefinitionsRequest } from './test-definitions.types.ee';
@RestController('/evaluation/test-definitions')
export class TestDefinitionsController {
constructor(
private readonly testDefinitionService: TestDefinitionService,
private readonly testRunnerService: TestRunnerService,
) {}
@Get('/', { middlewares: listQueryMiddleware })
async getMany(req: TestDefinitionsRequest.GetMany) {
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
try {
return await this.testDefinitionService.getMany(
req.listQueryOptions,
userAccessibleWorkflowIds,
);
} catch (error) {
if (error instanceof UserError) throw new ForbiddenError(error.message);
throw error;
}
}
@Get('/:id')
async getOne(req: TestDefinitionsRequest.GetOne) {
const { id: testDefinitionId } = req.params;
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
const testDefinition = await this.testDefinitionService.findOne(
testDefinitionId,
userAccessibleWorkflowIds,
);
if (!testDefinition) throw new NotFoundError('Test definition not found');
return testDefinition;
}
@Post('/')
async create(req: TestDefinitionsRequest.Create, res: express.Response) {
const bodyParseResult = testDefinitionCreateRequestBodySchema.safeParse(req.body);
if (!bodyParseResult.success) {
res.status(400).json({ errors: bodyParseResult.error.errors });
return;
}
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
if (!userAccessibleWorkflowIds.includes(req.body.workflowId)) {
throw new ForbiddenError('User does not have access to the workflow');
}
if (
req.body.evaluationWorkflowId &&
!userAccessibleWorkflowIds.includes(req.body.evaluationWorkflowId)
) {
throw new ForbiddenError('User does not have access to the evaluation workflow');
}
return await this.testDefinitionService.save(
this.testDefinitionService.toEntity(bodyParseResult.data),
);
}
@Delete('/:id')
async delete(req: TestDefinitionsRequest.Delete) {
const { id: testDefinitionId } = req.params;
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
if (userAccessibleWorkflowIds.length === 0)
throw new ForbiddenError('User does not have access to any workflows');
await this.testDefinitionService.delete(testDefinitionId, userAccessibleWorkflowIds);
return { success: true };
}
@Patch('/:id')
async patch(req: TestDefinitionsRequest.Patch, res: express.Response) {
const { id: testDefinitionId } = req.params;
const bodyParseResult = testDefinitionPatchRequestBodySchema.safeParse(req.body);
if (!bodyParseResult.success) {
res.status(400).json({ errors: bodyParseResult.error.errors });
return;
}
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
// Fail fast if no workflows are accessible
if (userAccessibleWorkflowIds.length === 0)
throw new ForbiddenError('User does not have access to any workflows');
const existingTest = await this.testDefinitionService.findOne(
testDefinitionId,
userAccessibleWorkflowIds,
);
if (!existingTest) throw new NotFoundError('Test definition not found');
if (
req.body.evaluationWorkflowId &&
!userAccessibleWorkflowIds.includes(req.body.evaluationWorkflowId)
) {
throw new ForbiddenError('User does not have access to the evaluation workflow');
}
await this.testDefinitionService.update(testDefinitionId, req.body);
// Respond with the updated test definition
const testDefinition = await this.testDefinitionService.findOne(
testDefinitionId,
userAccessibleWorkflowIds,
);
assert(testDefinition, 'Test definition not found');
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 });
}
@Get('/:id/example-evaluation-input')
async exampleEvaluationInput(req: TestDefinitionsRequest.ExampleEvaluationInput) {
const { id: testDefinitionId } = req.params;
const { annotationTagId } = req.query;
const workflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
const testDefinition = await this.testDefinitionService.findOne(testDefinitionId, workflowIds);
if (!testDefinition) throw new NotFoundError('Test definition not found');
return await this.testRunnerService.getExampleEvaluationInputData(
testDefinition,
annotationTagId,
);
}
}

View File

@@ -1,77 +0,0 @@
import type { MockedNodeItem } from '@n8n/db';
import type { AuthenticatedRequest, ListQuery } from '@/requests';
// ----------------------------------
// /test-definitions
// ----------------------------------
export declare namespace TestDefinitionsRequest {
namespace RouteParams {
type TestId = {
id: string;
};
}
type GetOne = AuthenticatedRequest<RouteParams.TestId>;
type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params> & {
listQueryOptions: ListQuery.Options;
};
type Create = AuthenticatedRequest<
{},
{},
{ name: string; workflowId: string; evaluationWorkflowId?: string }
>;
type Patch = AuthenticatedRequest<
RouteParams.TestId,
{},
{
name?: string;
evaluationWorkflowId?: string;
annotationTagId?: string;
mockedNodes?: MockedNodeItem[];
}
>;
type Delete = AuthenticatedRequest<RouteParams.TestId>;
type Run = AuthenticatedRequest<RouteParams.TestId>;
type ExampleEvaluationInput = AuthenticatedRequest<
RouteParams.TestId,
{},
{},
{ annotationTagId: string }
>;
}
// ----------------------------------
// /test-definitions/:testDefinitionId/runs
// ----------------------------------
export declare namespace TestRunsRequest {
namespace RouteParams {
type TestId = {
testDefinitionId: string;
};
type TestRunId = {
id: string;
};
}
type GetMany = AuthenticatedRequest<RouteParams.TestId, {}, {}, ListQuery.Params> & {
listQueryOptions: ListQuery.Options;
};
type GetOne = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>;
type Delete = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>;
type Cancel = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>;
type GetCases = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>;
}

View File

@@ -2,8 +2,7 @@ import { EvaluationMetrics } from '../evaluation-metrics.ee';
describe('EvaluationMetrics', () => { describe('EvaluationMetrics', () => {
test('should aggregate metrics correctly', () => { test('should aggregate metrics correctly', () => {
const testMetricNames = new Set(['metric1', 'metric2']); const metrics = new EvaluationMetrics();
const metrics = new EvaluationMetrics(testMetricNames);
metrics.addResults({ metric1: 1, metric2: 0 }); metrics.addResults({ metric1: 1, metric2: 0 });
metrics.addResults({ metric1: 0.5, metric2: 0.2 }); metrics.addResults({ metric1: 0.5, metric2: 0.2 });
@@ -14,8 +13,7 @@ describe('EvaluationMetrics', () => {
}); });
test('should throw when metric value is not number', () => { test('should throw when metric value is not number', () => {
const testMetricNames = new Set(['metric1', 'metric2']); const metrics = new EvaluationMetrics();
const metrics = new EvaluationMetrics(testMetricNames);
expect(() => metrics.addResults({ metric1: 1, metric2: 0 })).not.toThrow(); expect(() => metrics.addResults({ metric1: 1, metric2: 0 })).not.toThrow();
expect(() => metrics.addResults({ metric1: '0.5', metric2: 0.2 })).toThrow('INVALID_METRICS'); expect(() => metrics.addResults({ metric1: '0.5', metric2: 0.2 })).toThrow('INVALID_METRICS');
@@ -25,49 +23,21 @@ describe('EvaluationMetrics', () => {
}); });
test('should handle empty metrics', () => { test('should handle empty metrics', () => {
const testMetricNames = new Set(['metric1', 'metric2']); const metrics = new EvaluationMetrics();
const metrics = new EvaluationMetrics(testMetricNames);
const aggregatedMetrics = metrics.getAggregatedMetrics(); const aggregatedMetrics = metrics.getAggregatedMetrics();
expect(aggregatedMetrics).toEqual({}); expect(aggregatedMetrics).toEqual({});
}); });
test('should handle empty testMetrics', () => {
const metrics = new EvaluationMetrics(new Set());
metrics.addResults({ metric1: 1, metric2: 0 });
metrics.addResults({ metric1: 0.5, metric2: 0.2 });
const aggregatedMetrics = metrics.getAggregatedMetrics();
expect(aggregatedMetrics).toEqual({});
});
test('should ignore non-relevant values', () => {
const testMetricNames = new Set(['metric1']);
const metrics = new EvaluationMetrics(testMetricNames);
metrics.addResults({ metric1: 1, notRelevant: 0 });
metrics.addResults({ metric1: 0.5, notRelevant2: { foo: 'bar' } });
const aggregatedMetrics = metrics.getAggregatedMetrics();
expect(aggregatedMetrics).toEqual({ metric1: 0.75 });
});
test('should report info on added metrics', () => { test('should report info on added metrics', () => {
const testMetricNames = new Set(['metric1']); const metrics = new EvaluationMetrics();
const metrics = new EvaluationMetrics(testMetricNames);
let info; let info;
expect(() => (info = metrics.addResults({ metric1: 1, metric2: 0 }))).not.toThrow(); expect(() => (info = metrics.addResults({ metric1: 1, metric2: 0 }))).not.toThrow();
expect(info).toBeDefined(); expect(info).toBeDefined();
expect(info).toHaveProperty('unknownMetrics');
expect(info!.unknownMetrics).toEqual(new Set(['metric2']));
expect(info).toHaveProperty('addedMetrics'); expect(info).toHaveProperty('addedMetrics');
expect(info!.addedMetrics).toEqual({ metric1: 1 }); expect(info!.addedMetrics).toEqual({ metric1: 1, metric2: 0 });
}); });
}); });

View File

@@ -1,84 +0,0 @@
import { readFileSync } from 'fs';
import { mock } from 'jest-mock-extended';
import path from 'path';
import type { TestCaseRunMetadata } from '@/evaluation.ee/test-runner/test-runner.service.ee';
import { formatTestCaseExecutionInputData } from '@/evaluation.ee/test-runner/utils.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' }),
);
describe('formatTestCaseExecutionInputData', () => {
test('should format the test case execution input data correctly', () => {
const data = formatTestCaseExecutionInputData(
executionDataJson.resultData.runData,
wfUnderTestJson,
executionDataJson.resultData.runData,
wfUnderTestJson,
mock<TestCaseRunMetadata>({
pastExecutionId: 'exec-id',
highlightedData: [],
annotation: {
vote: 'up',
tags: [{ id: 'tag-id', name: 'tag-name' }],
},
}),
);
// Check data have all expected properties
expect(data.json).toMatchObject({
originalExecution: expect.anything(),
newExecution: expect.anything(),
annotations: expect.anything(),
});
// Check original execution contains all the expected nodes
expect(data.json.originalExecution).toHaveProperty('72256d90-3a67-4e29-b032-47df4e5768af');
expect(data.json.originalExecution).toHaveProperty('319f29bc-1dd4-4122-b223-c584752151a4');
expect(data.json.originalExecution).toHaveProperty('d2474215-63af-40a4-a51e-0ea30d762621');
// Check format of specific node data
expect(data.json.originalExecution).toMatchObject({
'72256d90-3a67-4e29-b032-47df4e5768af': {
nodeName: 'When clicking Execute workflow',
runs: [
{
executionTime: 0,
rootNode: true,
output: {
main: [
[
{
query: 'First item',
},
{
query: 'Second item',
},
{
query: 'Third item',
},
],
],
},
},
],
},
});
// Check annotations
expect(data).toMatchObject({
json: {
annotations: {
vote: 'up',
tags: [{ id: 'tag-id', name: 'tag-name' }],
highlightedData: {},
},
},
});
});
});

View File

@@ -4,41 +4,34 @@ import { TestCaseExecutionError } from '@/evaluation.ee/test-runner/errors.ee';
export interface EvaluationMetricsAddResultsInfo { export interface EvaluationMetricsAddResultsInfo {
addedMetrics: Record<string, number>; addedMetrics: Record<string, number>;
missingMetrics: Set<string>;
unknownMetrics: Set<string>;
incorrectTypeMetrics: Set<string>; incorrectTypeMetrics: Set<string>;
} }
export class EvaluationMetrics { export class EvaluationMetrics {
private readonly rawMetricsByName = new Map<string, number[]>(); private readonly rawMetricsByName = new Map<string, number[]>();
constructor(private readonly metricNames: Set<string>) {
for (const metricName of metricNames) {
this.rawMetricsByName.set(metricName, []);
}
}
addResults(result: IDataObject): EvaluationMetricsAddResultsInfo { addResults(result: IDataObject): EvaluationMetricsAddResultsInfo {
const addResultsInfo: EvaluationMetricsAddResultsInfo = { const addResultsInfo: EvaluationMetricsAddResultsInfo = {
addedMetrics: {}, addedMetrics: {},
missingMetrics: new Set<string>(),
unknownMetrics: new Set<string>(),
incorrectTypeMetrics: new Set<string>(), incorrectTypeMetrics: new Set<string>(),
}; };
for (const [metricName, metricValue] of Object.entries(result)) { for (const [metricName, metricValue] of Object.entries(result)) {
if (this.metricNames.has(metricName)) { if (typeof metricValue === 'number') {
if (typeof metricValue === 'number') { addResultsInfo.addedMetrics[metricName] = metricValue;
addResultsInfo.addedMetrics[metricName] = metricValue;
this.rawMetricsByName.get(metricName)!.push(metricValue); // Initialize the array if this is the first time we see this metric
} else { if (!this.rawMetricsByName.has(metricName)) {
throw new TestCaseExecutionError('INVALID_METRICS', { this.rawMetricsByName.set(metricName, []);
metricName,
metricValue,
});
} }
this.rawMetricsByName.get(metricName)!.push(metricValue);
} else { } else {
addResultsInfo.unknownMetrics.add(metricName); addResultsInfo.incorrectTypeMetrics.add(metricName);
throw new TestCaseExecutionError('INVALID_METRICS', {
metricName,
metricValue,
});
} }
} }

View File

@@ -0,0 +1,25 @@
import { TestRunRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { Logger } from 'n8n-core';
/**
* This service is responsible for cleaning up pending Test Runs on application startup.
*/
@Service()
export class TestRunCleanupService {
constructor(
private readonly logger: Logger,
private readonly testRunRepository: TestRunRepository,
) {}
/**
* As Test Runner does not have a recovery mechanism, it can not resume Test Runs interrupted by the server restart.
* All Test Runs in incomplete state will be marked as failed.
*/
async cleanupIncompleteRuns() {
const result = await this.testRunRepository.markAllIncompleteAsFailed();
if (result.affected && result.affected > 0) {
this.logger.debug(`Marked ${result.affected} incomplete test runs as failed`);
}
}
}

View File

@@ -1,53 +1,23 @@
import type { User, ExecutionEntity, MockedNodeItem, TestDefinition, TestRun } from '@n8n/db'; import type { User, TestRun } from '@n8n/db';
import { import { TestCaseExecutionRepository, TestRunRepository, WorkflowRepository } from '@n8n/db';
ExecutionRepository,
TestCaseExecutionRepository,
TestMetricRepository,
TestRunRepository,
WorkflowRepository,
} from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { parse } from 'flatted';
import difference from 'lodash/difference';
import { ErrorReporter, Logger } from 'n8n-core'; import { ErrorReporter, Logger } from 'n8n-core';
import { ExecutionCancelledError, NodeConnectionTypes, Workflow } from 'n8n-workflow'; import { ExecutionCancelledError } from 'n8n-workflow';
import type { import type { IRun, IWorkflowBase, IWorkflowExecutionDataProcess } from 'n8n-workflow';
AssignmentCollectionValue,
IDataObject,
IRun,
IRunExecutionData,
IWorkflowBase,
IWorkflowExecutionDataProcess,
} from 'n8n-workflow';
import assert from 'node:assert'; import assert from 'node:assert';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import config from '@/config'; import config from '@/config';
import { EVALUATION_METRICS_NODE } from '@/constants'; import { EVALUATION_METRICS_NODE } from '@/constants';
import { TestCaseExecutionError, TestRunError } from '@/evaluation.ee/test-runner/errors.ee'; import { TestCaseExecutionError, TestRunError } from '@/evaluation.ee/test-runner/errors.ee';
import { NodeTypes } from '@/node-types';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { getRunData } from '@/workflow-execute-additional-data';
import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowRunner } from '@/workflow-runner';
import { EvaluationMetrics } from './evaluation-metrics.ee';
import {
createPinData,
formatTestCaseExecutionInputData,
getPastExecutionTriggerNode,
} from './utils.ee';
export interface TestRunMetadata { export interface TestRunMetadata {
testRunId: string; testRunId: string;
userId: string; userId: string;
} }
export interface TestCaseRunMetadata extends TestRunMetadata {
pastExecutionId: string;
annotation: ExecutionEntity['annotation'];
highlightedData: ExecutionEntity['metadata'];
}
/** /**
* This service orchestrates the running of test cases. * This service orchestrates the running of test cases.
* It uses the test definitions to find * It uses the test definitions to find
@@ -65,78 +35,33 @@ export class TestRunnerService {
private readonly telemetry: Telemetry, private readonly telemetry: Telemetry,
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
private readonly workflowRunner: WorkflowRunner, private readonly workflowRunner: WorkflowRunner,
private readonly executionRepository: ExecutionRepository,
private readonly activeExecutions: ActiveExecutions, private readonly activeExecutions: ActiveExecutions,
private readonly testRunRepository: TestRunRepository, private readonly testRunRepository: TestRunRepository,
private readonly testCaseExecutionRepository: TestCaseExecutionRepository, private readonly testCaseExecutionRepository: TestCaseExecutionRepository,
private readonly testMetricRepository: TestMetricRepository,
private readonly nodeTypes: NodeTypes,
private readonly errorReporter: ErrorReporter, private readonly errorReporter: ErrorReporter,
) {} ) {}
/**
* As Test Runner does not have a recovery mechanism, it can not resume Test Runs interrupted by the server restart.
* All Test Runs in incomplete state will be marked as cancelled.
*/
async cleanupIncompleteRuns() {
await this.testRunRepository.markAllIncompleteAsFailed();
}
/** /**
* Prepares the start nodes and trigger node data props for the `workflowRunner.run` method input. * Prepares the start nodes and trigger node data props for the `workflowRunner.run` method input.
*/ */
private getStartNodesData( private getStartNodesData(
workflow: IWorkflowBase, workflow: IWorkflowBase,
pastExecutionData: IRunExecutionData, ): Pick<IWorkflowExecutionDataProcess, 'triggerToStartFrom'> {
pastExecutionWorkflowData: IWorkflowBase, // Find the dataset trigger node
): Pick<IWorkflowExecutionDataProcess, 'startNodes' | 'triggerToStartFrom'> { // TODO: replace with dataset trigger node
// Create a new workflow instance to use the helper functions (getChildNodes) const triggerNode = workflow.nodes.find(
const workflowInstance = new Workflow({ (node) => node.type === 'n8n-nodes-base.executeWorkflowTrigger',
nodes: workflow.nodes,
connections: workflow.connections,
active: false,
nodeTypes: this.nodeTypes,
});
// Create a map between node IDs and node names for the past workflow
const pastWorkflowNodeIdByName = new Map(
pastExecutionWorkflowData.nodes.map((node) => [node.name, node.id]),
); );
// Create a map between node names and IDs for the up-to-date workflow
const workflowNodeNameById = new Map(workflow.nodes.map((node) => [node.id, node.name]));
// Determine the trigger node of the past execution
const pastExecutionTriggerNode = getPastExecutionTriggerNode(pastExecutionData);
assert(pastExecutionTriggerNode, 'Could not find the trigger node of the past execution');
const pastExecutionTriggerNodeId = pastWorkflowNodeIdByName.get(pastExecutionTriggerNode);
assert(pastExecutionTriggerNodeId, 'Could not find the trigger node ID of the past execution');
// Check the trigger is still present in the workflow
const triggerNode = workflowNodeNameById.get(pastExecutionTriggerNodeId);
if (!triggerNode) { if (!triggerNode) {
// TODO: Change error
throw new TestCaseExecutionError('TRIGGER_NO_LONGER_EXISTS'); throw new TestCaseExecutionError('TRIGGER_NO_LONGER_EXISTS');
} }
const triggerNodeData = pastExecutionData.resultData.runData[pastExecutionTriggerNode][0];
assert(triggerNodeData, 'Trigger node data not found');
const triggerToStartFrom = { const triggerToStartFrom = {
name: triggerNode, name: triggerNode.name,
data: triggerNodeData,
}; };
// Start nodes are the nodes that are connected to the trigger node
const startNodes = workflowInstance
.getChildNodes(triggerNode, NodeConnectionTypes.Main, 1)
.map((nodeName) => ({
name: nodeName,
sourceData: { previousNode: pastExecutionTriggerNode },
}));
return { return {
startNodes,
triggerToStartFrom, triggerToStartFrom,
}; };
} }
@@ -147,10 +72,7 @@ export class TestRunnerService {
*/ */
private async runTestCase( private async runTestCase(
workflow: IWorkflowBase, workflow: IWorkflowBase,
pastExecutionData: IRunExecutionData, metadata: TestRunMetadata,
pastExecutionWorkflowData: IWorkflowBase,
mockedNodes: MockedNodeItem[],
metadata: TestCaseRunMetadata,
abortSignal: AbortSignal, abortSignal: AbortSignal,
): Promise<IRun | undefined> { ): Promise<IRun | undefined> {
// Do not run if the test run is cancelled // Do not run if the test run is cancelled
@@ -158,19 +80,7 @@ export class TestRunnerService {
return; return;
} }
// Create pin data from the past execution data const startNodesData = this.getStartNodesData(workflow);
const pinData = createPinData(
workflow,
mockedNodes,
pastExecutionData,
pastExecutionWorkflowData,
);
const startNodesData = this.getStartNodesData(
workflow,
pastExecutionData,
pastExecutionWorkflowData,
);
// Prepare the data to run the workflow // Prepare the data to run the workflow
// Evaluation executions should run the same way as manual, // Evaluation executions should run the same way as manual,
@@ -179,8 +89,8 @@ export class TestRunnerService {
...startNodesData, ...startNodesData,
executionMode: 'evaluation', executionMode: 'evaluation',
runData: {}, runData: {},
pinData, // pinData,
workflowData: { ...workflow, pinData }, workflowData: workflow,
userId: metadata.userId, userId: metadata.userId,
partialExecutionVersion: 2, partialExecutionVersion: 2,
}; };
@@ -190,10 +100,10 @@ export class TestRunnerService {
if (config.getEnv('executions.mode') === 'queue') { if (config.getEnv('executions.mode') === 'queue') {
data.executionData = { data.executionData = {
startData: { startData: {
startNodes: startNodesData.startNodes, // startNodes: startNodesData.startNodes,
}, },
resultData: { resultData: {
pinData, // pinData,
runData: {}, runData: {},
}, },
manualData: { manualData: {
@@ -213,96 +123,7 @@ export class TestRunnerService {
this.activeExecutions.stopExecution(executionId); this.activeExecutions.stopExecution(executionId);
}); });
// Update status of the test run execution mapping // TODO: Update status of the test run execution
await this.testCaseExecutionRepository.markAsRunning({
testRunId: metadata.testRunId,
pastExecutionId: metadata.pastExecutionId,
executionId,
});
// Wait for the execution to finish
const executePromise = this.activeExecutions.getPostExecutePromise(executionId);
return await executePromise;
}
/**
* Sync the metrics of the test definition with the evaluation workflow.
*/
async syncMetrics(
testDefinitionId: string,
evaluationWorkflow: IWorkflowBase,
): Promise<Set<string>> {
const usedTestMetricNames = await this.getUsedTestMetricNames(evaluationWorkflow);
const existingTestMetrics = await this.testMetricRepository.find({
where: {
testDefinition: { id: testDefinitionId },
},
});
const existingMetricNames = new Set(existingTestMetrics.map((metric) => metric.name));
const metricsToAdd = difference(
Array.from(usedTestMetricNames),
Array.from(existingMetricNames),
);
const metricsToRemove = difference(
Array.from(existingMetricNames),
Array.from(usedTestMetricNames),
);
// Add new metrics
const metricsToAddEntities = metricsToAdd.map((metricName) =>
this.testMetricRepository.create({
name: metricName,
testDefinition: { id: testDefinitionId },
}),
);
await this.testMetricRepository.save(metricsToAddEntities);
// Remove no longer used metrics
metricsToRemove.forEach(async (metricName) => {
const metric = existingTestMetrics.find((m) => m.name === metricName);
assert(metric, 'Existing metric not found');
await this.testMetricRepository.delete(metric.id);
});
return usedTestMetricNames;
}
/**
* Run the evaluation workflow with the expected and actual run data.
*/
private async runTestCaseEvaluation(
evaluationWorkflow: IWorkflowBase,
evaluationInputData: any,
abortSignal: AbortSignal,
metadata: TestCaseRunMetadata,
) {
// Do not run if the test run is cancelled
if (abortSignal.aborted) {
return;
}
// Prepare the data to run the evaluation workflow
const data = await getRunData(evaluationWorkflow, [evaluationInputData]);
data.executionMode = 'integrated';
// Trigger the evaluation workflow
const executionId = await this.workflowRunner.run(data);
assert(executionId);
// Listen to the abort signal to stop the execution in case test run is cancelled
abortSignal.addEventListener('abort', () => {
this.activeExecutions.stopExecution(executionId);
});
// Update status of the test run execution mapping
await this.testCaseExecutionRepository.markAsEvaluationRunning({
testRunId: metadata.testRunId,
pastExecutionId: metadata.pastExecutionId,
evaluationExecutionId: executionId,
});
// Wait for the execution to finish // Wait for the execution to finish
const executePromise = this.activeExecutions.getPostExecutePromise(executionId); const executePromise = this.activeExecutions.getPostExecutePromise(executionId);
@@ -317,51 +138,18 @@ export class TestRunnerService {
return workflow.nodes.filter((node) => node.type === EVALUATION_METRICS_NODE); return workflow.nodes.filter((node) => node.type === EVALUATION_METRICS_NODE);
} }
/**
* Evaluation result is the first item in the output of the last node
* executed in the evaluation workflow. Defaults to an empty object
* in case the node doesn't produce any output items.
*/
private extractEvaluationResult(execution: IRun, evaluationWorkflow: IWorkflowBase): IDataObject {
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
assert(lastNodeExecuted, 'Could not find the last node executed in evaluation workflow');
const metricsNodes = TestRunnerService.getEvaluationMetricsNodes(evaluationWorkflow);
const metricsRunData = metricsNodes.flatMap(
(node) => execution.data.resultData.runData[node.name],
);
const metricsData = metricsRunData.reverse().map((data) => data.data?.main?.[0]?.[0]?.json);
const metricsResult = metricsData.reduce((acc, curr) => ({ ...acc, ...curr }), {}) ?? {};
return metricsResult;
}
/**
* Get the metrics to collect from the evaluation workflow execution results.
*/
private async getUsedTestMetricNames(evaluationWorkflow: IWorkflowBase) {
const metricsNodes = TestRunnerService.getEvaluationMetricsNodes(evaluationWorkflow);
const metrics = metricsNodes.map((node) => {
const metricsParameter = node.parameters?.metrics as AssignmentCollectionValue;
assert(metricsParameter, 'Metrics parameter not found');
const metricsNames = metricsParameter.assignments.map((assignment) => assignment.name);
return metricsNames;
});
return new Set(metrics.flat());
}
/** /**
* Creates a new test run for the given test definition. * Creates a new test run for the given test definition.
*/ */
async runTest(user: User, test: TestDefinition): Promise<void> { async runTest(user: User, workflowId: string): Promise<void> {
this.logger.debug('Starting new test run', { testId: test.id }); this.logger.debug('Starting new test run', { workflowId });
const workflow = await this.workflowRepository.findById(test.workflowId); const workflow = await this.workflowRepository.findById(workflowId);
assert(workflow, 'Workflow not found'); assert(workflow, 'Workflow not found');
// 0. Create new Test Run // 0. Create new Test Run
const testRun = await this.testRunRepository.createTestRun(test.id); // TODO: Check that createTestRun takes workflowId as an argument
const testRun = await this.testRunRepository.createTestRun(workflowId);
assert(testRun, 'Unable to create a test run'); assert(testRun, 'Unable to create a test run');
// 0.1 Initialize AbortController // 0.1 Initialize AbortController
@@ -378,217 +166,120 @@ export class TestRunnerService {
let testRunEndStatusForTelemetry; let testRunEndStatusForTelemetry;
const abortSignal = abortController.signal; const abortSignal = abortController.signal;
const { manager: dbManager } = this.executionRepository; const { manager: dbManager } = this.testRunRepository;
try { try {
// Get the evaluation workflow
const evaluationWorkflow = await this.workflowRepository.findById(test.evaluationWorkflowId);
if (!evaluationWorkflow) {
throw new TestRunError('EVALUATION_WORKFLOW_NOT_FOUND');
}
/// ///
// 1. Make test cases from previous executions // 1. Make test cases list
/// ///
// Select executions with the annotation tag and workflow ID of the test. // TODO: Get the test cases from the dataset trigger node
// Fetch only ids to reduce the data transfer. const testCases = [{ id: 1 }];
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();
this.logger.debug('Found past executions', { count: pastExecutions.length }); this.logger.debug('Found test cases', { count: testCases.length });
if (pastExecutions.length === 0) { if (testCases.length === 0) {
// TODO: Change error
throw new TestRunError('PAST_EXECUTIONS_NOT_FOUND'); throw new TestRunError('PAST_EXECUTIONS_NOT_FOUND');
} }
// Add all past executions mappings to the test run. // Add all past executions mappings to the test run.
// This will be used to track the status of each test case and keep the connection between test run and all related executions (past, current, and evaluation). // This will be used to track the status of each test case and keep the connection between test run and all related executions (past, current, and evaluation).
await this.testCaseExecutionRepository.createBatch( // await this.testCaseExecutionRepository.createBatch(
testRun.id, // testRun.id,
pastExecutions.map((e) => e.id), // testCases.map((e) => e.id),
); // );
// Sync the metrics of the test definition with the evaluation workflow // TODO: Collect metric names from evaluation nodes of the workflow
const testMetricNames = await this.syncMetrics(test.id, evaluationWorkflow); // const testMetricNames = new Set<string>();
// 2. Run over all the test cases // 2. Run over all the test cases
const pastExecutionIds = pastExecutions.map((e) => e.id); // const pastExecutionIds = pastExecutions.map((e) => e.id);
// Update test run status // Update test run status
await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length); // TODO: mark test run as running
// await this.testRunRepository.markAsRunning(testRun.id);
this.telemetry.track('User ran test', { this.telemetry.track('User ran test', {
user_id: user.id, user_id: user.id,
test_id: test.id,
run_id: testRun.id, run_id: testRun.id,
executions_ids: pastExecutionIds, workflow_id: workflowId,
workflow_id: test.workflowId,
evaluation_workflow_id: test.evaluationWorkflowId,
}); });
// Initialize object to collect the results of the evaluation workflow executions // Initialize object to collect the results of the evaluation workflow executions
const metrics = new EvaluationMetrics(testMetricNames); // const metrics = new EvaluationMetrics();
/// ///
// 2. Run over all the test cases // 2. Run over all the test cases
/// ///
for (const pastExecutionId of pastExecutionIds) { for (const _testCase of testCases) {
if (abortSignal.aborted) { if (abortSignal.aborted) {
this.logger.debug('Test run was cancelled', { this.logger.debug('Test run was cancelled', {
testId: test.id, workflowId,
stoppedOn: pastExecutionId, // stoppedOn: pastExecutionId,
}); });
break; break;
} }
this.logger.debug('Running test case', { pastExecutionId }); this.logger.debug('Running test case');
try { try {
// Fetch past execution with data
const pastExecution = await this.executionRepository.findOne({
where: { id: pastExecutionId },
relations: ['executionData', 'metadata', 'annotation', 'annotation.tags'],
});
assert(pastExecution, 'Execution not found');
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
const testCaseMetadata = { const testCaseMetadata = {
...testRunMetadata, ...testRunMetadata,
pastExecutionId,
highlightedData: pastExecution.metadata,
annotation: pastExecution.annotation,
}; };
// Run the test case and wait for it to finish // Run the test case and wait for it to finish
const testCaseExecution = await this.runTestCase( const testCaseExecution = await this.runTestCase(workflow, testCaseMetadata, abortSignal);
workflow,
executionData,
pastExecution.executionData.workflowData,
test.mockedNodes,
testCaseMetadata,
abortSignal,
);
this.logger.debug('Test case execution finished', { pastExecutionId }); this.logger.debug('Test case execution finished');
// In case of a permission check issue, the test case execution will be undefined. // In case of a permission check issue, the test case execution will be undefined.
// If that happens, or if the test case execution produced an error, mark the test case as failed. // If that happens, or if the test case execution produced an error, mark the test case as failed.
if (!testCaseExecution || testCaseExecution.data.resultData.error) { if (!testCaseExecution || testCaseExecution.data.resultData.error) {
await dbManager.transaction(async (trx) => { // TODO: add failed test case execution to DB
await this.testRunRepository.incrementFailed(testRun.id, trx);
await this.testCaseExecutionRepository.markAsFailed({
testRunId: testRun.id,
pastExecutionId,
errorCode: 'FAILED_TO_EXECUTE_WORKFLOW',
trx,
});
});
continue; continue;
} }
// Collect the results of the test case execution // TODO: extract metrics
const testCaseRunData = testCaseExecution.data.resultData.runData;
// Get the original runData from the test case execution data // Create a new test case execution in DB
const originalRunData = executionData.resultData.runData; // TODO: add successful test case execution to DB
const evaluationInputData = formatTestCaseExecutionInputData(
originalRunData,
pastExecution.executionData.workflowData,
testCaseRunData,
workflow,
testCaseMetadata,
);
// Run the evaluation workflow with the original and new run data
const evalExecution = await this.runTestCaseEvaluation(
evaluationWorkflow,
evaluationInputData,
abortSignal,
testCaseMetadata,
);
assert(evalExecution);
this.logger.debug('Evaluation execution finished', { pastExecutionId });
// Extract the output of the last node executed in the evaluation workflow
const { addedMetrics } = metrics.addResults(
this.extractEvaluationResult(evalExecution, evaluationWorkflow),
);
if (evalExecution.data.resultData.error) {
await dbManager.transaction(async (trx) => {
await this.testRunRepository.incrementFailed(testRun.id, trx);
await this.testCaseExecutionRepository.markAsFailed({
testRunId: testRun.id,
pastExecutionId,
errorCode: 'FAILED_TO_EXECUTE_EVALUATION_WORKFLOW',
trx,
});
});
} else {
await dbManager.transaction(async (trx) => {
await this.testRunRepository.incrementPassed(testRun.id, trx);
await this.testCaseExecutionRepository.markAsCompleted({
testRunId: testRun.id,
pastExecutionId,
metrics: addedMetrics,
trx,
});
});
}
} catch (e) { } catch (e) {
// In case of an unexpected error, increment the failed count and continue with the next test case // FIXME: this is a temporary log
await dbManager.transaction(async (trx) => { this.logger.error('Test case execution failed', {
await this.testRunRepository.incrementFailed(testRun.id, trx); workflowId,
testRunId: testRun.id,
if (e instanceof TestCaseExecutionError) { error: e,
await this.testCaseExecutionRepository.markAsFailed({
testRunId: testRun.id,
pastExecutionId,
errorCode: e.code,
errorDetails: e.extra as IDataObject,
trx,
});
} else {
await this.testCaseExecutionRepository.markAsFailed({
testRunId: testRun.id,
pastExecutionId,
errorCode: 'UNKNOWN_ERROR',
trx,
});
// Report unexpected errors
this.errorReporter.error(e);
}
}); });
// In case of an unexpected error save it as failed test case execution and continue with the next test case
if (e instanceof TestCaseExecutionError) {
// TODO: add failed test case execution to DB
} else {
// TODO: add failed test case execution to DB
// Report unexpected errors
this.errorReporter.error(e);
}
} }
} }
// Mark the test run as completed or cancelled // Mark the test run as completed or cancelled
if (abortSignal.aborted) { if (abortSignal.aborted) {
await dbManager.transaction(async (trx) => { await dbManager.transaction(async (trx) => {
await this.testRunRepository.markAsCancelled(testRun.id, trx); // TODO: mark test run as cancelled
// await this.testRunRepository.markAsCancelled(testRun.id, trx);
await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRun.id, trx); await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRun.id, trx);
testRunEndStatusForTelemetry = 'cancelled'; testRunEndStatusForTelemetry = 'cancelled';
}); });
} else { } else {
const aggregatedMetrics = metrics.getAggregatedMetrics(); // const aggregatedMetrics = metrics.getAggregatedMetrics();
await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics); // TODO: mark test run as completed in DB and save metrics
this.logger.debug('Test run finished', { testId: test.id, testRunId: testRun.id }); this.logger.debug('Test run finished', { workflowId, testRunId: testRun.id });
testRunEndStatusForTelemetry = 'completed'; testRunEndStatusForTelemetry = 'completed';
} }
@@ -600,16 +291,16 @@ export class TestRunnerService {
}); });
await dbManager.transaction(async (trx) => { await dbManager.transaction(async (trx) => {
await this.testRunRepository.markAsCancelled(testRun.id, trx); // TODO: mark test run as cancelled in DB
await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRun.id, trx); await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRun.id, trx);
}); });
testRunEndStatusForTelemetry = 'cancelled'; testRunEndStatusForTelemetry = 'cancelled';
} else if (e instanceof TestRunError) { } else if (e instanceof TestRunError) {
await this.testRunRepository.markAsError(testRun.id, e.code, e.extra as IDataObject); // TODO: mark test run as error
testRunEndStatusForTelemetry = 'error'; testRunEndStatusForTelemetry = 'error';
} else { } else {
await this.testRunRepository.markAsError(testRun.id, 'UNKNOWN_ERROR'); // TODO: mark test run as error
testRunEndStatusForTelemetry = 'error'; testRunEndStatusForTelemetry = 'error';
throw e; throw e;
} }
@@ -619,7 +310,7 @@ export class TestRunnerService {
// Send telemetry event // Send telemetry event
this.telemetry.track('Test run finished', { this.telemetry.track('Test run finished', {
test_id: test.id, workflow_id: workflowId,
run_id: testRun.id, run_id: testRun.id,
status: testRunEndStatusForTelemetry, status: testRunEndStatusForTelemetry,
}); });
@@ -643,69 +334,13 @@ export class TestRunnerService {
abortController.abort(); abortController.abort();
this.abortControllers.delete(testRunId); this.abortControllers.delete(testRunId);
} else { } else {
const { manager: dbManager } = this.executionRepository; const { manager: dbManager } = this.testRunRepository;
// If there is no abort controller - just mark the test run and all its' pending test case executions as cancelled // If there is no abort controller - just mark the test run and all its' pending test case executions as cancelled
await dbManager.transaction(async (trx) => { await dbManager.transaction(async (trx) => {
await this.testRunRepository.markAsCancelled(testRunId, trx); // TODO: mark test run as cancelled in DB
await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRunId, trx); await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRunId, trx);
}); });
} }
} }
/**
* Returns the example evaluation WF input for the test definition.
* It uses the latest execution of a workflow under test as a source and formats it
* the same way as the evaluation input would be formatted.
* We explicitly provide annotation tag here (and DO NOT use the one from DB), because the test definition
* might not be saved to the DB with the updated annotation tag at the moment we need to get the example data.
*/
async getExampleEvaluationInputData(test: TestDefinition, annotationTagId: string) {
// Select the id of latest execution with the annotation tag and workflow ID of the test
const lastPastExecution: Pick<ExecutionEntity, 'id'> | null = await this.executionRepository
.createQueryBuilder('execution')
.select('execution.id')
.leftJoin('execution.annotation', 'annotation')
.leftJoin('annotation.tags', 'annotationTag')
.where('annotationTag.id = :tagId', { tagId: annotationTagId })
.andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId })
.orderBy('execution.createdAt', 'DESC')
.getOne();
if (lastPastExecution === null) {
return null;
}
// Fetch past execution with data
const pastExecution = await this.executionRepository.findOne({
where: {
id: lastPastExecution.id,
},
relations: ['executionData', 'metadata', 'annotation', 'annotation.tags'],
});
assert(pastExecution, 'Execution not found');
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
const sampleTestCaseMetadata = {
testRunId: 'sample-test-run-id',
userId: 'sample-user-id',
pastExecutionId: lastPastExecution.id,
highlightedData: pastExecution.metadata,
annotation: pastExecution.annotation,
};
// Get the original runData from the test case execution data
const originalRunData = executionData.resultData.runData;
// We use the same execution data for the original and new run data format example
const evaluationInputData = formatTestCaseExecutionInputData(
originalRunData,
pastExecution.executionData.workflowData,
originalRunData,
pastExecution.executionData.workflowData,
sampleTestCaseMetadata,
);
return evaluationInputData.json;
}
} }

View File

@@ -1,17 +1,13 @@
import type { MockedNodeItem } from '@n8n/db';
import assert from 'assert'; import assert from 'assert';
import { mapValues, pick } from 'lodash'; import type { IRunExecutionData, IPinData, IWorkflowBase } from 'n8n-workflow';
import type {
IRunExecutionData,
IPinData,
IWorkflowBase,
IRunData,
ITaskData,
INode,
} from 'n8n-workflow';
import { TestCaseExecutionError } from '@/evaluation.ee/test-runner/errors.ee'; import { TestCaseExecutionError } from '@/evaluation.ee/test-runner/errors.ee';
import type { TestCaseRunMetadata } from '@/evaluation.ee/test-runner/test-runner.service.ee';
// Entity representing a node in a workflow under test, for which data should be mocked during test execution
export type MockedNodeItem = {
name?: string;
id: string;
};
/** /**
* Extracts the execution data from the past execution * Extracts the execution data from the past execution
@@ -69,72 +65,3 @@ export function getPastExecutionTriggerNode(executionData: IRunExecutionData) {
return !data[0].source || data[0].source.length === 0 || data[0].source[0] === null; return !data[0].source || data[0].source.length === 0 || data[0].source[0] === null;
}); });
} }
/**
* Function to check if the node is root node or sub-node.
* Sub-node is a node which does not have the main output (the only exception is Stop and Error node)
*/
function isSubNode(node: INode, nodeData: ITaskData[]) {
return (
!node.type.endsWith('stopAndError') &&
nodeData.some((nodeRunData) => !(nodeRunData.data && 'main' in nodeRunData.data))
);
}
/**
* Transform execution data and workflow data into a more user-friendly format to supply to evaluation workflow
*/
function formatExecutionData(data: IRunData, workflow: IWorkflowBase) {
const formattedData = {} as Record<string, any>;
for (const [nodeName, nodeData] of Object.entries(data)) {
const node = workflow.nodes.find((n) => n.name === nodeName);
assert(node, `Node "${nodeName}" not found in the workflow`);
const rootNode = !isSubNode(node, nodeData);
const runs = nodeData.map((nodeRunData) => ({
executionTime: nodeRunData.executionTime,
rootNode,
output: nodeRunData.data
? mapValues(nodeRunData.data, (connections) =>
connections.map((singleOutputData) => singleOutputData?.map((item) => item.json) ?? []),
)
: null,
}));
formattedData[node.id] = { nodeName, runs };
}
return formattedData;
}
/**
* Prepare the evaluation wf input data.
* Provide both the expected data (past execution) and the actual data (new execution),
* as well as any annotations or highlighted data associated with the past execution
*/
export function formatTestCaseExecutionInputData(
originalExecutionData: IRunData,
_originalWorkflowData: IWorkflowBase,
newExecutionData: IRunData,
_newWorkflowData: IWorkflowBase,
metadata: TestCaseRunMetadata,
) {
const annotations = {
vote: metadata.annotation?.vote,
tags: metadata.annotation?.tags?.map((tag) => pick(tag, ['id', 'name'])),
highlightedData: Object.fromEntries(
metadata.highlightedData?.map(({ key, value }) => [key, value]),
),
};
return {
json: {
annotations,
originalExecution: formatExecutionData(originalExecutionData, _originalWorkflowData),
newExecution: formatExecutionData(newExecutionData, _newWorkflowData),
},
};
}

View File

@@ -1,4 +1,5 @@
import { TestCaseExecutionRepository, TestRunRepository } from '@n8n/db'; import { TestCaseExecutionRepository, TestRunRepository } from '@n8n/db';
import type { User } from '@n8n/db';
import { Delete, Get, Post, RestController } from '@n8n/decorators'; import { Delete, Get, Post, RestController } from '@n8n/decorators';
import express from 'express'; import express from 'express';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings } from 'n8n-core';
@@ -7,56 +8,36 @@ import { UnexpectedError } from 'n8n-workflow';
import { ConflictError } from '@/errors/response-errors/conflict.error'; import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { NotImplementedError } from '@/errors/response-errors/not-implemented.error'; import { NotImplementedError } from '@/errors/response-errors/not-implemented.error';
import { TestRunsRequest } from '@/evaluation.ee/test-definitions.types.ee';
import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee';
import { TestRunsRequest } from '@/evaluation.ee/test-runs.types.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';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { TestDefinitionService } from './test-definition.service.ee'; @RestController('/workflows')
@RestController('/evaluation/test-definitions')
export class TestRunsController { export class TestRunsController {
constructor( constructor(
private readonly testDefinitionService: TestDefinitionService,
private readonly testRunRepository: TestRunRepository, private readonly testRunRepository: TestRunRepository,
private readonly workflowFinderService: WorkflowFinderService,
private readonly testCaseExecutionRepository: TestCaseExecutionRepository, private readonly testCaseExecutionRepository: TestCaseExecutionRepository,
private readonly testRunnerService: TestRunnerService, private readonly testRunnerService: TestRunnerService,
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
private readonly telemetry: Telemetry, private readonly telemetry: Telemetry,
) {} ) {}
/**
* This method is used in multiple places in the controller to get the test definition
* (or just check that it exists and the user has access to it).
*/
private async getTestDefinition(
req: TestRunsRequest.GetOne | TestRunsRequest.GetMany | TestRunsRequest.Delete,
) {
const { testDefinitionId } = req.params;
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
const testDefinition = await this.testDefinitionService.findOne(
testDefinitionId,
userAccessibleWorkflowIds,
);
if (!testDefinition) throw new NotFoundError('Test definition not found');
return testDefinition;
}
/** /**
* Get the test run (or just check that it exists and the user has access to it) * Get the test run (or just check that it exists and the user has access to it)
*/ */
private async getTestRun( private async getTestRun(testRunId: string, workflowId: string, user: User) {
req: TestRunsRequest.GetOne | TestRunsRequest.Delete | TestRunsRequest.Cancel, const sharedWorkflowsIds = await getSharedWorkflowIds(user, ['workflow:read']);
) {
const { id: testRunId, testDefinitionId } = req.params; if (!sharedWorkflowsIds.includes(workflowId)) {
throw new NotFoundError('Test run not found');
}
const testRun = await this.testRunRepository.findOne({ const testRun = await this.testRunRepository.findOne({
where: { id: testRunId, testDefinition: { id: testDefinitionId } }, where: { id: testRunId },
}); });
if (!testRun) throw new NotFoundError('Test run not found'); if (!testRun) throw new NotFoundError('Test run not found');
@@ -64,55 +45,50 @@ export class TestRunsController {
return testRun; return testRun;
} }
@Get('/:testDefinitionId/runs', { middlewares: listQueryMiddleware }) @Get('/:workflowId/test-runs', { middlewares: listQueryMiddleware })
async getMany(req: TestRunsRequest.GetMany) { async getMany(req: TestRunsRequest.GetMany) {
const { testDefinitionId } = req.params; const { workflowId } = req.params;
await this.getTestDefinition(req); return await this.testRunRepository.getMany(workflowId, req.listQueryOptions);
return await this.testRunRepository.getMany(testDefinitionId, req.listQueryOptions);
} }
@Get('/:testDefinitionId/runs/:id') @Get('/:workflowId/test-runs/:id')
async getOne(req: TestRunsRequest.GetOne) { async getOne(req: TestRunsRequest.GetOne) {
const { testDefinitionId, id } = req.params; const { id } = req.params;
await this.getTestDefinition(req);
try { try {
return await this.testRunRepository.getTestRunSummaryById(testDefinitionId, id); await this.getTestRun(req.params.id, req.params.workflowId, req.user); // FIXME: do not fetch test run twice
return await this.testRunRepository.getTestRunSummaryById(id);
} catch (error) { } catch (error) {
if (error instanceof UnexpectedError) throw new NotFoundError(error.message); if (error instanceof UnexpectedError) throw new NotFoundError(error.message);
throw error; throw error;
} }
} }
@Get('/:testDefinitionId/runs/:id/cases') @Get('/:workflowId/test-runs/:id/cases')
async getTestCases(req: TestRunsRequest.GetCases) { async getTestCases(req: TestRunsRequest.GetCases) {
await this.getTestDefinition(req); await this.getTestRun(req.params.id, req.params.workflowId, req.user);
await this.getTestRun(req);
return await this.testCaseExecutionRepository.find({ return await this.testCaseExecutionRepository.find({
where: { testRun: { id: req.params.id } }, where: { testRun: { id: req.params.id } },
}); });
} }
@Delete('/:testDefinitionId/runs/:id') @Delete('/:workflowId/test-runs/:id')
async delete(req: TestRunsRequest.Delete) { async delete(req: TestRunsRequest.Delete) {
const { id: testRunId, testDefinitionId } = req.params; const { id: testRunId } = req.params;
// Check test definition and test run exist // Check test run exist
await this.getTestDefinition(req); await this.getTestRun(req.params.id, req.params.workflowId, req.user);
await this.getTestRun(req);
await this.testRunRepository.delete({ id: testRunId }); await this.testRunRepository.delete({ id: testRunId });
this.telemetry.track('User deleted a run', { run_id: testRunId, test_id: testDefinitionId }); this.telemetry.track('User deleted a run', { run_id: testRunId });
return { success: true }; return { success: true };
} }
@Post('/:testDefinitionId/runs/:id/cancel') @Post('/:workflowId/test-runs/:id/cancel')
async cancel(req: TestRunsRequest.Cancel, res: express.Response) { async cancel(req: TestRunsRequest.Cancel, res: express.Response) {
if (this.instanceSettings.isMultiMain) { if (this.instanceSettings.isMultiMain) {
throw new NotImplementedError('Cancelling test runs is not yet supported in multi-main mode'); throw new NotImplementedError('Cancelling test runs is not yet supported in multi-main mode');
@@ -121,8 +97,7 @@ export class TestRunsController {
const { id: testRunId } = req.params; const { id: testRunId } = req.params;
// Check test definition and test run exist // Check test definition and test run exist
await this.getTestDefinition(req); const testRun = await this.getTestRun(req.params.id, req.params.workflowId, req.user);
const testRun = await this.getTestRun(req);
if (this.testRunnerService.canBeCancelled(testRun)) { if (this.testRunnerService.canBeCancelled(testRun)) {
const message = `The test run "${testRunId}" cannot be cancelled`; const message = `The test run "${testRunId}" cannot be cancelled`;
@@ -133,4 +108,25 @@ export class TestRunsController {
res.status(202).json({ success: true }); res.status(202).json({ success: true });
} }
@Post('/:workflowId/test-runs/new')
async create(req: TestRunsRequest.Create, res: express.Response) {
const { workflowId } = req.params;
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, req.user, [
'workflow:read',
]);
if (!workflow) {
// user trying to access a workflow they do not own
// and was not shared to them
// Or does not exist.
return res.status(404).json({ message: 'Not Found' });
}
// We do not await for the test run to complete
void this.testRunnerService.runTest(req.user, workflow.id);
return res.status(202).json({ success: true });
}
} }

View File

@@ -0,0 +1,27 @@
import type { AuthenticatedRequest, ListQuery } from '@/requests';
export declare namespace TestRunsRequest {
namespace RouteParams {
type WorkflowId = {
workflowId: string;
};
type TestRunId = {
id: string;
};
}
type Create = AuthenticatedRequest<RouteParams.WorkflowId>;
type GetMany = AuthenticatedRequest<RouteParams.WorkflowId, {}, {}, ListQuery.Params> & {
listQueryOptions: ListQuery.Options;
};
type GetOne = AuthenticatedRequest<RouteParams.WorkflowId & RouteParams.TestRunId>;
type Delete = AuthenticatedRequest<RouteParams.WorkflowId & RouteParams.TestRunId>;
type Cancel = AuthenticatedRequest<RouteParams.WorkflowId & RouteParams.TestRunId>;
type GetCases = AuthenticatedRequest<RouteParams.WorkflowId & RouteParams.TestRunId>;
}

View File

@@ -4,7 +4,6 @@ import type {
WorkflowEntity, WorkflowEntity,
TagEntity, TagEntity,
AnnotationTagEntity, AnnotationTagEntity,
TestDefinition,
} from '@n8n/db'; } from '@n8n/db';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
@@ -14,7 +13,6 @@ import { BadRequestError } from './errors/response-errors/bad-request.error';
export async function validateEntity( export async function validateEntity(
entity: entity:
| WorkflowEntity | WorkflowEntity
| TestDefinition
| CredentialsEntity | CredentialsEntity
| TagEntity | TagEntity
| AnnotationTagEntity | AnnotationTagEntity

View File

@@ -1,15 +0,0 @@
import { Expose } from 'class-transformer';
import { IsOptional, IsString } from 'class-validator';
import { BaseFilter } from './base.filter.dto';
export class TestDefinitionsFilter extends BaseFilter {
@IsString()
@IsOptional()
@Expose()
workflowId?: string;
static async fromString(rawFilter: string) {
return await this.toFilter(rawFilter, TestDefinitionsFilter);
}
}

View File

@@ -5,7 +5,6 @@ import * as ResponseHelper from '@/response-helper';
import { toError } from '@/utils'; import { toError } from '@/utils';
import { CredentialsFilter } from './dtos/credentials.filter.dto'; import { CredentialsFilter } from './dtos/credentials.filter.dto';
import { TestDefinitionsFilter } from './dtos/test-definitions.filter.dto';
import { UserFilter } from './dtos/user.filter.dto'; import { UserFilter } from './dtos/user.filter.dto';
import { WorkflowFilter } from './dtos/workflow.filter.dto'; import { WorkflowFilter } from './dtos/workflow.filter.dto';
@@ -26,8 +25,6 @@ export const filterListQueryMiddleware = async (
Filter = CredentialsFilter; Filter = CredentialsFilter;
} else if (req.baseUrl.endsWith('users')) { } else if (req.baseUrl.endsWith('users')) {
Filter = UserFilter; Filter = UserFilter;
} else if (req.baseUrl.endsWith('test-definitions')) {
Filter = TestDefinitionsFilter;
} else { } else {
return next(); return next();
} }

View File

@@ -58,7 +58,6 @@ import '@/events/events.controller';
import '@/executions/executions.controller'; import '@/executions/executions.controller';
import '@/external-secrets.ee/external-secrets.controller.ee'; import '@/external-secrets.ee/external-secrets.controller.ee';
import '@/license/license.controller'; import '@/license/license.controller';
import '@/evaluation.ee/test-definitions.controller.ee';
import '@/evaluation.ee/test-runs.controller.ee'; import '@/evaluation.ee/test-runs.controller.ee';
import '@/workflows/workflow-history.ee/workflow-history.controller.ee'; import '@/workflows/workflow-history.ee/workflow-history.controller.ee';
import '@/workflows/workflows.controller'; import '@/workflows/workflows.controller';

View File

@@ -1,505 +0,0 @@
import type { User } from '@n8n/db';
import type { AnnotationTagEntity } from '@n8n/db';
import { TestDefinitionRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mockInstance } from 'n8n-core/test/utils';
import type { IWorkflowBase } from 'n8n-workflow';
import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee';
import { createAnnotationTags } from '@test-integration/db/executions';
import { createUserShell } from './../shared/db/users';
import { createWorkflow } from './../shared/db/workflows';
import * as testDb from './../shared/test-db';
import type { SuperAgentTest } from './../shared/types';
import * as utils from './../shared/utils/';
const testRunner = mockInstance(TestRunnerService);
let authOwnerAgent: SuperAgentTest;
let workflowUnderTest: IWorkflowBase;
let workflowUnderTest2: IWorkflowBase;
let evaluationWorkflow: IWorkflowBase;
let otherWorkflow: IWorkflowBase;
let ownerShell: User;
let annotationTag: AnnotationTagEntity;
const testServer = utils.setupTestServer({ endpointGroups: ['evaluation'] });
beforeAll(async () => {
ownerShell = await createUserShell('global:owner');
authOwnerAgent = testServer.authAgentFor(ownerShell);
});
beforeEach(async () => {
await testDb.truncate(['TestDefinition', 'WorkflowEntity', 'AnnotationTagEntity']);
workflowUnderTest = await createWorkflow({ name: 'workflow-under-test' }, ownerShell);
workflowUnderTest2 = await createWorkflow({ name: 'workflow-under-test-2' }, ownerShell);
evaluationWorkflow = await createWorkflow({ name: 'evaluation-workflow' }, ownerShell);
otherWorkflow = await createWorkflow({ name: 'other-workflow' });
annotationTag = (await createAnnotationTags(['test-tag']))[0];
});
describe('GET /evaluation/test-definitions', () => {
test('should retrieve empty test definitions list', async () => {
const resp = await authOwnerAgent.get('/evaluation/test-definitions');
expect(resp.statusCode).toBe(200);
expect(resp.body.data.count).toBe(0);
expect(resp.body.data.testDefinitions).toHaveLength(0);
});
test('should retrieve test definitions list', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.get('/evaluation/test-definitions');
expect(resp.statusCode).toBe(200);
expect(resp.body.data).toEqual({
count: 1,
testDefinitions: [
expect.objectContaining({
name: 'test',
workflowId: workflowUnderTest.id,
evaluationWorkflowId: null,
}),
],
});
});
test('should retrieve test definitions list with pagination', async () => {
// Add a bunch of test definitions
const testDefinitions = [];
for (let i = 0; i < 15; i++) {
const newTest = Container.get(TestDefinitionRepository).create({
name: `test-${i}`,
workflow: { id: workflowUnderTest.id },
});
testDefinitions.push(newTest);
}
await Container.get(TestDefinitionRepository).save(testDefinitions);
// Fetch the first page
let resp = await authOwnerAgent.get('/evaluation/test-definitions?take=10');
expect(resp.statusCode).toBe(200);
expect(resp.body.data.count).toBe(15);
expect(resp.body.data.testDefinitions).toHaveLength(10);
// Fetch the second page
resp = await authOwnerAgent.get('/evaluation/test-definitions?take=10&skip=10');
expect(resp.statusCode).toBe(200);
expect(resp.body.data.count).toBe(15);
expect(resp.body.data.testDefinitions).toHaveLength(5);
});
test('should retrieve test definitions list for a workflow', async () => {
// Add a bunch of test definitions for two different workflows
const testDefinitions = [];
for (let i = 0; i < 15; i++) {
const newTest = Container.get(TestDefinitionRepository).create({
name: `test-${i}`,
workflow: { id: workflowUnderTest.id },
});
const newTest2 = Container.get(TestDefinitionRepository).create({
name: `test-${i * 2}`,
workflow: { id: workflowUnderTest2.id },
});
testDefinitions.push(newTest, newTest2);
}
await Container.get(TestDefinitionRepository).save(testDefinitions);
// Fetch test definitions of a second workflow
let resp = await authOwnerAgent.get(
`/evaluation/test-definitions?filter=${JSON.stringify({ workflowId: workflowUnderTest2.id })}`,
);
expect(resp.statusCode).toBe(200);
expect(resp.body.data.count).toBe(15);
});
test('should return error if user has no access to the workflowId specified in filter', async () => {
let resp = await authOwnerAgent.get(
`/evaluation/test-definitions?filter=${JSON.stringify({ workflowId: otherWorkflow.id })}`,
);
expect(resp.statusCode).toBe(403);
expect(resp.body.message).toBe('User does not have access to the workflow');
});
});
describe('GET /evaluation/test-definitions/:id', () => {
test('should retrieve test definition', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${newTest.id}`);
expect(resp.statusCode).toBe(200);
expect(resp.body.data.name).toBe('test');
expect(resp.body.data.workflowId).toBe(workflowUnderTest.id);
expect(resp.body.data.evaluationWorkflowId).toBe(null);
});
test('should return 404 for non-existent test definition', async () => {
const resp = await authOwnerAgent.get('/evaluation/test-definitions/123');
expect(resp.statusCode).toBe(404);
});
test('should retrieve test definition with evaluation workflow', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
evaluationWorkflow: { id: evaluationWorkflow.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${newTest.id}`);
expect(resp.statusCode).toBe(200);
expect(resp.body.data.name).toBe('test');
expect(resp.body.data.workflowId).toBe(workflowUnderTest.id);
expect(resp.body.data.evaluationWorkflowId).toBe(evaluationWorkflow.id);
});
test('should not retrieve test definition if user does not have access to workflow under test', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: otherWorkflow.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${newTest.id}`);
expect(resp.statusCode).toBe(404);
});
});
describe('POST /evaluation/test-definitions', () => {
test('should create test definition', async () => {
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
name: 'test',
workflowId: workflowUnderTest.id,
});
expect(resp.statusCode).toBe(200);
expect(resp.body.data.name).toBe('test');
expect(resp.body.data.workflowId).toBe(workflowUnderTest.id);
});
test('should create test definition with evaluation workflow', async () => {
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
name: 'test',
workflowId: workflowUnderTest.id,
evaluationWorkflowId: evaluationWorkflow.id,
});
expect(resp.statusCode).toBe(200);
expect(resp.body.data).toEqual(
expect.objectContaining({
name: 'test',
workflowId: workflowUnderTest.id,
evaluationWorkflowId: evaluationWorkflow.id,
}),
);
});
test('should create test definition with all fields', async () => {
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
name: 'test',
description: 'test description',
workflowId: workflowUnderTest.id,
evaluationWorkflowId: evaluationWorkflow.id,
annotationTagId: annotationTag.id,
});
expect(resp.statusCode).toBe(200);
expect(resp.body.data).toEqual(
expect.objectContaining({
name: 'test',
description: 'test description',
workflowId: workflowUnderTest.id,
evaluationWorkflowId: evaluationWorkflow.id,
annotationTag: expect.objectContaining({
id: annotationTag.id,
}),
}),
);
});
test('should return error if name is empty', async () => {
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
name: '',
workflowId: workflowUnderTest.id,
});
expect(resp.statusCode).toBe(400);
expect(resp.body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'too_small',
path: ['name'],
}),
]),
);
});
test('should return error if user has no access to the workflow', async () => {
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
name: 'test',
workflowId: otherWorkflow.id,
});
expect(resp.statusCode).toBe(403);
expect(resp.body.message).toBe('User does not have access to the workflow');
});
test('should return error if user has no access to the evaluation workflow', async () => {
const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({
name: 'test',
workflowId: workflowUnderTest.id,
evaluationWorkflowId: otherWorkflow.id,
});
expect(resp.statusCode).toBe(403);
expect(resp.body.message).toBe('User does not have access to the evaluation workflow');
});
});
describe('PATCH /evaluation/test-definitions/:id', () => {
test('should update test definition', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
name: 'updated-test',
});
expect(resp.statusCode).toBe(200);
expect(resp.body.data.name).toBe('updated-test');
});
test('should return 404 if user has no access to the workflow', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: otherWorkflow.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
name: 'updated-test',
});
expect(resp.statusCode).toBe(404);
expect(resp.body.message).toBe('Test definition not found');
});
test('should update test definition with evaluation workflow', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
name: 'updated-test',
evaluationWorkflowId: evaluationWorkflow.id,
});
expect(resp.statusCode).toBe(200);
expect(resp.body.data.name).toBe('updated-test');
expect(resp.body.data.evaluationWorkflowId).toBe(evaluationWorkflow.id);
});
test('should return error if user has no access to the evaluation workflow', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
name: 'updated-test',
evaluationWorkflowId: otherWorkflow.id,
});
expect(resp.statusCode).toBe(403);
expect(resp.body.message).toBe('User does not have access to the evaluation workflow');
});
test('should disallow workflowId', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
name: 'updated-test',
workflowId: otherWorkflow.id,
});
expect(resp.statusCode).toBe(400);
expect(resp.body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'unrecognized_keys',
keys: ['workflowId'],
}),
]),
);
});
test('should update annotationTagId', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
annotationTagId: annotationTag.id,
});
expect(resp.statusCode).toBe(200);
expect(resp.body.data.annotationTag.id).toBe(annotationTag.id);
});
test('should return error if annotationTagId is invalid', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
annotationTagId: '123',
});
expect(resp.statusCode).toBe(400);
expect(resp.body.message).toBe('Annotation tag not found');
});
test('should update pinned nodes', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
mockedNodes: [
{
id: 'uuid-1234',
name: 'Schedule Trigger',
},
],
});
expect(resp.statusCode).toBe(200);
expect(resp.body.data.mockedNodes).toEqual([{ id: 'uuid-1234', name: 'Schedule Trigger' }]);
});
test('should return error if pinned nodes are invalid', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
mockedNodes: ['Simple string'],
});
expect(resp.statusCode).toBe(400);
});
test('should return error if pinned nodes are not in the workflow', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
mockedNodes: [
{
name: 'Invalid Node',
},
],
});
expect(resp.statusCode).toBe(400);
});
});
describe('DELETE /evaluation/test-definitions/:id', () => {
test('should delete test definition', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.delete(`/evaluation/test-definitions/${newTest.id}`);
expect(resp.statusCode).toBe(200);
expect(resp.body.data.success).toBe(true);
});
test('should return 404 if test definition does not exist', async () => {
const resp = await authOwnerAgent.delete('/evaluation/test-definitions/123');
expect(resp.statusCode).toBe(404);
expect(resp.body.message).toBe('Test definition not found');
});
test('should return 404 if user has no access to the workflow', async () => {
const newTest = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: otherWorkflow.id },
});
await Container.get(TestDefinitionRepository).save(newTest);
const resp = await authOwnerAgent.delete(`/evaluation/test-definitions/${newTest.id}`);
expect(resp.statusCode).toBe(404);
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);
});
});

View File

@@ -1,7 +1,5 @@
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import type { TestDefinition } from '@n8n/db';
import { ProjectRepository } from '@n8n/db'; import { ProjectRepository } from '@n8n/db';
import { TestDefinitionRepository } from '@n8n/db';
import { TestRunRepository } from '@n8n/db'; import { TestRunRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { mockInstance } from 'n8n-core/test/utils'; import { mockInstance } from 'n8n-core/test/utils';
@@ -17,8 +15,6 @@ import * as utils from '@test-integration/utils';
let authOwnerAgent: SuperAgentTest; let authOwnerAgent: SuperAgentTest;
let workflowUnderTest: IWorkflowBase; let workflowUnderTest: IWorkflowBase;
let otherWorkflow: IWorkflowBase; let otherWorkflow: IWorkflowBase;
let testDefinition: TestDefinition;
let otherTestDefinition: TestDefinition;
let ownerShell: User; let ownerShell: User;
const testRunner = mockInstance(TestRunnerService); const testRunner = mockInstance(TestRunnerService);
@@ -34,83 +30,68 @@ beforeAll(async () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await testDb.truncate(['TestDefinition', 'TestRun', 'WorkflowEntity', 'SharedWorkflow']); await testDb.truncate(['TestRun', 'WorkflowEntity', 'SharedWorkflow']);
workflowUnderTest = await createWorkflow({ name: 'workflow-under-test' }, ownerShell); workflowUnderTest = await createWorkflow({ name: 'workflow-under-test' }, ownerShell);
testDefinition = Container.get(TestDefinitionRepository).create({
name: 'test',
workflow: { id: workflowUnderTest.id },
});
await Container.get(TestDefinitionRepository).save(testDefinition);
otherWorkflow = await createWorkflow({ name: 'other-workflow' }); otherWorkflow = await createWorkflow({ name: 'other-workflow' });
otherTestDefinition = Container.get(TestDefinitionRepository).create({
name: 'other-test',
workflow: { id: otherWorkflow.id },
});
await Container.get(TestDefinitionRepository).save(otherTestDefinition);
}); });
describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => { describe('GET /workflows/:workflowId/test-runs', () => {
test('should retrieve empty list of runs for a test definition', async () => { test('should retrieve empty list of test runs', async () => {
const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${testDefinition.id}/runs`); const resp = await authOwnerAgent.get(`/workflows/${workflowUnderTest.id}/test-runs`);
expect(resp.statusCode).toBe(200); expect(resp.statusCode).toBe(200);
expect(resp.body.data).toEqual([]); expect(resp.body.data).toEqual([]);
}); });
test('should return 404 if test definition does not exist', async () => { // TODO: replace with non existent workflow
const resp = await authOwnerAgent.get('/evaluation/test-definitions/123/runs'); // test('should return 404 if test definition does not exist', async () => {
// const resp = await authOwnerAgent.get('/evaluation/test-definitions/123/runs');
//
// expect(resp.statusCode).toBe(404);
// });
expect(resp.statusCode).toBe(404); // TODO: replace with workflow that is not accessible to the user
}); // test('should return 404 if user does not have access to test definition', async () => {
// const resp = await authOwnerAgent.get(
// `/evaluation/test-definitions/${otherTestDefinition.id}/runs`,
// );
//
// expect(resp.statusCode).toBe(404);
// });
test('should return 404 if user does not have access to test definition', async () => { test('should retrieve list of runs for a workflow', async () => {
const resp = await authOwnerAgent.get(
`/evaluation/test-definitions/${otherTestDefinition.id}/runs`,
);
expect(resp.statusCode).toBe(404);
});
test('should retrieve list of runs for a test definition', async () => {
const testRunRepository = Container.get(TestRunRepository); const testRunRepository = Container.get(TestRunRepository);
const testRun = await testRunRepository.createTestRun(testDefinition.id); const testRun = await testRunRepository.createTestRun(workflowUnderTest.id);
const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${testDefinition.id}/runs`); const resp = await authOwnerAgent.get(`/workflows/${workflowUnderTest.id}/test-runs`);
expect(resp.statusCode).toBe(200); expect(resp.statusCode).toBe(200);
expect(resp.body.data).toEqual([ expect(resp.body.data).toEqual([
expect.objectContaining({ expect.objectContaining({
id: testRun.id, id: testRun.id,
status: 'new', status: 'new',
testDefinitionId: testDefinition.id,
runAt: null, runAt: null,
completedAt: null, completedAt: null,
}), }),
]); ]);
}); });
test('should retrieve list of runs for a test definition with pagination', async () => { test('should retrieve list of test runs for a workflow with pagination', async () => {
const testRunRepository = Container.get(TestRunRepository); const testRunRepository = Container.get(TestRunRepository);
const testRun1 = await testRunRepository.createTestRun(testDefinition.id); const testRun1 = await testRunRepository.createTestRun(workflowUnderTest.id);
// Mark as running just to make a slight delay between the runs // Mark as running just to make a slight delay between the runs
await testRunRepository.markAsRunning(testRun1.id, 10); await testRunRepository.markAsRunning(testRun1.id);
const testRun2 = await testRunRepository.createTestRun(testDefinition.id); const testRun2 = await testRunRepository.createTestRun(workflowUnderTest.id);
// Fetch the first page // Fetch the first page
const resp = await authOwnerAgent.get( const resp = await authOwnerAgent.get(`/workflows/${workflowUnderTest.id}/test-runs?take=1`);
`/evaluation/test-definitions/${testDefinition.id}/runs?take=1`,
);
expect(resp.statusCode).toBe(200); expect(resp.statusCode).toBe(200);
expect(resp.body.data).toEqual([ expect(resp.body.data).toEqual([
expect.objectContaining({ expect.objectContaining({
id: testRun2.id, id: testRun2.id,
status: 'new', status: 'new',
testDefinitionId: testDefinition.id,
runAt: null, runAt: null,
completedAt: null, completedAt: null,
}), }),
@@ -118,7 +99,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => {
// Fetch the second page // Fetch the second page
const resp2 = await authOwnerAgent.get( const resp2 = await authOwnerAgent.get(
`/evaluation/test-definitions/${testDefinition.id}/runs?take=1&skip=1`, `/workflows/${workflowUnderTest.id}/test-runs?take=1&skip=1`,
); );
expect(resp2.statusCode).toBe(200); expect(resp2.statusCode).toBe(200);
@@ -126,7 +107,6 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => {
expect.objectContaining({ expect.objectContaining({
id: testRun1.id, id: testRun1.id,
status: 'running', status: 'running',
testDefinitionId: testDefinition.id,
runAt: expect.any(String), runAt: expect.any(String),
completedAt: null, completedAt: null,
}), }),
@@ -134,13 +114,13 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => {
}); });
}); });
describe('GET /evaluation/test-definitions/:testDefinitionId/runs/:id', () => { describe('GET /workflows/:workflowId/test-runs/:id', () => {
test('should retrieve test run for a test definition', async () => { test('should retrieve specific test run for a workflow', async () => {
const testRunRepository = Container.get(TestRunRepository); const testRunRepository = Container.get(TestRunRepository);
const testRun = await testRunRepository.createTestRun(testDefinition.id); const testRun = await testRunRepository.createTestRun(workflowUnderTest.id);
const resp = await authOwnerAgent.get( const resp = await authOwnerAgent.get(
`/evaluation/test-definitions/${testDefinition.id}/runs/${testRun.id}`, `/workflows/${workflowUnderTest.id}/test-runs/${testRun.id}`,
); );
expect(resp.statusCode).toBe(200); expect(resp.statusCode).toBe(200);
@@ -148,7 +128,6 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs/:id', () => {
expect.objectContaining({ expect.objectContaining({
id: testRun.id, id: testRun.id,
status: 'new', status: 'new',
testDefinitionId: testDefinition.id,
runAt: null, runAt: null,
completedAt: null, completedAt: null,
}), }),
@@ -156,25 +135,21 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs/:id', () => {
}); });
test('should return 404 if test run does not exist', async () => { test('should return 404 if test run does not exist', async () => {
const resp = await authOwnerAgent.get( const resp = await authOwnerAgent.get(`/workflows/${workflowUnderTest.id}/test-runs/123`);
`/evaluation/test-definitions/${testDefinition.id}/runs/123`,
);
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);
}); });
test('should return 404 if user does not have access to test definition', async () => { test('should return 404 if user does not have access to the workflow', async () => {
const testRunRepository = Container.get(TestRunRepository); const testRunRepository = Container.get(TestRunRepository);
const testRun = await testRunRepository.createTestRun(otherTestDefinition.id); const testRun = await testRunRepository.createTestRun(otherWorkflow.id);
const resp = await authOwnerAgent.get( const resp = await authOwnerAgent.get(`/workflows/${otherWorkflow.id}/test-runs/${testRun.id}`);
`/evaluation/test-definitions/${otherTestDefinition.id}/runs/${testRun.id}`,
);
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);
}); });
test('should retrieve test run for a test definition of a shared workflow', async () => { test('should retrieve test run of a shared workflow', async () => {
const memberShell = await createUserShell('global:member'); const memberShell = await createUserShell('global:member');
const memberAgent = testServer.authAgentFor(memberShell); const memberAgent = testServer.authAgentFor(memberShell);
const memberPersonalProject = await Container.get( const memberPersonalProject = await Container.get(
@@ -190,11 +165,11 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs/:id', () => {
// Create a test run for the shared workflow // Create a test run for the shared workflow
const testRunRepository = Container.get(TestRunRepository); const testRunRepository = Container.get(TestRunRepository);
const testRun = await testRunRepository.createTestRun(testDefinition.id); const testRun = await testRunRepository.createTestRun(workflowUnderTest.id);
// Check if member can retrieve the test run of a shared workflow // Check if member can retrieve the test run of a shared workflow
const resp = await memberAgent.get( const resp = await memberAgent.get(
`/evaluation/test-definitions/${testDefinition.id}/runs/${testRun.id}`, `/workflows/${workflowUnderTest.id}/test-runs/${testRun.id}`,
); );
expect(resp.statusCode).toBe(200); expect(resp.statusCode).toBe(200);
@@ -209,10 +184,10 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs/:id', () => {
describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () => { describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () => {
test('should delete test run for a test definition', async () => { test('should delete test run for a test definition', async () => {
const testRunRepository = Container.get(TestRunRepository); const testRunRepository = Container.get(TestRunRepository);
const testRun = await testRunRepository.createTestRun(testDefinition.id); const testRun = await testRunRepository.createTestRun(workflowUnderTest.id);
const resp = await authOwnerAgent.delete( const resp = await authOwnerAgent.delete(
`/evaluation/test-definitions/${testDefinition.id}/runs/${testRun.id}`, `/workflows/${workflowUnderTest.id}/test-runs/${testRun.id}`,
); );
expect(resp.statusCode).toBe(200); expect(resp.statusCode).toBe(200);
@@ -223,19 +198,17 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () =>
}); });
test('should return 404 if test run does not exist', async () => { test('should return 404 if test run does not exist', async () => {
const resp = await authOwnerAgent.delete( const resp = await authOwnerAgent.delete(`/workflows/${workflowUnderTest.id}/test-runs/123`);
`/evaluation/test-definitions/${testDefinition.id}/runs/123`,
);
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);
}); });
test('should return 404 if user does not have access to test definition', async () => { test('should return 404 if user does not have access to test definition', async () => {
const testRunRepository = Container.get(TestRunRepository); const testRunRepository = Container.get(TestRunRepository);
const testRun = await testRunRepository.createTestRun(otherTestDefinition.id); const testRun = await testRunRepository.createTestRun(otherWorkflow.id);
const resp = await authOwnerAgent.delete( const resp = await authOwnerAgent.delete(
`/evaluation/test-definitions/${otherTestDefinition.id}/runs/${testRun.id}`, `/workflows/${otherWorkflow.id}/test-runs/${testRun.id}`,
); );
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);
@@ -245,12 +218,12 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () =>
describe('POST /evaluation/test-definitions/:testDefinitionId/runs/:id/cancel', () => { describe('POST /evaluation/test-definitions/:testDefinitionId/runs/:id/cancel', () => {
test('should cancel test run', async () => { test('should cancel test run', async () => {
const testRunRepository = Container.get(TestRunRepository); const testRunRepository = Container.get(TestRunRepository);
const testRun = await testRunRepository.createTestRun(testDefinition.id); const testRun = await testRunRepository.createTestRun(workflowUnderTest.id);
jest.spyOn(testRunRepository, 'markAsCancelled'); jest.spyOn(testRunRepository, 'markAsCancelled');
const resp = await authOwnerAgent.post( const resp = await authOwnerAgent.post(
`/evaluation/test-definitions/${testDefinition.id}/runs/${testRun.id}/cancel`, `/workflows/${workflowUnderTest.id}/test-runs/${testRun.id}/cancel`,
); );
expect(resp.statusCode).toBe(202); expect(resp.statusCode).toBe(202);
@@ -261,24 +234,24 @@ describe('POST /evaluation/test-definitions/:testDefinitionId/runs/:id/cancel',
test('should return 404 if test run does not exist', async () => { test('should return 404 if test run does not exist', async () => {
const resp = await authOwnerAgent.post( const resp = await authOwnerAgent.post(
`/evaluation/test-definitions/${testDefinition.id}/runs/123/cancel`, `/workflows/${workflowUnderTest.id}/test-runs/123/cancel`,
); );
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);
}); });
test('should return 404 if test definition does not exist', async () => { test('should return 404 if workflow does not exist', async () => {
const resp = await authOwnerAgent.post('/evaluation/test-definitions/123/runs/123/cancel'); const resp = await authOwnerAgent.post('/workflows/123/test-runs/123/cancel');
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);
}); });
test('should return 404 if user does not have access to test definition', async () => { test('should return 404 if user does not have access to the workflow', async () => {
const testRunRepository = Container.get(TestRunRepository); const testRunRepository = Container.get(TestRunRepository);
const testRun = await testRunRepository.createTestRun(otherTestDefinition.id); const testRun = await testRunRepository.createTestRun(otherWorkflow.id);
const resp = await authOwnerAgent.post( const resp = await authOwnerAgent.post(
`/evaluation/test-definitions/${otherTestDefinition.id}/runs/${testRun.id}/cancel`, `/workflows/${otherWorkflow.id}/test-runs/${testRun.id}/cancel`,
); );
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);

View File

@@ -283,7 +283,6 @@ export const setupTestServer = ({
break; break;
case 'evaluation': case 'evaluation':
await import('@/evaluation.ee/test-definitions.controller.ee');
await import('@/evaluation.ee/test-runs.controller.ee'); await import('@/evaluation.ee/test-runs.controller.ee');
break; break;

View File

@@ -52,9 +52,6 @@ export interface TestRunRecord {
errorCode?: string; errorCode?: string;
errorDetails?: Record<string, unknown>; errorDetails?: Record<string, unknown>;
finalResult?: 'success' | 'error' | 'warning'; finalResult?: 'success' | 'error' | 'warning';
failedCases?: number;
passedCases?: number;
totalCases?: number;
} }
interface GetTestRunParams { interface GetTestRunParams {

View File

@@ -89,7 +89,7 @@ const runSummaries = computed(() => {
class="mr-2xs" class="mr-2xs"
/> />
<template v-if="row.status === 'error'"> <template v-if="row.status === 'error'">
{{ row.failedCases }} {{ row.status }} {{ row.status }}
</template> </template>
<template v-else> <template v-else>
{{ row.status }} {{ row.status }}

View File

@@ -74,9 +74,6 @@ const TEST_RUN: TestRunRecord = {
updatedAt: '2024-01-01', updatedAt: '2024-01-01',
runAt: '2024-01-01', runAt: '2024-01-01',
completedAt: '2024-01-01', completedAt: '2024-01-01',
failedCases: 0,
totalCases: 1,
passedCases: 1,
}; };
describe('testDefinition.store.ee', () => { describe('testDefinition.store.ee', () => {

View File

@@ -85,9 +85,6 @@ describe('TestDefinitionEditView', () => {
createdAt: '2023-01-01', createdAt: '2023-01-01',
updatedAt: '2023-01-01', updatedAt: '2023-01-01',
completedAt: '2023-01-01', completedAt: '2023-01-01',
failedCases: 0,
passedCases: 1,
totalCases: 1,
}, },
}; };