feat: Add API endpoint to cancel a test run (no-changelog) (#12115)

This commit is contained in:
Eugene 2025-01-14 12:12:31 +01:00 committed by GitHub
parent 479933fbd5
commit be520b4f60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 447 additions and 108 deletions

View file

@ -7,7 +7,7 @@ import {
} from '@/databases/entities/abstract-entity'; } from '@/databases/entities/abstract-entity';
import { TestDefinition } from '@/databases/entities/test-definition.ee'; import { TestDefinition } from '@/databases/entities/test-definition.ee';
type TestRunStatus = 'new' | 'running' | 'completed' | 'error'; type TestRunStatus = 'new' | 'running' | 'completed' | 'error' | 'cancelled';
export type AggregatedTestRunMetrics = Record<string, number | boolean>; export type AggregatedTestRunMetrics = Record<string, number | boolean>;

View file

@ -35,6 +35,10 @@ 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) {
return await this.update(id, { status: 'cancelled' });
}
async incrementPassed(id: string) { async incrementPassed(id: string) {
return await this.increment({ id }, 'passedCases', 1); return await this.increment({ id }, 'passedCases', 1);
} }

View file

@ -0,0 +1,7 @@
import { ResponseError } from './abstract/response.error';
export class NotImplementedError extends ResponseError {
constructor(message: string, hint: string | undefined = undefined) {
super(message, 501, 501, hint);
}
}

View file

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

View file

@ -132,6 +132,12 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
const errorReporter = mock<ErrorReporter>(); const errorReporter = mock<ErrorReporter>();
const logger = mockLogger(); const logger = mockLogger();
async function mockLongExecutionPromise(data: IRun, delay: number): Promise<IRun> {
return await new Promise((resolve) => {
setTimeout(() => resolve(data), delay);
});
}
describe('TestRunnerService', () => { describe('TestRunnerService', () => {
const executionRepository = mock<ExecutionRepository>(); const executionRepository = mock<ExecutionRepository>();
const workflowRepository = mock<WorkflowRepository>(); const workflowRepository = mock<WorkflowRepository>();
@ -168,11 +174,7 @@ describe('TestRunnerService', () => {
}); });
afterEach(() => { afterEach(() => {
activeExecutions.getPostExecutePromise.mockClear(); jest.resetAllMocks();
workflowRunner.run.mockClear();
testRunRepository.createTestRun.mockClear();
testRunRepository.markAsRunning.mockClear();
testRunRepository.markAsCompleted.mockClear();
testRunRepository.incrementFailed.mockClear(); testRunRepository.incrementFailed.mockClear();
testRunRepository.incrementPassed.mockClear(); testRunRepository.incrementPassed.mockClear();
}); });
@ -633,4 +635,87 @@ describe('TestRunnerService', () => {
}), }),
}); });
}); });
describe('Test Run cancellation', () => {
beforeAll(() => {
jest.useFakeTimers();
});
test('should cancel test run', async () => {
const testRunnerService = new TestRunnerService(
logger,
workflowRepository,
workflowRunner,
executionRepository,
activeExecutions,
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
id: 'workflow-under-test-id',
...wfUnderTestJson,
});
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
id: 'evaluation-workflow-id',
...wfEvaluationJson,
});
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
// Mock long execution of workflow under test
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id')
.mockReturnValue(mockLongExecutionPromise(mockExecutionData(), 1000));
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-3')
.mockReturnValue(mockLongExecutionPromise(mockExecutionData(), 1000));
// Mock executions of evaluation workflow
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-2')
.mockReturnValue(
mockLongExecutionPromise(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }), 1000),
);
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-4')
.mockReturnValue(
mockLongExecutionPromise(mockEvaluationExecutionData({ metric1: 0.5 }), 1000),
);
// Do not await here to test canceling
void testRunnerService.runTest(
mock<User>(),
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
}),
);
// Simulate the moment when first test case is running (wf under test execution)
await jest.advanceTimersByTimeAsync(100);
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
const abortController = (testRunnerService as any).abortControllers.get('test-run-id');
expect(abortController).toBeDefined();
await testRunnerService.cancelTestRun('test-run-id');
expect(abortController.signal.aborted).toBe(true);
expect(activeExecutions.stopExecution).toBeCalledWith('some-execution-id');
});
afterAll(() => {
jest.useRealTimers();
});
});
}); });

View file

@ -1,7 +1,7 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { parse } from 'flatted'; import { parse } from 'flatted';
import { ErrorReporter, Logger } from 'n8n-core'; import { ErrorReporter, Logger } from 'n8n-core';
import { NodeConnectionType, Workflow } from 'n8n-workflow'; import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow';
import type { import type {
IDataObject, IDataObject,
IRun, IRun,
@ -15,6 +15,7 @@ import assert from 'node:assert';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import type { ExecutionEntity } from '@/databases/entities/execution-entity'; import type { ExecutionEntity } from '@/databases/entities/execution-entity';
import type { MockedNodeItem, TestDefinition } from '@/databases/entities/test-definition.ee'; import type { MockedNodeItem, TestDefinition } from '@/databases/entities/test-definition.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 { 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';
@ -38,6 +39,8 @@ import { createPinData, getPastExecutionTriggerNode } from './utils.ee';
*/ */
@Service() @Service()
export class TestRunnerService { export class TestRunnerService {
private abortControllers: Map<TestRun['id'], AbortController> = new Map();
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
@ -101,7 +104,13 @@ export class TestRunnerService {
pastExecutionWorkflowData: IWorkflowBase, pastExecutionWorkflowData: IWorkflowBase,
mockedNodes: MockedNodeItem[], mockedNodes: MockedNodeItem[],
userId: string, userId: string,
abortSignal: AbortSignal,
): Promise<IRun | undefined> { ): Promise<IRun | undefined> {
// Do not run if the test run is cancelled
if (abortSignal.aborted) {
return;
}
// Create pin data from the past execution data // Create pin data from the past execution data
const pinData = createPinData( const pinData = createPinData(
workflow, workflow,
@ -125,6 +134,11 @@ export class TestRunnerService {
const executionId = await this.workflowRunner.run(data); const executionId = await this.workflowRunner.run(data);
assert(executionId); assert(executionId);
// Listen to the abort signal to stop the execution in case test run is cancelled
abortSignal.addEventListener('abort', () => {
this.activeExecutions.stopExecution(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);
@ -138,8 +152,14 @@ export class TestRunnerService {
evaluationWorkflow: WorkflowEntity, evaluationWorkflow: WorkflowEntity,
expectedData: IRunData, expectedData: IRunData,
actualData: IRunData, actualData: IRunData,
abortSignal: AbortSignal,
testRunId?: string, testRunId?: string,
) { ) {
// Do not run if the test run is cancelled
if (abortSignal.aborted) {
return;
}
// Prepare the evaluation wf input data. // Prepare the evaluation wf input data.
// Provide both the expected data and the actual data // Provide both the expected data and the actual data
const evaluationInputData = { const evaluationInputData = {
@ -164,6 +184,11 @@ export class TestRunnerService {
const executionId = await this.workflowRunner.run(data); const executionId = await this.workflowRunner.run(data);
assert(executionId); assert(executionId);
// Listen to the abort signal to stop the execution in case test run is cancelled
abortSignal.addEventListener('abort', () => {
this.activeExecutions.stopExecution(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);
@ -217,6 +242,12 @@ export class TestRunnerService {
const testRun = await this.testRunRepository.createTestRun(test.id); const testRun = await this.testRunRepository.createTestRun(test.id);
assert(testRun, 'Unable to create a test run'); assert(testRun, 'Unable to create a test run');
// 0.1 Initialize AbortController
const abortController = new AbortController();
this.abortControllers.set(testRun.id, abortController);
const abortSignal = abortController.signal;
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.
@ -237,13 +268,20 @@ export class TestRunnerService {
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
await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length); await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length);
// Object to collect the results of the evaluation workflow executions // Object to collect the results of the evaluation workflow executions
const metrics = new EvaluationMetrics(testMetricNames); const metrics = new EvaluationMetrics(testMetricNames);
for (const { id: pastExecutionId } of pastExecutions) { for (const { id: pastExecutionId } of pastExecutions) {
if (abortSignal.aborted) {
this.logger.debug('Test run was cancelled', {
testId: test.id,
stoppedOn: pastExecutionId,
});
break;
}
this.logger.debug('Running test case', { pastExecutionId }); this.logger.debug('Running test case', { pastExecutionId });
try { try {
@ -263,6 +301,7 @@ export class TestRunnerService {
pastExecution.executionData.workflowData, pastExecution.executionData.workflowData,
test.mockedNodes, test.mockedNodes,
user.id, user.id,
abortSignal,
); );
this.logger.debug('Test case execution finished', { pastExecutionId }); this.logger.debug('Test case execution finished', { pastExecutionId });
@ -285,12 +324,14 @@ export class TestRunnerService {
evaluationWorkflow, evaluationWorkflow,
originalRunData, originalRunData,
testCaseRunData, testCaseRunData,
abortSignal,
testRun.id, testRun.id,
); );
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
metrics.addResults(this.extractEvaluationResult(evalExecution)); metrics.addResults(this.extractEvaluationResult(evalExecution));
if (evalExecution.data.resultData.error) { if (evalExecution.data.resultData.error) {
@ -306,10 +347,51 @@ export class TestRunnerService {
} }
} }
// Mark the test run as completed or cancelled
if (abortSignal.aborted) {
await this.testRunRepository.markAsCancelled(testRun.id);
} else {
const aggregatedMetrics = metrics.getAggregatedMetrics(); const aggregatedMetrics = metrics.getAggregatedMetrics();
await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics); await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics);
this.logger.debug('Test run finished', { testId: test.id }); this.logger.debug('Test run finished', { testId: test.id });
} }
} catch (e) {
if (e instanceof ExecutionCancelledError) {
this.logger.debug('Evaluation execution was cancelled. Cancelling test run', {
testRunId: testRun.id,
stoppedOn: e.extra?.executionId,
});
await this.testRunRepository.markAsCancelled(testRun.id);
} else {
throw e;
}
} finally {
// Clean up abort controller
this.abortControllers.delete(testRun.id);
}
}
/**
* Checks if the test run in a cancellable state.
*/
canBeCancelled(testRun: TestRun) {
return testRun.status !== 'running' && testRun.status !== 'new';
}
/**
* Cancels the test run with the given ID.
* TODO: Implement the cancellation of the test run in a multi-main scenario
*/
async cancelTestRun(testRunId: string) {
const abortController = this.abortControllers.get(testRunId);
if (abortController) {
abortController.abort();
this.abortControllers.delete(testRunId);
} else {
// If there is no abort controller - just mark the test run as cancelled
await this.testRunRepository.markAsCancelled(testRunId);
}
}
} }

View file

@ -1,7 +1,13 @@
import express from 'express';
import { InstanceSettings } from 'n8n-core';
import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
import { Delete, Get, RestController } from '@/decorators'; import { Delete, Get, Post, RestController } from '@/decorators';
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 { TestRunsRequest } from '@/evaluation.ee/test-definitions.types.ee'; import { TestRunsRequest } from '@/evaluation.ee/test-definitions.types.ee';
import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee';
import { listQueryMiddleware } from '@/middlewares'; import { listQueryMiddleware } from '@/middlewares';
import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service';
@ -12,9 +18,12 @@ export class TestRunsController {
constructor( constructor(
private readonly testDefinitionService: TestDefinitionService, private readonly testDefinitionService: TestDefinitionService,
private readonly testRunRepository: TestRunRepository, private readonly testRunRepository: TestRunRepository,
private readonly testRunnerService: TestRunnerService,
private readonly instanceSettings: InstanceSettings,
) {} ) {}
/** This method is used in multiple places in the controller to get the test definition /**
* 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). * (or just check that it exists and the user has access to it).
*/ */
private async getTestDefinition( private async getTestDefinition(
@ -34,6 +43,23 @@ export class TestRunsController {
return testDefinition; return testDefinition;
} }
/**
* Get the test run (or just check that it exists and the user has access to it)
*/
private async getTestRun(
req: TestRunsRequest.GetOne | TestRunsRequest.Delete | TestRunsRequest.Cancel,
) {
const { id: testRunId, testDefinitionId } = req.params;
const testRun = await this.testRunRepository.findOne({
where: { id: testRunId, testDefinition: { id: testDefinitionId } },
});
if (!testRun) throw new NotFoundError('Test run not found');
return testRun;
}
@Get('/:testDefinitionId/runs', { middlewares: listQueryMiddleware }) @Get('/:testDefinitionId/runs', { middlewares: listQueryMiddleware })
async getMany(req: TestRunsRequest.GetMany) { async getMany(req: TestRunsRequest.GetMany) {
const { testDefinitionId } = req.params; const { testDefinitionId } = req.params;
@ -45,33 +71,43 @@ export class TestRunsController {
@Get('/:testDefinitionId/runs/:id') @Get('/:testDefinitionId/runs/:id')
async getOne(req: TestRunsRequest.GetOne) { async getOne(req: TestRunsRequest.GetOne) {
const { id: testRunId, testDefinitionId } = req.params;
await this.getTestDefinition(req); await this.getTestDefinition(req);
const testRun = await this.testRunRepository.findOne({ return await this.getTestRun(req);
where: { id: testRunId, testDefinition: { id: testDefinitionId } },
});
if (!testRun) throw new NotFoundError('Test run not found');
return testRun;
} }
@Delete('/:testDefinitionId/runs/:id') @Delete('/:testDefinitionId/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
await this.getTestDefinition(req); await this.getTestDefinition(req);
await this.getTestRun(req);
const testRun = await this.testRunRepository.findOne({
where: { id: testRunId, testDefinition: { id: testDefinitionId } },
});
if (!testRun) throw new NotFoundError('Test run not found');
await this.testRunRepository.delete({ id: testRunId }); await this.testRunRepository.delete({ id: testRunId });
return { success: true }; return { success: true };
} }
@Post('/:testDefinitionId/runs/:id/cancel')
async cancel(req: TestRunsRequest.Cancel, res: express.Response) {
if (this.instanceSettings.isMultiMain) {
throw new NotImplementedError('Cancelling test runs is not yet supported in multi-main mode');
}
const { id: testRunId } = req.params;
// Check test definition and test run exist
await this.getTestDefinition(req);
const testRun = await this.getTestRun(req);
if (this.testRunnerService.canBeCancelled(testRun)) {
const message = `The test run "${testRunId}" cannot be cancelled`;
throw new ConflictError(message);
}
await this.testRunnerService.cancelTestRun(testRunId);
res.status(202).json({ success: true });
}
} }

View file

@ -1,4 +1,5 @@
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { mockInstance } from 'n8n-core/test/utils';
import type { TestDefinition } from '@/databases/entities/test-definition.ee'; import type { TestDefinition } from '@/databases/entities/test-definition.ee';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
@ -6,6 +7,7 @@ import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee'; import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee';
import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee';
import { createUserShell } from '@test-integration/db/users'; import { createUserShell } from '@test-integration/db/users';
import { createWorkflow } from '@test-integration/db/workflows'; import { createWorkflow } from '@test-integration/db/workflows';
import * as testDb from '@test-integration/test-db'; import * as testDb from '@test-integration/test-db';
@ -19,9 +21,11 @@ let testDefinition: TestDefinition;
let otherTestDefinition: TestDefinition; let otherTestDefinition: TestDefinition;
let ownerShell: User; let ownerShell: User;
const testRunner = mockInstance(TestRunnerService);
const testServer = utils.setupTestServer({ const testServer = utils.setupTestServer({
endpointGroups: ['workflows', 'evaluation'], endpointGroups: ['workflows', 'evaluation'],
enabledFeatures: ['feat:sharing'], enabledFeatures: ['feat:sharing', 'feat:multipleMainInstances'],
}); });
beforeAll(async () => { beforeAll(async () => {
@ -57,13 +61,13 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => {
expect(resp.body.data).toEqual([]); expect(resp.body.data).toEqual([]);
}); });
test('should retrieve 404 if test definition does not exist', async () => { test('should return 404 if test definition does not exist', async () => {
const resp = await authOwnerAgent.get('/evaluation/test-definitions/123/runs'); const resp = await authOwnerAgent.get('/evaluation/test-definitions/123/runs');
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);
}); });
test('should retrieve 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 resp = await authOwnerAgent.get( const resp = await authOwnerAgent.get(
`/evaluation/test-definitions/${otherTestDefinition.id}/runs`, `/evaluation/test-definitions/${otherTestDefinition.id}/runs`,
); );
@ -151,7 +155,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs/:id', () => {
); );
}); });
test('should retrieve 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(
`/evaluation/test-definitions/${testDefinition.id}/runs/123`, `/evaluation/test-definitions/${testDefinition.id}/runs/123`,
); );
@ -159,7 +163,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs/:id', () => {
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);
}); });
test('should retrieve 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(otherTestDefinition.id);
@ -218,7 +222,7 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () =>
expect(testRunAfterDelete).toBeNull(); expect(testRunAfterDelete).toBeNull();
}); });
test('should retrieve 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(
`/evaluation/test-definitions/${testDefinition.id}/runs/123`, `/evaluation/test-definitions/${testDefinition.id}/runs/123`,
); );
@ -226,7 +230,7 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () =>
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);
}); });
test('should retrieve 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(otherTestDefinition.id);
@ -237,3 +241,46 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () =>
expect(resp.statusCode).toBe(404); expect(resp.statusCode).toBe(404);
}); });
}); });
describe('POST /evaluation/test-definitions/:testDefinitionId/runs/:id/cancel', () => {
test('should cancel test run', async () => {
const testRunRepository = Container.get(TestRunRepository);
const testRun = await testRunRepository.createTestRun(testDefinition.id);
jest.spyOn(testRunRepository, 'markAsCancelled');
const resp = await authOwnerAgent.post(
`/evaluation/test-definitions/${testDefinition.id}/runs/${testRun.id}/cancel`,
);
expect(resp.statusCode).toBe(202);
expect(resp.body).toEqual({ success: true });
expect(testRunner.cancelTestRun).toHaveBeenCalledWith(testRun.id);
});
test('should return 404 if test run does not exist', async () => {
const resp = await authOwnerAgent.post(
`/evaluation/test-definitions/${testDefinition.id}/runs/123/cancel`,
);
expect(resp.statusCode).toBe(404);
});
test('should return 404 if test definition does not exist', async () => {
const resp = await authOwnerAgent.post('/evaluation/test-definitions/123/runs/123/cancel');
expect(resp.statusCode).toBe(404);
});
test('should return 404 if user does not have access to test definition', async () => {
const testRunRepository = Container.get(TestRunRepository);
const testRun = await testRunRepository.createTestRun(otherTestDefinition.id);
const resp = await authOwnerAgent.post(
`/evaluation/test-definitions/${otherTestDefinition.id}/runs/${testRun.id}/cancel`,
);
expect(resp.statusCode).toBe(404);
});
});

View file

@ -43,7 +43,7 @@ export interface UpdateTestResponse {
export interface TestRunRecord { export interface TestRunRecord {
id: string; id: string;
testDefinitionId: string; testDefinitionId: string;
status: 'new' | 'running' | 'completed' | 'error'; status: 'new' | 'running' | 'completed' | 'error' | 'cancelled';
metrics?: Record<string, number>; metrics?: Record<string, number>;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@ -221,6 +221,21 @@ export const startTestRun = async (context: IRestApiContext, testDefinitionId: s
return response as { success: boolean }; return response as { success: boolean };
}; };
export const cancelTestRun = async (
context: IRestApiContext,
testDefinitionId: string,
testRunId: string,
) => {
const response = await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${endpoint}/${testDefinitionId}/runs/${testRunId}/cancel`,
headers: { 'push-ref': context.pushRef },
});
// CLI is returning the response without wrapping it in `data` key
return response as { success: boolean };
};
// Delete a test run // Delete a test run
export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => { export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => {
return await makeRestApiRequest<{ success: boolean }>( return await makeRestApiRequest<{ success: boolean }>(

View file

@ -3,6 +3,7 @@ import type { TestListItem } from '@/components/TestDefinition/types';
import TimeAgo from '@/components/TimeAgo.vue'; import TimeAgo from '@/components/TimeAgo.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import n8nIconButton from 'n8n-design-system/components/N8nIconButton'; import n8nIconButton from 'n8n-design-system/components/N8nIconButton';
import { computed } from 'vue';
export interface TestItemProps { export interface TestItemProps {
test: TestListItem; test: TestListItem;
@ -16,6 +17,7 @@ const emit = defineEmits<{
'view-details': [testId: string]; 'view-details': [testId: string];
'edit-test': [testId: string]; 'edit-test': [testId: string];
'delete-test': [testId: string]; 'delete-test': [testId: string];
'cancel-test-run': [testId: string, testRunId: string | null];
}>(); }>();
const actions = [ const actions = [
@ -24,6 +26,14 @@ const actions = [
id: 'run', id: 'run',
event: () => emit('run-test', props.test.id), event: () => emit('run-test', props.test.id),
tooltip: locale.baseText('testDefinition.runTest'), tooltip: locale.baseText('testDefinition.runTest'),
show: () => props.test.execution.status !== 'running',
},
{
icon: 'stop',
id: 'cancel',
event: () => emit('cancel-test-run', props.test.id, props.test.execution.id),
tooltip: locale.baseText('testDefinition.cancelTestRun'),
show: () => props.test.execution.status === 'running',
}, },
{ {
icon: 'list', icon: 'list',
@ -44,6 +54,8 @@ const actions = [
tooltip: locale.baseText('testDefinition.deleteTest'), tooltip: locale.baseText('testDefinition.deleteTest'),
}, },
]; ];
const visibleActions = computed(() => actions.filter((action) => action.show?.() ?? true));
</script> </script>
<template> <template>
@ -85,7 +97,12 @@ const actions = [
</div> </div>
<div :class="$style.actions"> <div :class="$style.actions">
<n8n-tooltip v-for="action in actions" :key="action.icon" placement="top" :show-after="1000"> <n8n-tooltip
v-for="action in visibleActions"
:key="action.icon"
placement="top"
:show-after="1000"
>
<template #content> <template #content>
{{ action.tooltip }} {{ action.tooltip }}
</template> </template>

View file

@ -49,6 +49,7 @@ const columns = computed((): Array<TestDefinitionTableColumn<TestRunRecord>> =>
{ text: locale.baseText('testDefinition.listRuns.status.running'), value: 'running' }, { text: locale.baseText('testDefinition.listRuns.status.running'), value: 'running' },
{ text: locale.baseText('testDefinition.listRuns.status.completed'), value: 'completed' }, { text: locale.baseText('testDefinition.listRuns.status.completed'), value: 'completed' },
{ text: locale.baseText('testDefinition.listRuns.status.error'), value: 'error' }, { text: locale.baseText('testDefinition.listRuns.status.error'), value: 'error' },
{ text: locale.baseText('testDefinition.listRuns.status.cancelled'), value: 'cancelled' },
], ],
filterMethod: (value: string, row: TestRunRecord) => row.status === value, filterMethod: (value: string, row: TestRunRecord) => row.status === value,
}, },

View file

@ -28,6 +28,7 @@ const statusThemeMap: Record<string, string> = {
completed: 'success', completed: 'success',
error: 'danger', error: 'danger',
success: 'success', success: 'success',
cancelled: 'default',
}; };
const statusLabelMap: Record<string, string> = { const statusLabelMap: Record<string, string> = {
@ -36,6 +37,7 @@ const statusLabelMap: Record<string, string> = {
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'),
cancelled: locale.baseText('testDefinition.listRuns.status.cancelled'),
}; };
function hasProperty(row: unknown, prop: string): row is Record<string, unknown> { function hasProperty(row: unknown, prop: string): row is Record<string, unknown> {

View file

@ -24,6 +24,7 @@ export interface TestExecution {
errorRate: number | null; errorRate: number | null;
metrics: Record<string, number>; metrics: Record<string, number>;
status: TestRunRecord['status']; status: TestRunRecord['status'];
id: string | null;
} }
export interface TestListItem { export interface TestListItem {

View file

@ -2823,10 +2823,12 @@
"testDefinition.list.errorRate": "Error rate: {errorRate}", "testDefinition.list.errorRate": "Error rate: {errorRate}",
"testDefinition.list.testStartError": "Failed to start test run", "testDefinition.list.testStartError": "Failed to start test run",
"testDefinition.list.testStarted": "Test run started", "testDefinition.list.testStarted": "Test run started",
"testDefinition.list.testCancelled": "Test run cancelled",
"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.completed": "Completed", "testDefinition.listRuns.status.completed": "Completed",
"testDefinition.listRuns.status.cancelled": "Cancelled",
"testDefinition.listRuns.status.error": "Error", "testDefinition.listRuns.status.error": "Error",
"testDefinition.listRuns.status.success": "Success", "testDefinition.listRuns.status.success": "Success",
"testDefinition.listRuns.metricsOverTime": "Metrics over time", "testDefinition.listRuns.metricsOverTime": "Metrics over time",
@ -2844,6 +2846,7 @@
"testDefinition.runDetail.testCase.status": "Test case status", "testDefinition.runDetail.testCase.status": "Test case status",
"testDefinition.runDetail.totalCases": "Total cases", "testDefinition.runDetail.totalCases": "Total cases",
"testDefinition.runTest": "Run Test", "testDefinition.runTest": "Run Test",
"testDefinition.cancelTestRun": "Cancel Test Run",
"testDefinition.notImplemented": "This feature is not implemented yet!", "testDefinition.notImplemented": "This feature is not implemented yet!",
"testDefinition.viewDetails": "View Details", "testDefinition.viewDetails": "View Details",
"testDefinition.editTest": "Edit Test", "testDefinition.editTest": "Edit Test",

View file

@ -307,6 +307,15 @@ export const useTestDefinitionStore = defineStore(
return result; return result;
}; };
const cancelTestRun = async (testDefinitionId: string, testRunId: string) => {
const result = await testDefinitionsApi.cancelTestRun(
rootStore.restApiContext,
testDefinitionId,
testRunId,
);
return result;
};
const deleteTestRun = async (params: { testDefinitionId: string; runId: string }) => { const deleteTestRun = async (params: { testDefinitionId: string; runId: string }) => {
const result = await testDefinitionsApi.deleteTestRun(rootStore.restApiContext, params); const result = await testDefinitionsApi.deleteTestRun(rootStore.restApiContext, params);
if (result.success) { if (result.success) {
@ -369,6 +378,7 @@ export const useTestDefinitionStore = defineStore(
fetchTestRuns, fetchTestRuns,
getTestRun, getTestRun,
startTestRun, startTestRun,
cancelTestRun,
deleteTestRun, deleteTestRun,
cleanupPolling, cleanupPolling,
}; };

View file

@ -48,6 +48,7 @@ function getTestExecution(testId: string): TestExecution {
const lastRun = testDefinitionStore.lastRunByTestId[testId]; const lastRun = testDefinitionStore.lastRunByTestId[testId];
if (!lastRun) { if (!lastRun) {
return { return {
id: null,
lastRun: null, lastRun: null,
errorRate: 0, errorRate: 0,
metrics: {}, metrics: {},
@ -56,6 +57,7 @@ function getTestExecution(testId: string): TestExecution {
} }
const mockExecutions = { const mockExecutions = {
id: lastRun.id,
lastRun: lastRun.updatedAt ?? '', lastRun: lastRun.updatedAt ?? '',
errorRate: 0, errorRate: 0,
metrics: lastRun.metrics ?? {}, metrics: lastRun.metrics ?? {},
@ -89,6 +91,30 @@ async function onRunTest(testId: string) {
} }
} }
async function onCancelTestRun(testId: string, testRunId: string | null) {
try {
// FIXME: testRunId might be null for a short period of time between user clicking start and the test run being created and fetched. Just ignore it for now.
if (!testRunId) {
throw new Error('Failed to cancel test run');
}
const result = await testDefinitionStore.cancelTestRun(testId, testRunId);
if (result.success) {
toast.showMessage({
title: locale.baseText('testDefinition.list.testCancelled'),
type: 'success',
});
// Optionally fetch the updated test runs
await testDefinitionStore.fetchTestRuns(testId);
} else {
throw new Error('Failed to cancel test run');
}
} catch (error) {
toast.showError(error, locale.baseText('testDefinition.list.testStartError'));
}
}
async function onViewDetails(testId: string) { async function onViewDetails(testId: string) {
void router.push({ name: VIEWS.TEST_DEFINITION_RUNS, params: { testId } }); void router.push({ name: VIEWS.TEST_DEFINITION_RUNS, params: { testId } });
} }
@ -165,6 +191,7 @@ onMounted(() => {
@view-details="onViewDetails" @view-details="onViewDetails"
@edit-test="onEditTest" @edit-test="onEditTest"
@delete-test="onDeleteTest" @delete-test="onDeleteTest"
@cancel-test-run="onCancelTestRun"
/> />
</template> </template>
</div> </div>