feat(core): Keep track of test case executions during test run (no-changelog) (#12787)

This commit is contained in:
Eugene 2025-01-31 14:27:35 +03:00 committed by GitHub
parent d9d64083d3
commit 1ca6a9799a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 463 additions and 124 deletions

View file

@ -20,6 +20,7 @@ import { Settings } from './settings';
import { SharedCredentials } from './shared-credentials'; 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 { TestDefinition } from './test-definition.ee'; import { TestDefinition } from './test-definition.ee';
import { TestMetric } from './test-metric.ee'; import { TestMetric } from './test-metric.ee';
import { TestRun } from './test-run.ee'; import { TestRun } from './test-run.ee';
@ -64,4 +65,5 @@ export const entities = {
TestDefinition, TestDefinition,
TestMetric, TestMetric,
TestRun, TestRun,
TestCaseExecution,
}; };

View file

@ -0,0 +1,68 @@
import { Column, Entity, ManyToOne, OneToOne } from '@n8n/typeorm';
import {
datetimeColumnType,
jsonColumnType,
WithStringId,
} from '@/databases/entities/abstract-entity';
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
import { TestRun } from '@/databases/entities/test-run.ee';
export type TestCaseRunMetrics = Record<string, number | boolean>;
/**
* 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
* 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.
*/
@Entity({ name: 'test_case_execution' })
export class TestCaseExecution extends WithStringId {
@ManyToOne('TestRun')
testRun: TestRun;
@ManyToOne('ExecutionEntity', {
onDelete: 'SET NULL',
nullable: true,
})
pastExecution: ExecutionEntity | null;
@Column({ type: 'varchar', nullable: true })
pastExecutionId: string | null;
@OneToOne('ExecutionEntity', {
onDelete: 'SET NULL',
nullable: true,
})
execution: ExecutionEntity | null;
@Column({ type: 'varchar', nullable: true })
executionId: string | null;
@OneToOne('ExecutionEntity', {
onDelete: 'SET NULL',
nullable: true,
})
evaluationExecution: ExecutionEntity | null;
@Column({ type: 'varchar', nullable: true })
evaluationExecutionId: string | null;
@Column()
status: 'new' | 'running' | 'evaluation_running' | 'success' | 'error' | 'cancelled';
@Column({ type: datetimeColumnType, nullable: true })
runAt: Date | null;
@Column({ type: datetimeColumnType, nullable: true })
completedAt: Date | null;
@Column('varchar', { nullable: true })
errorCode: string | null;
@Column(jsonColumnType, { nullable: true })
errorDetails: Record<string, unknown>;
@Column(jsonColumnType, { nullable: true })
metrics: TestCaseRunMetrics;
}

View file

@ -0,0 +1,47 @@
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
const testCaseExecutionTableName = 'test_case_execution';
export class CreateTestCaseExecutionTable1736947513045 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(testCaseExecutionTableName)
.withColumns(
column('id').varchar(36).primary.notNull,
column('testRunId').varchar(36).notNull,
column('pastExecutionId').int, // Might be null if execution was deleted after the test run
column('executionId').int, // Execution of the workflow under test. Might be null if execution was deleted after the test run
column('evaluationExecutionId').int, // Execution of the evaluation workflow. Might be null if execution was deleted after the test run, or if the test run was cancelled
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('pastExecutionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
})
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
})
.withForeignKey('evaluationExecutionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'SET NULL',
}).withTimestamps;
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(testCaseExecutionTableName);
}
}

View file

@ -77,6 +77,7 @@ import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRu
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition'; import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable'; import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun'; import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable';
export const mysqlMigrations: Migration[] = [ export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -156,4 +157,5 @@ export const mysqlMigrations: Migration[] = [
AddManagedColumnToCredentialsTable1734479635324, AddManagedColumnToCredentialsTable1734479635324,
AddProjectIcons1729607673469, AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779, AddStatsColumnsToTestRun1736172058779,
CreateTestCaseExecutionTable1736947513045,
]; ];

View file

@ -77,6 +77,7 @@ import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRu
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition'; import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable'; import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun'; import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable';
export const postgresMigrations: Migration[] = [ export const postgresMigrations: Migration[] = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -156,4 +157,5 @@ export const postgresMigrations: Migration[] = [
AddManagedColumnToCredentialsTable1734479635324, AddManagedColumnToCredentialsTable1734479635324,
AddProjectIcons1729607673469, AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779, AddStatsColumnsToTestRun1736172058779,
CreateTestCaseExecutionTable1736947513045,
]; ];

View file

@ -74,6 +74,7 @@ import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRu
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition'; import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable'; import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun'; import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable';
const sqliteMigrations: Migration[] = [ const sqliteMigrations: Migration[] = [
InitialMigration1588102412422, InitialMigration1588102412422,
@ -150,6 +151,7 @@ const sqliteMigrations: Migration[] = [
AddManagedColumnToCredentialsTable1734479635324, AddManagedColumnToCredentialsTable1734479635324,
AddProjectIcons1729607673469, AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779, AddStatsColumnsToTestRun1736172058779,
CreateTestCaseExecutionTable1736947513045,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -0,0 +1,99 @@
import { Service } from '@n8n/di';
import type { EntityManager } from '@n8n/typeorm';
import { DataSource, In, Not, Repository } from '@n8n/typeorm';
import type { DeepPartial } from '@n8n/typeorm/common/DeepPartial';
import { TestCaseExecution } from '@/databases/entities/test-case-execution.ee';
@Service()
export class TestCaseExecutionRepository extends Repository<TestCaseExecution> {
constructor(dataSource: DataSource) {
super(TestCaseExecution, dataSource.manager);
}
async createBatch(testRunId: string, pastExecutionIds: string[]) {
const mappings = this.create(
pastExecutionIds.map<DeepPartial<TestCaseExecution>>((id) => ({
testRun: {
id: testRunId,
},
pastExecution: {
id,
},
status: 'new',
})),
);
return await this.save(mappings);
}
async markAsRunning(testRunId: string, pastExecutionId: string, executionId: string) {
return await this.update(
{ testRun: { id: testRunId }, pastExecutionId },
{
status: 'running',
executionId,
runAt: new Date(),
},
);
}
async markAsEvaluationRunning(
testRunId: string,
pastExecutionId: string,
evaluationExecutionId: string,
) {
return await this.update(
{ testRun: { id: testRunId }, pastExecutionId },
{
status: 'evaluation_running',
evaluationExecutionId,
},
);
}
async markAsCompleted(
testRunId: string,
pastExecutionId: string,
metrics: Record<string, number>,
trx?: EntityManager,
) {
trx = trx ?? this.manager;
return await trx.update(
TestCaseExecution,
{ testRun: { id: testRunId }, pastExecutionId },
{
status: 'success',
completedAt: new Date(),
metrics,
},
);
}
async markAllPendingAsCancelled(testRunId: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.update(
TestCaseExecution,
{ testRun: { id: testRunId }, status: Not(In(['success', 'error', 'cancelled'])) },
{
status: 'cancelled',
completedAt: new Date(),
},
);
}
async markAsFailed(testRunId: string, pastExecutionId: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.update(
TestCaseExecution,
{ testRun: { id: testRunId }, pastExecutionId },
{
status: 'error',
completedAt: new Date(),
},
);
}
}

View file

@ -1,5 +1,5 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { FindManyOptions } from '@n8n/typeorm'; import type { EntityManager, FindManyOptions } from '@n8n/typeorm';
import { DataSource, Repository } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm';
import type { AggregatedTestRunMetrics } from '@/databases/entities/test-run.ee'; import type { AggregatedTestRunMetrics } from '@/databases/entities/test-run.ee';
@ -35,16 +35,19 @@ export class TestRunRepository extends Repository<TestRun> {
return await this.update(id, { status: 'completed', completedAt: new Date(), metrics }); return await this.update(id, { status: 'completed', completedAt: new Date(), metrics });
} }
async markAsCancelled(id: string) { async markAsCancelled(id: string, trx?: EntityManager) {
return await this.update(id, { status: 'cancelled' }); trx = trx ?? this.manager;
return await trx.update(TestRun, id, { status: 'cancelled' });
} }
async incrementPassed(id: string) { async incrementPassed(id: string, trx?: EntityManager) {
return await this.increment({ id }, 'passedCases', 1); trx = trx ?? this.manager;
return await trx.increment(TestRun, { id }, 'passedCases', 1);
} }
async incrementFailed(id: string) { async incrementFailed(id: string, trx?: EntityManager) {
return await this.increment({ id }, 'failedCases', 1); trx = trx ?? this.manager;
return await trx.increment(TestRun, { id }, 'failedCases', 1);
} }
async getMany(testDefinitionId: string, options: ListQuery.Options) { async getMany(testDefinitionId: string, options: ListQuery.Options) {

View file

@ -94,4 +94,6 @@ export declare namespace TestRunsRequest {
type Delete = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>; type Delete = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>;
type Cancel = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>; type Cancel = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>;
type GetCases = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>;
} }

View file

@ -13,6 +13,7 @@ import type { TestMetric } from '@/databases/entities/test-metric.ee';
import type { TestRun } from '@/databases/entities/test-run.ee'; import type { TestRun } from '@/databases/entities/test-run.ee';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { TestCaseExecutionRepository } from '@/databases/repositories/test-case-execution.repository.ee';
import type { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee'; import type { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee';
import type { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import type { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
@ -25,6 +26,10 @@ import { mockNodeTypesData } from '@test-integration/utils/node-types-data';
import { TestRunnerService } from '../test-runner.service.ee'; import { TestRunnerService } from '../test-runner.service.ee';
jest.mock('@/db', () => ({
transaction: (cb: any) => cb(),
}));
const wfUnderTestJson = JSON.parse( const wfUnderTestJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }), readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
); );
@ -147,6 +152,7 @@ describe('TestRunnerService', () => {
const activeExecutions = mock<ActiveExecutions>(); const activeExecutions = mock<ActiveExecutions>();
const testRunRepository = mock<TestRunRepository>(); const testRunRepository = mock<TestRunRepository>();
const testMetricRepository = mock<TestMetricRepository>(); const testMetricRepository = mock<TestMetricRepository>();
const testCaseExecutionRepository = mock<TestCaseExecutionRepository>();
const mockNodeTypes = mockInstance(NodeTypes); const mockNodeTypes = mockInstance(NodeTypes);
mockInstance(LoadNodesAndCredentials, { mockInstance(LoadNodesAndCredentials, {
@ -190,6 +196,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testCaseExecutionRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
errorReporter, errorReporter,
@ -207,6 +214,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testCaseExecutionRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
errorReporter, errorReporter,
@ -247,6 +255,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testCaseExecutionRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
errorReporter, errorReporter,
@ -350,6 +359,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testCaseExecutionRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
errorReporter, errorReporter,
@ -410,6 +420,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testCaseExecutionRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
errorReporter, errorReporter,
@ -466,6 +477,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testCaseExecutionRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
errorReporter, errorReporter,
@ -526,6 +538,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testCaseExecutionRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
errorReporter, errorReporter,
@ -602,6 +615,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testCaseExecutionRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
errorReporter, errorReporter,
@ -629,6 +643,7 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testCaseExecutionRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
errorReporter, errorReporter,
@ -661,9 +676,10 @@ describe('TestRunnerService', () => {
executionRepository, executionRepository,
activeExecutions, activeExecutions,
testRunRepository, testRunRepository,
testCaseExecutionRepository,
testMetricRepository, testMetricRepository,
mockNodeTypes, mockNodeTypes,
mock<ErrorReporter>(), errorReporter,
); );
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({

View file

@ -9,12 +9,17 @@ export class EvaluationMetrics {
} }
} }
addResults(result: IDataObject) { addResults(result: IDataObject): Record<string, number> {
const addedMetrics: Record<string, number> = {};
for (const [metricName, metricValue] of Object.entries(result)) { for (const [metricName, metricValue] of Object.entries(result)) {
if (typeof metricValue === 'number' && this.metricNames.has(metricName)) { if (typeof metricValue === 'number' && this.metricNames.has(metricName)) {
addedMetrics[metricName] = metricValue;
this.rawMetricsByName.get(metricName)!.push(metricValue); this.rawMetricsByName.get(metricName)!.push(metricValue);
} }
} }
return addedMetrics;
} }
getAggregatedMetrics() { getAggregatedMetrics() {

View file

@ -19,9 +19,11 @@ import type { TestRun } from '@/databases/entities/test-run.ee';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { TestCaseExecutionRepository } from '@/databases/repositories/test-case-execution.repository.ee';
import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee'; import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee';
import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import * as Db from '@/db';
import { NodeTypes } from '@/node-types'; import { NodeTypes } from '@/node-types';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { getRunData } from '@/workflow-execute-additional-data'; import { getRunData } from '@/workflow-execute-additional-data';
@ -30,6 +32,15 @@ import { WorkflowRunner } from '@/workflow-runner';
import { EvaluationMetrics } from './evaluation-metrics.ee'; import { EvaluationMetrics } from './evaluation-metrics.ee';
import { createPinData, getPastExecutionTriggerNode } from './utils.ee'; import { createPinData, getPastExecutionTriggerNode } from './utils.ee';
interface TestRunMetadata {
testRunId: string;
userId: string;
}
interface TestCaseRunMetadata extends TestRunMetadata {
pastExecutionId: string;
}
/** /**
* 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
@ -50,6 +61,7 @@ export class TestRunnerService {
private readonly executionRepository: ExecutionRepository, 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 testMetricRepository: TestMetricRepository, private readonly testMetricRepository: TestMetricRepository,
private readonly nodeTypes: NodeTypes, private readonly nodeTypes: NodeTypes,
private readonly errorReporter: ErrorReporter, private readonly errorReporter: ErrorReporter,
@ -105,7 +117,7 @@ export class TestRunnerService {
pastExecutionData: IRunExecutionData, pastExecutionData: IRunExecutionData,
pastExecutionWorkflowData: IWorkflowBase, pastExecutionWorkflowData: IWorkflowBase,
mockedNodes: MockedNodeItem[], mockedNodes: MockedNodeItem[],
userId: string, 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
@ -128,7 +140,7 @@ export class TestRunnerService {
runData: {}, runData: {},
pinData, pinData,
workflowData: { ...workflow, pinData }, workflowData: { ...workflow, pinData },
userId, userId: metadata.userId,
partialExecutionVersion: '1', partialExecutionVersion: '1',
}; };
@ -141,6 +153,13 @@ export class TestRunnerService {
this.activeExecutions.stopExecution(executionId); this.activeExecutions.stopExecution(executionId);
}); });
// Update status of the test run execution mapping
await this.testCaseExecutionRepository.markAsRunning(
metadata.testRunId,
metadata.pastExecutionId,
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);
@ -155,7 +174,7 @@ export class TestRunnerService {
expectedData: IRunData, expectedData: IRunData,
actualData: IRunData, actualData: IRunData,
abortSignal: AbortSignal, abortSignal: AbortSignal,
testRunId?: string, metadata: TestCaseRunMetadata,
) { ) {
// Do not run if the test run is cancelled // Do not run if the test run is cancelled
if (abortSignal.aborted) { if (abortSignal.aborted) {
@ -173,13 +192,6 @@ export class TestRunnerService {
// Prepare the data to run the evaluation workflow // Prepare the data to run the evaluation workflow
const data = await getRunData(evaluationWorkflow, [evaluationInputData]); const data = await getRunData(evaluationWorkflow, [evaluationInputData]);
// FIXME: This is a hack to add the testRunId to the evaluation workflow execution data
// So that we can fetch all execution runs for a test run
if (testRunId && data.executionData) {
data.executionData.resultData.metadata = {
testRunId,
};
}
data.executionMode = 'evaluation'; data.executionMode = 'evaluation';
// Trigger the evaluation workflow // Trigger the evaluation workflow
@ -191,6 +203,13 @@ export class TestRunnerService {
this.activeExecutions.stopExecution(executionId); this.activeExecutions.stopExecution(executionId);
}); });
// Update status of the test run execution mapping
await this.testCaseExecutionRepository.markAsEvaluationRunning(
metadata.testRunId,
metadata.pastExecutionId,
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);
@ -248,9 +267,18 @@ export class TestRunnerService {
const abortController = new AbortController(); const abortController = new AbortController();
this.abortControllers.set(testRun.id, abortController); this.abortControllers.set(testRun.id, abortController);
// 0.2 Initialize metadata
// This will be passed to the test case executions
const testRunMetadata = {
testRunId: testRun.id,
userId: user.id,
};
const abortSignal = abortController.signal; const abortSignal = abortController.signal;
try { try {
///
// 1. Make test cases from previous executions // 1. Make test cases from previous executions
///
// Select executions with the annotation tag and workflow ID of the test. // Select executions with the annotation tag and workflow ID of the test.
// Fetch only ids to reduce the data transfer. // Fetch only ids to reduce the data transfer.
@ -266,13 +294,22 @@ export class TestRunnerService {
this.logger.debug('Found past executions', { count: pastExecutions.length }); this.logger.debug('Found past executions', { count: pastExecutions.length });
// 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).
await this.testCaseExecutionRepository.createBatch(
testRun.id,
pastExecutions.map((e) => e.id),
);
// Get the metrics to collect from the evaluation workflow // Get the metrics to collect from the evaluation workflow
const testMetricNames = await this.getTestMetricNames(test.id); const testMetricNames = await this.getTestMetricNames(test.id);
// 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
await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length); await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length);
this.telemetry.track('User runs test', { this.telemetry.track('User runs test', {
user_id: user.id, user_id: user.id,
test_id: test.id, test_id: test.id,
@ -282,9 +319,13 @@ export class TestRunnerService {
evaluation_workflow_id: test.evaluationWorkflowId, evaluation_workflow_id: test.evaluationWorkflowId,
}); });
// 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(testMetricNames);
///
// 2. Run over all the test cases
///
for (const pastExecutionId of pastExecutionIds) { for (const pastExecutionId of pastExecutionIds) {
if (abortSignal.aborted) { if (abortSignal.aborted) {
this.logger.debug('Test run was cancelled', { this.logger.debug('Test run was cancelled', {
@ -306,13 +347,18 @@ export class TestRunnerService {
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData; const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
const testCaseMetadata = {
...testRunMetadata,
pastExecutionId,
};
// 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, workflow,
executionData, executionData,
pastExecution.executionData.workflowData, pastExecution.executionData.workflowData,
test.mockedNodes, test.mockedNodes,
user.id, testCaseMetadata,
abortSignal, abortSignal,
); );
@ -321,10 +367,18 @@ export class TestRunnerService {
// 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.
// Skip them, increment the failed count and continue with the next test case // Skip them, increment the failed count and continue with the next test case
if (!testCaseExecution) { if (!testCaseExecution) {
await this.testRunRepository.incrementFailed(testRun.id); await Db.transaction(async (trx) => {
await this.testRunRepository.incrementFailed(testRun.id, trx);
await this.testCaseExecutionRepository.markAsFailed(testRun.id, pastExecutionId, trx);
});
continue; continue;
} }
// Update status of the test case execution mapping entry in case of an error
if (testCaseExecution.data.resultData.error) {
await this.testCaseExecutionRepository.markAsFailed(testRun.id, pastExecutionId);
}
// Collect the results of the test case execution // Collect the results of the test case execution
const testCaseRunData = testCaseExecution.data.resultData.runData; const testCaseRunData = testCaseExecution.data.resultData.runData;
@ -337,23 +391,37 @@ export class TestRunnerService {
originalRunData, originalRunData,
testCaseRunData, testCaseRunData,
abortSignal, abortSignal,
testRun.id, testCaseMetadata,
); );
assert(evalExecution); assert(evalExecution);
this.logger.debug('Evaluation execution finished', { pastExecutionId }); this.logger.debug('Evaluation execution finished', { pastExecutionId });
// Extract the output of the last node executed in the evaluation workflow // Extract the output of the last node executed in the evaluation workflow
metrics.addResults(this.extractEvaluationResult(evalExecution)); const addedMetrics = metrics.addResults(this.extractEvaluationResult(evalExecution));
if (evalExecution.data.resultData.error) { if (evalExecution.data.resultData.error) {
await this.testRunRepository.incrementFailed(testRun.id); await Db.transaction(async (trx) => {
await this.testRunRepository.incrementFailed(testRun.id, trx);
await this.testCaseExecutionRepository.markAsFailed(testRun.id, pastExecutionId, trx);
});
} else { } else {
await this.testRunRepository.incrementPassed(testRun.id); await Db.transaction(async (trx) => {
await this.testRunRepository.incrementPassed(testRun.id, trx);
await this.testCaseExecutionRepository.markAsCompleted(
testRun.id,
pastExecutionId,
addedMetrics,
trx,
);
});
} }
} catch (e) { } catch (e) {
// In case of an unexpected error, increment the failed count and continue with the next test case // In case of an unexpected error, increment the failed count and continue with the next test case
await this.testRunRepository.incrementFailed(testRun.id); await Db.transaction(async (trx) => {
await this.testRunRepository.incrementFailed(testRun.id, trx);
await this.testCaseExecutionRepository.markAsFailed(testRun.id, pastExecutionId, trx);
});
this.errorReporter.error(e); this.errorReporter.error(e);
} }
@ -361,7 +429,10 @@ export class TestRunnerService {
// Mark the test run as completed or cancelled // Mark the test run as completed or cancelled
if (abortSignal.aborted) { if (abortSignal.aborted) {
await this.testRunRepository.markAsCancelled(testRun.id); await Db.transaction(async (trx) => {
await this.testRunRepository.markAsCancelled(testRun.id, trx);
await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRun.id, trx);
});
} else { } else {
const aggregatedMetrics = metrics.getAggregatedMetrics(); const aggregatedMetrics = metrics.getAggregatedMetrics();
await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics); await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics);
@ -375,7 +446,10 @@ export class TestRunnerService {
stoppedOn: e.extra?.executionId, stoppedOn: e.extra?.executionId,
}); });
await this.testRunRepository.markAsCancelled(testRun.id); await Db.transaction(async (trx) => {
await this.testRunRepository.markAsCancelled(testRun.id, trx);
await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRun.id, trx);
});
} else { } else {
throw e; throw e;
} }
@ -402,8 +476,11 @@ export class TestRunnerService {
abortController.abort(); abortController.abort();
this.abortControllers.delete(testRunId); this.abortControllers.delete(testRunId);
} else { } else {
// If there is no abort controller - just mark the test run as cancelled // If there is no abort controller - just mark the test run and all its' pending test case executions as cancelled
await this.testRunRepository.markAsCancelled(testRunId); await Db.transaction(async (trx) => {
await this.testRunRepository.markAsCancelled(testRunId, trx);
await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRunId, trx);
});
} }
} }
} }

View file

@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings } from 'n8n-core';
import { TestCaseExecutionRepository } from '@/databases/repositories/test-case-execution.repository.ee';
import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
import { Delete, Get, Post, RestController } from '@/decorators'; import { Delete, Get, Post, RestController } from '@/decorators';
import { ConflictError } from '@/errors/response-errors/conflict.error'; import { ConflictError } from '@/errors/response-errors/conflict.error';
@ -18,6 +19,7 @@ export class TestRunsController {
constructor( constructor(
private readonly testDefinitionService: TestDefinitionService, private readonly testDefinitionService: TestDefinitionService,
private readonly testRunRepository: TestRunRepository, private readonly testRunRepository: TestRunRepository,
private readonly testCaseExecutionRepository: TestCaseExecutionRepository,
private readonly testRunnerService: TestRunnerService, private readonly testRunnerService: TestRunnerService,
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
) {} ) {}
@ -76,6 +78,16 @@ export class TestRunsController {
return await this.getTestRun(req); return await this.getTestRun(req);
} }
@Get('/:testDefinitionId/runs/:id/cases')
async getTestCases(req: TestRunsRequest.GetCases) {
await this.getTestDefinition(req);
await this.getTestRun(req);
return await this.testCaseExecutionRepository.find({
where: { testRun: { id: req.params.id } },
});
}
@Delete('/:testDefinitionId/runs/:id') @Delete('/:testDefinitionId/runs/:id')
async delete(req: TestRunsRequest.Delete) { async delete(req: TestRunsRequest.Delete) {
const { id: testRunId } = req.params; const { id: testRunId } = req.params;

View file

@ -61,6 +61,21 @@ interface DeleteTestRunParams {
runId: string; runId: string;
} }
export interface TestCaseExecutionRecord {
id: string;
testRunId: string;
executionId: string;
pastExecutionId: string;
evaluationExecutionId: string;
status: 'running' | 'completed' | 'error';
createdAt: string;
updatedAt: string;
runAt: string;
metrics?: Record<string, number>;
errorCode?: string;
errorDetails?: Record<string, unknown>;
}
const endpoint = '/evaluation/test-definitions'; const endpoint = '/evaluation/test-definitions';
const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) => const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) =>
`${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`; `${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`;
@ -244,3 +259,19 @@ export const deleteTestRun = async (context: IRestApiContext, params: DeleteTest
getRunsEndpoint(params.testDefinitionId, params.runId), getRunsEndpoint(params.testDefinitionId, params.runId),
); );
}; };
const getRunExecutionsEndpoint = (testDefinitionId: string, runId: string) =>
`${endpoint}/${testDefinitionId}/runs/${runId}/cases`;
// Get all test cases of a test run
export const getTestCaseExecutions = async (
context: IRestApiContext,
testDefinitionId: string,
runId: string,
) => {
return await makeRestApiRequest<TestCaseExecutionRecord[]>(
context,
'GET',
getRunExecutionsEndpoint(testDefinitionId, runId),
);
};

View file

@ -23,8 +23,9 @@ function hasStatus(row: unknown): row is WithStatus {
} }
const statusThemeMap: Record<string, string> = { const statusThemeMap: Record<string, string> = {
new: 'info', new: 'default',
running: 'warning', running: 'warning',
evaluation_running: 'warning',
completed: 'success', completed: 'success',
error: 'danger', error: 'danger',
success: 'success', success: 'success',
@ -34,6 +35,7 @@ const statusThemeMap: Record<string, string> = {
const statusLabelMap: Record<string, string> = { const statusLabelMap: Record<string, string> = {
new: locale.baseText('testDefinition.listRuns.status.new'), new: locale.baseText('testDefinition.listRuns.status.new'),
running: locale.baseText('testDefinition.listRuns.status.running'), running: locale.baseText('testDefinition.listRuns.status.running'),
evaluation_running: locale.baseText('testDefinition.listRuns.status.evaluating'),
completed: locale.baseText('testDefinition.listRuns.status.completed'), completed: locale.baseText('testDefinition.listRuns.status.completed'),
error: locale.baseText('testDefinition.listRuns.status.error'), error: locale.baseText('testDefinition.listRuns.status.error'),
success: locale.baseText('testDefinition.listRuns.status.success'), success: locale.baseText('testDefinition.listRuns.status.success'),
@ -53,11 +55,11 @@ const getCellContent = (column: TestTableColumn<T>, row: T) => {
</script> </script>
<template> <template>
<div v-if="column.route"> <div v-if="column.route?.(row)">
<a v-if="column.openInNewTab" :href="router.resolve(column.route(row)).href" target="_blank"> <a v-if="column.openInNewTab" :href="router.resolve(column.route(row)!).href" target="_blank">
{{ getCellContent(column, row) }} {{ getCellContent(column, row) }}
</a> </a>
<router-link v-else :to="column.route(row)"> <router-link v-else :to="column.route(row)!">
{{ getCellContent(column, row) }} {{ getCellContent(column, row) }}
</router-link> </router-link>
</div> </div>

View file

@ -21,7 +21,7 @@ export type TestTableColumn<TRow> = {
sortable?: boolean; sortable?: boolean;
filters?: Array<{ text: string; value: string }>; filters?: Array<{ text: string; value: string }>;
filterMethod?: (value: string, row: TRow) => boolean; filterMethod?: (value: string, row: TRow) => boolean;
route?: (row: TRow) => RouteLocationRaw; route?: (row: TRow) => RouteLocationRaw | undefined;
sortMethod?: (a: TRow, b: TRow) => number; sortMethod?: (a: TRow, b: TRow) => number;
openInNewTab?: boolean; openInNewTab?: boolean;
formatter?: (row: TRow) => string; formatter?: (row: TRow) => string;

View file

@ -2854,6 +2854,7 @@
"testDefinition.list.loadError": "Failed to load tests", "testDefinition.list.loadError": "Failed to load tests",
"testDefinition.listRuns.status.new": "New", "testDefinition.listRuns.status.new": "New",
"testDefinition.listRuns.status.running": "Running", "testDefinition.listRuns.status.running": "Running",
"testDefinition.listRuns.status.evaluating": "Evaluating",
"testDefinition.listRuns.status.completed": "Completed", "testDefinition.listRuns.status.completed": "Completed",
"testDefinition.listRuns.status.cancelled": "Cancelled", "testDefinition.listRuns.status.cancelled": "Cancelled",
"testDefinition.listRuns.status.error": "Error", "testDefinition.listRuns.status.error": "Error",

View file

@ -2,7 +2,11 @@ import { defineStore } from 'pinia';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useRootStore } from './root.store'; import { useRootStore } from './root.store';
import * as testDefinitionsApi from '@/api/testDefinition.ee'; import * as testDefinitionsApi from '@/api/testDefinition.ee';
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee'; import type {
TestCaseExecutionRecord,
TestDefinitionRecord,
TestRunRecord,
} from '@/api/testDefinition.ee';
import { usePostHog } from './posthog.store'; import { usePostHog } from './posthog.store';
import { STORES, WORKFLOW_EVALUATION_EXPERIMENT } from '@/constants'; import { STORES, WORKFLOW_EVALUATION_EXPERIMENT } from '@/constants';
import { useAnnotationTagsStore } from './tags.store'; import { useAnnotationTagsStore } from './tags.store';
@ -19,6 +23,7 @@ export const useTestDefinitionStore = defineStore(
const fetchedAll = ref(false); const fetchedAll = ref(false);
const metricsById = ref<Record<string, testDefinitionsApi.TestMetricRecord>>({}); const metricsById = ref<Record<string, testDefinitionsApi.TestMetricRecord>>({});
const testRunsById = ref<Record<string, TestRunRecord>>({}); const testRunsById = ref<Record<string, TestRunRecord>>({});
const testCaseExecutionsById = ref<Record<string, TestCaseExecutionRecord>>({});
const pollingTimeouts = ref<Record<string, NodeJS.Timeout>>({}); const pollingTimeouts = ref<Record<string, NodeJS.Timeout>>({});
const fieldsIssues = ref<Record<string, FieldIssue[]>>({}); const fieldsIssues = ref<Record<string, FieldIssue[]>>({});
@ -167,6 +172,20 @@ export const useTestDefinitionStore = defineStore(
return testDefinition; return testDefinition;
}; };
const fetchTestCaseExecutions = async (params: { testDefinitionId: string; runId: string }) => {
const testCaseExecutions = await testDefinitionsApi.getTestCaseExecutions(
rootStore.restApiContext,
params.testDefinitionId,
params.runId,
);
testCaseExecutions.forEach((testCaseExecution) => {
testCaseExecutionsById.value[testCaseExecution.id] = testCaseExecution;
});
return testCaseExecutions;
};
/** /**
* Fetches all test definitions from the API. * Fetches all test definitions from the API.
* @param {boolean} force - If true, fetches the definitions from the API even if they were already fetched before. * @param {boolean} force - If true, fetches the definitions from the API even if they were already fetched before.
@ -421,6 +440,7 @@ export const useTestDefinitionStore = defineStore(
fetchedAll, fetchedAll,
testDefinitionsById, testDefinitionsById,
testRunsById, testRunsById,
testCaseExecutionsById,
// Computed // Computed
allTestDefinitions, allTestDefinitions,
@ -435,6 +455,7 @@ export const useTestDefinitionStore = defineStore(
// Methods // Methods
fetchTestDefinition, fetchTestDefinition,
fetchTestCaseExecutions,
fetchAll, fetchAll,
create, create,
update, update,

View file

@ -7,24 +7,17 @@ import { useI18n } from '@/composables/useI18n';
import { N8nCard, N8nText } from 'n8n-design-system'; import { N8nCard, N8nText } from 'n8n-design-system';
import TestTableBase from '@/components/TestDefinition/shared/TestTableBase.vue'; import TestTableBase from '@/components/TestDefinition/shared/TestTableBase.vue';
import type { TestTableColumn } from '@/components/TestDefinition/shared/TestTableBase.vue'; import type { TestTableColumn } from '@/components/TestDefinition/shared/TestTableBase.vue';
import { useExecutionsStore } from '@/stores/executions.store';
import { get } from 'lodash-es';
import type { ExecutionSummaryWithScopes } from '@/Interface';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import type { TestCaseExecutionRecord } from '@/api/testDefinition.ee';
interface TestCase extends ExecutionSummaryWithScopes {
metrics: Record<string, number>;
}
const router = useRouter(); const router = useRouter();
const toast = useToast(); const toast = useToast();
const testDefinitionStore = useTestDefinitionStore(); const testDefinitionStore = useTestDefinitionStore();
const executionsStore = useExecutionsStore();
const locale = useI18n(); const locale = useI18n();
const isLoading = ref(true); const isLoading = ref(true);
const testCases = ref<TestCase[]>([]); const testCases = ref<TestCaseExecutionRecord[]>([]);
const runId = computed(() => router.currentRoute.value.params.runId as string); const runId = computed(() => router.currentRoute.value.params.runId as string);
const testId = computed(() => router.currentRoute.value.params.testId as string); const testId = computed(() => router.currentRoute.value.params.testId as string);
@ -36,17 +29,26 @@ const filteredTestCases = computed(() => {
}); });
const columns = computed( const columns = computed(
(): Array<TestTableColumn<TestCase>> => [ (): Array<TestTableColumn<TestCaseExecutionRecord>> => [
{ {
prop: 'id', prop: 'id',
width: 200, width: 200,
label: locale.baseText('testDefinition.runDetail.testCase'), label: locale.baseText('testDefinition.runDetail.testCase'),
sortable: true, sortable: true,
route: (row: TestCase) => ({ route: (row: TestCaseExecutionRecord) => {
name: VIEWS.EXECUTION_PREVIEW, if (test.value?.evaluationWorkflowId && row.evaluationExecutionId) {
params: { name: row.workflowId, executionId: row.id }, return {
}), name: VIEWS.EXECUTION_PREVIEW,
formatter: (row: TestCase) => `[${row.id}] ${row.workflowName}`, params: {
name: test.value?.evaluationWorkflowId,
executionId: row.evaluationExecutionId,
},
};
}
return undefined;
},
formatter: (row: TestCaseExecutionRecord) => `${row.id}`,
openInNewTab: true, openInNewTab: true,
}, },
{ {
@ -58,14 +60,14 @@ const columns = computed(
{ text: locale.baseText('testDefinition.listRuns.status.success'), value: 'success' }, { text: locale.baseText('testDefinition.listRuns.status.success'), value: 'success' },
{ text: locale.baseText('testDefinition.listRuns.status.error'), value: 'error' }, { text: locale.baseText('testDefinition.listRuns.status.error'), value: 'error' },
], ],
filterMethod: (value: string, row: TestCase) => row.status === value, filterMethod: (value: string, row: TestCaseExecutionRecord) => row.status === value,
}, },
...Object.keys(run.value?.metrics ?? {}).map((metric) => ({ ...Object.keys(run.value?.metrics ?? {}).map((metric) => ({
prop: `metrics.${metric}`, prop: `metrics.${metric}`,
label: metric, label: metric,
sortable: true, sortable: true,
filter: true, filter: true,
formatter: (row: TestCase) => row.metrics[metric]?.toFixed(2) ?? '-', formatter: (row: TestCaseExecutionRecord) => row.metrics?.[metric]?.toFixed(2) ?? '-',
})), })),
], ],
); );
@ -73,57 +75,24 @@ const columns = computed(
const metrics = computed(() => run.value?.metrics ?? {}); const metrics = computed(() => run.value?.metrics ?? {});
// Temporary workaround to fetch test cases by manually getting workflow executions // Temporary workaround to fetch test cases by manually getting workflow executions
// TODO: Replace with dedicated API endpoint once available
const fetchExecutionTestCases = async () => { const fetchExecutionTestCases = async () => {
if (!runId.value || !testId.value) return; if (!runId.value || !testId.value) return;
isLoading.value = true; isLoading.value = true;
try { try {
await testDefinitionStore.fetchTestDefinition(testId.value);
const testRun = await testDefinitionStore.getTestRun({ const testRun = await testDefinitionStore.getTestRun({
testDefinitionId: testId.value, testDefinitionId: testId.value,
runId: runId.value, runId: runId.value,
}); });
const testDefinition = await testDefinitionStore.fetchTestDefinition(testId.value);
// Fetch workflow executions that match this test run const testCaseEvaluationExecutions = await testDefinitionStore.fetchTestCaseExecutions({
const evaluationWorkflowExecutions = await executionsStore.fetchExecutions({ testDefinitionId: testId.value,
workflowId: testDefinition.evaluationWorkflowId ?? '', runId: testRun.id,
metadata: [{ key: 'testRunId', value: testRun.id }],
}); });
// For each execution, fetch full details and extract metrics testCases.value = testCaseEvaluationExecutions ?? [];
const executionsData = await Promise.all(
evaluationWorkflowExecutions?.results.map(async (execution) => {
const executionData = await executionsStore.fetchExecution(execution.id);
const lastExecutedNode = executionData?.data?.resultData?.lastNodeExecuted;
if (!lastExecutedNode) {
throw new Error('Last executed node is required');
}
const metricsData = get(
executionData,
[
'data',
'resultData',
'runData',
lastExecutedNode,
'0',
'data',
'main',
'0',
'0',
'json',
],
{},
);
return {
...execution,
metrics: metricsData,
};
}),
);
testCases.value = executionsData ?? [];
} catch (error) { } catch (error) {
toast.showError(error, 'Failed to load run details'); toast.showError(error, 'Failed to load run details');
} finally { } finally {

View file

@ -7,7 +7,6 @@ import TestDefinitionRunDetailView from '@/views/TestDefinition/TestDefinitionRu
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useExecutionsStore } from '@/stores/executions.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { nextTick, ref } from 'vue'; import { nextTick, ref } from 'vue';
import { mockedStore, waitAllPromises } from '@/__tests__/utils'; import { mockedStore, waitAllPromises } from '@/__tests__/utils';
@ -23,8 +22,7 @@ describe('TestDefinitionRunDetailView', () => {
let showErrorMock: Mock; let showErrorMock: Mock;
let getTestRunMock: Mock; let getTestRunMock: Mock;
let fetchExecutionsMock: Mock; let fetchTestCaseExecutionsMock: Mock;
let fetchExecutionMock: Mock;
const mockTestRun: TestRunRecord = { const mockTestRun: TestRunRecord = {
id: 'run1', id: 'run1',
@ -52,12 +50,10 @@ describe('TestDefinitionRunDetailView', () => {
name: 'Evaluation Workflow', name: 'Evaluation Workflow',
}; };
const mockExecutions = { const mockTestCaseExecutions = [
results: [ { id: 'exec1', status: 'success' },
{ id: 'exec1', status: 'success' }, { id: 'exec2', status: 'error' },
{ id: 'exec2', status: 'error' }, ];
],
};
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()); setActivePinia(createPinia());
@ -78,17 +74,7 @@ describe('TestDefinitionRunDetailView', () => {
showErrorMock = vi.fn(); showErrorMock = vi.fn();
getTestRunMock = vi.fn().mockResolvedValue(mockTestRun); getTestRunMock = vi.fn().mockResolvedValue(mockTestRun);
fetchExecutionsMock = vi.fn().mockResolvedValue(mockExecutions); fetchTestCaseExecutionsMock = vi.fn().mockResolvedValue(mockTestCaseExecutions);
fetchExecutionMock = vi.fn().mockResolvedValue({
data: {
resultData: {
lastNodeExecuted: 'Node1',
runData: {
Node1: [{ data: { main: [[{ json: { accuracy: 0.95 } }]] } }],
},
},
},
});
vi.mocked(useToast).mockReturnValue({ vi.mocked(useToast).mockReturnValue({
showError: showErrorMock, showError: showErrorMock,
@ -108,10 +94,6 @@ describe('TestDefinitionRunDetailView', () => {
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition }; testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
testDefinitionStore.getTestRun = getTestRunMock; testDefinitionStore.getTestRun = getTestRunMock;
const executionsStore = mockedStore(useExecutionsStore);
executionsStore.fetchExecutions = fetchExecutionsMock;
executionsStore.fetchExecution = fetchExecutionMock;
const workflowsStore = mockedStore(useWorkflowsStore); const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.workflowsById = { workflow1: mockWorkflow as IWorkflowDb }; workflowsStore.workflowsById = { workflow1: mockWorkflow as IWorkflowDb };
@ -151,7 +133,7 @@ describe('TestDefinitionRunDetailView', () => {
testDefinitionStore.getTestRun = vi.fn().mockRejectedValue(new Error('Failed to load')); testDefinitionStore.getTestRun = vi.fn().mockRejectedValue(new Error('Failed to load'));
renderComponent({ pinia }); renderComponent({ pinia });
await nextTick(); await waitAllPromises();
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), 'Failed to load run details'); expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), 'Failed to load run details');
}); });
@ -243,7 +225,6 @@ describe('TestDefinitionRunDetailView', () => {
setActivePinia(pinia); setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore); const testDefinitionStore = mockedStore(useTestDefinitionStore);
const executionsStore = mockedStore(useExecutionsStore);
// Mock all required store methods // Mock all required store methods
testDefinitionStore.testRunsById = { run1: mockTestRun }; testDefinitionStore.testRunsById = { run1: mockTestRun };
@ -251,17 +232,14 @@ describe('TestDefinitionRunDetailView', () => {
testDefinitionStore.getTestRun = getTestRunMock; testDefinitionStore.getTestRun = getTestRunMock;
// Add this mock for fetchTestDefinition // Add this mock for fetchTestDefinition
testDefinitionStore.fetchTestDefinition = vi.fn().mockResolvedValue(mockTestDefinition); testDefinitionStore.fetchTestDefinition = vi.fn().mockResolvedValue(mockTestDefinition);
testDefinitionStore.fetchTestCaseExecutions = fetchTestCaseExecutionsMock;
executionsStore.fetchExecutions = fetchExecutionsMock;
executionsStore.fetchExecution = fetchExecutionMock;
const { container } = renderComponent({ pinia }); const { container } = renderComponent({ pinia });
await nextTick();
// Wait for all promises to resolve // Wait for all promises to resolve
await waitAllPromises(); await waitAllPromises();
const tableRows = container.querySelectorAll('.el-table__row'); const tableRows = container.querySelectorAll('.el-table__row');
expect(tableRows.length).toBe(mockExecutions.results.length); expect(tableRows.length).toBe(mockTestCaseExecutions.length);
}); });
}); });