mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-02 07:01:30 -08:00
feat: Add API endpoint to cancel a test run (no-changelog) (#12115)
This commit is contained in:
parent
479933fbd5
commit
be520b4f60
|
@ -7,7 +7,7 @@ import {
|
|||
} from '@/databases/entities/abstract-entity';
|
||||
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>;
|
||||
|
||||
|
|
|
@ -35,6 +35,10 @@ export class TestRunRepository extends Repository<TestRun> {
|
|||
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) {
|
||||
return await this.increment({ id }, 'passedCases', 1);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -92,4 +92,6 @@ export declare namespace TestRunsRequest {
|
|||
type GetOne = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>;
|
||||
|
||||
type Delete = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>;
|
||||
|
||||
type Cancel = AuthenticatedRequest<RouteParams.TestId & RouteParams.TestRunId>;
|
||||
}
|
||||
|
|
|
@ -132,6 +132,12 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
|
|||
const errorReporter = mock<ErrorReporter>();
|
||||
const logger = mockLogger();
|
||||
|
||||
async function mockLongExecutionPromise(data: IRun, delay: number): Promise<IRun> {
|
||||
return await new Promise((resolve) => {
|
||||
setTimeout(() => resolve(data), delay);
|
||||
});
|
||||
}
|
||||
|
||||
describe('TestRunnerService', () => {
|
||||
const executionRepository = mock<ExecutionRepository>();
|
||||
const workflowRepository = mock<WorkflowRepository>();
|
||||
|
@ -168,11 +174,7 @@ describe('TestRunnerService', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
activeExecutions.getPostExecutePromise.mockClear();
|
||||
workflowRunner.run.mockClear();
|
||||
testRunRepository.createTestRun.mockClear();
|
||||
testRunRepository.markAsRunning.mockClear();
|
||||
testRunRepository.markAsCompleted.mockClear();
|
||||
jest.resetAllMocks();
|
||||
testRunRepository.incrementFailed.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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { parse } from 'flatted';
|
||||
import { ErrorReporter, Logger } from 'n8n-core';
|
||||
import { NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||
import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IRun,
|
||||
|
@ -15,6 +15,7 @@ import assert from 'node:assert';
|
|||
import { ActiveExecutions } from '@/active-executions';
|
||||
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||
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 { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||
|
@ -38,6 +39,8 @@ import { createPinData, getPastExecutionTriggerNode } from './utils.ee';
|
|||
*/
|
||||
@Service()
|
||||
export class TestRunnerService {
|
||||
private abortControllers: Map<TestRun['id'], AbortController> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
|
@ -101,7 +104,13 @@ export class TestRunnerService {
|
|||
pastExecutionWorkflowData: IWorkflowBase,
|
||||
mockedNodes: MockedNodeItem[],
|
||||
userId: string,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<IRun | undefined> {
|
||||
// Do not run if the test run is cancelled
|
||||
if (abortSignal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create pin data from the past execution data
|
||||
const pinData = createPinData(
|
||||
workflow,
|
||||
|
@ -125,6 +134,11 @@ export class TestRunnerService {
|
|||
const executionId = await this.workflowRunner.run(data);
|
||||
assert(executionId);
|
||||
|
||||
// Listen to the abort signal to stop the execution in case test run is cancelled
|
||||
abortSignal.addEventListener('abort', () => {
|
||||
this.activeExecutions.stopExecution(executionId);
|
||||
});
|
||||
|
||||
// Wait for the execution to finish
|
||||
const executePromise = this.activeExecutions.getPostExecutePromise(executionId);
|
||||
|
||||
|
@ -138,8 +152,14 @@ export class TestRunnerService {
|
|||
evaluationWorkflow: WorkflowEntity,
|
||||
expectedData: IRunData,
|
||||
actualData: IRunData,
|
||||
abortSignal: AbortSignal,
|
||||
testRunId?: string,
|
||||
) {
|
||||
// Do not run if the test run is cancelled
|
||||
if (abortSignal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the evaluation wf input data.
|
||||
// Provide both the expected data and the actual data
|
||||
const evaluationInputData = {
|
||||
|
@ -164,6 +184,11 @@ export class TestRunnerService {
|
|||
const executionId = await this.workflowRunner.run(data);
|
||||
assert(executionId);
|
||||
|
||||
// Listen to the abort signal to stop the execution in case test run is cancelled
|
||||
abortSignal.addEventListener('abort', () => {
|
||||
this.activeExecutions.stopExecution(executionId);
|
||||
});
|
||||
|
||||
// Wait for the execution to finish
|
||||
const executePromise = this.activeExecutions.getPostExecutePromise(executionId);
|
||||
|
||||
|
@ -217,6 +242,12 @@ export class TestRunnerService {
|
|||
const testRun = await this.testRunRepository.createTestRun(test.id);
|
||||
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
|
||||
|
||||
// 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);
|
||||
|
||||
// 2. Run over all the test cases
|
||||
|
||||
await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length);
|
||||
|
||||
// Object to collect the results of the evaluation workflow executions
|
||||
const metrics = new EvaluationMetrics(testMetricNames);
|
||||
|
||||
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 });
|
||||
|
||||
try {
|
||||
|
@ -263,6 +301,7 @@ export class TestRunnerService {
|
|||
pastExecution.executionData.workflowData,
|
||||
test.mockedNodes,
|
||||
user.id,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
this.logger.debug('Test case execution finished', { pastExecutionId });
|
||||
|
@ -285,12 +324,14 @@ export class TestRunnerService {
|
|||
evaluationWorkflow,
|
||||
originalRunData,
|
||||
testCaseRunData,
|
||||
abortSignal,
|
||||
testRun.id,
|
||||
);
|
||||
assert(evalExecution);
|
||||
|
||||
this.logger.debug('Evaluation execution finished', { pastExecutionId });
|
||||
|
||||
// Extract the output of the last node executed in the evaluation workflow
|
||||
metrics.addResults(this.extractEvaluationResult(evalExecution));
|
||||
|
||||
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();
|
||||
|
||||
await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import express from 'express';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
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 { NotImplementedError } from '@/errors/response-errors/not-implemented.error';
|
||||
import { TestRunsRequest } from '@/evaluation.ee/test-definitions.types.ee';
|
||||
import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee';
|
||||
import { listQueryMiddleware } from '@/middlewares';
|
||||
import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service';
|
||||
|
||||
|
@ -12,9 +18,12 @@ export class TestRunsController {
|
|||
constructor(
|
||||
private readonly testDefinitionService: TestDefinitionService,
|
||||
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).
|
||||
*/
|
||||
private async getTestDefinition(
|
||||
|
@ -34,6 +43,23 @@ export class TestRunsController {
|
|||
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 })
|
||||
async getMany(req: TestRunsRequest.GetMany) {
|
||||
const { testDefinitionId } = req.params;
|
||||
|
@ -45,33 +71,43 @@ export class TestRunsController {
|
|||
|
||||
@Get('/:testDefinitionId/runs/:id')
|
||||
async getOne(req: TestRunsRequest.GetOne) {
|
||||
const { id: testRunId, testDefinitionId } = req.params;
|
||||
|
||||
await this.getTestDefinition(req);
|
||||
|
||||
const testRun = await this.testRunRepository.findOne({
|
||||
where: { id: testRunId, testDefinition: { id: testDefinitionId } },
|
||||
});
|
||||
|
||||
if (!testRun) throw new NotFoundError('Test run not found');
|
||||
|
||||
return testRun;
|
||||
return await this.getTestRun(req);
|
||||
}
|
||||
|
||||
@Delete('/:testDefinitionId/runs/:id')
|
||||
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);
|
||||
|
||||
const testRun = await this.testRunRepository.findOne({
|
||||
where: { id: testRunId, testDefinition: { id: testDefinitionId } },
|
||||
});
|
||||
|
||||
if (!testRun) throw new NotFoundError('Test run not found');
|
||||
await this.getTestRun(req);
|
||||
|
||||
await this.testRunRepository.delete({ id: testRunId });
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Container } from '@n8n/di';
|
||||
import { mockInstance } from 'n8n-core/test/utils';
|
||||
|
||||
import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||
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 { TestDefinitionRepository } from '@/databases/repositories/test-definition.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 { createWorkflow } from '@test-integration/db/workflows';
|
||||
import * as testDb from '@test-integration/test-db';
|
||||
|
@ -19,9 +21,11 @@ let testDefinition: TestDefinition;
|
|||
let otherTestDefinition: TestDefinition;
|
||||
let ownerShell: User;
|
||||
|
||||
const testRunner = mockInstance(TestRunnerService);
|
||||
|
||||
const testServer = utils.setupTestServer({
|
||||
endpointGroups: ['workflows', 'evaluation'],
|
||||
enabledFeatures: ['feat:sharing'],
|
||||
enabledFeatures: ['feat:sharing', 'feat:multipleMainInstances'],
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
|
@ -57,13 +61,13 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => {
|
|||
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');
|
||||
|
||||
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(
|
||||
`/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(
|
||||
`/evaluation/test-definitions/${testDefinition.id}/runs/123`,
|
||||
);
|
||||
|
@ -159,7 +163,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs/:id', () => {
|
|||
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 testRun = await testRunRepository.createTestRun(otherTestDefinition.id);
|
||||
|
||||
|
@ -218,7 +222,7 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () =>
|
|||
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(
|
||||
`/evaluation/test-definitions/${testDefinition.id}/runs/123`,
|
||||
);
|
||||
|
@ -226,7 +230,7 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () =>
|
|||
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 testRun = await testRunRepository.createTestRun(otherTestDefinition.id);
|
||||
|
||||
|
@ -237,3 +241,46 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () =>
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -43,7 +43,7 @@ export interface UpdateTestResponse {
|
|||
export interface TestRunRecord {
|
||||
id: string;
|
||||
testDefinitionId: string;
|
||||
status: 'new' | 'running' | 'completed' | 'error';
|
||||
status: 'new' | 'running' | 'completed' | 'error' | 'cancelled';
|
||||
metrics?: Record<string, number>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
@ -221,6 +221,21 @@ export const startTestRun = async (context: IRestApiContext, testDefinitionId: s
|
|||
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
|
||||
export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => {
|
||||
return await makeRestApiRequest<{ success: boolean }>(
|
||||
|
|
|
@ -3,6 +3,7 @@ import type { TestListItem } from '@/components/TestDefinition/types';
|
|||
import TimeAgo from '@/components/TimeAgo.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import n8nIconButton from 'n8n-design-system/components/N8nIconButton';
|
||||
import { computed } from 'vue';
|
||||
|
||||
export interface TestItemProps {
|
||||
test: TestListItem;
|
||||
|
@ -16,6 +17,7 @@ const emit = defineEmits<{
|
|||
'view-details': [testId: string];
|
||||
'edit-test': [testId: string];
|
||||
'delete-test': [testId: string];
|
||||
'cancel-test-run': [testId: string, testRunId: string | null];
|
||||
}>();
|
||||
|
||||
const actions = [
|
||||
|
@ -24,6 +26,14 @@ const actions = [
|
|||
id: 'run',
|
||||
event: () => emit('run-test', props.test.id),
|
||||
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',
|
||||
|
@ -44,6 +54,8 @@ const actions = [
|
|||
tooltip: locale.baseText('testDefinition.deleteTest'),
|
||||
},
|
||||
];
|
||||
|
||||
const visibleActions = computed(() => actions.filter((action) => action.show?.() ?? true));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -85,7 +97,12 @@ const actions = [
|
|||
</div>
|
||||
|
||||
<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>
|
||||
{{ action.tooltip }}
|
||||
</template>
|
||||
|
|
|
@ -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.completed'), value: 'completed' },
|
||||
{ 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,
|
||||
},
|
||||
|
|
|
@ -28,6 +28,7 @@ const statusThemeMap: Record<string, string> = {
|
|||
completed: 'success',
|
||||
error: 'danger',
|
||||
success: 'success',
|
||||
cancelled: 'default',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
|
@ -36,6 +37,7 @@ const statusLabelMap: Record<string, string> = {
|
|||
completed: locale.baseText('testDefinition.listRuns.status.completed'),
|
||||
error: locale.baseText('testDefinition.listRuns.status.error'),
|
||||
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> {
|
||||
|
|
|
@ -24,6 +24,7 @@ export interface TestExecution {
|
|||
errorRate: number | null;
|
||||
metrics: Record<string, number>;
|
||||
status: TestRunRecord['status'];
|
||||
id: string | null;
|
||||
}
|
||||
|
||||
export interface TestListItem {
|
||||
|
|
|
@ -2823,10 +2823,12 @@
|
|||
"testDefinition.list.errorRate": "Error rate: {errorRate}",
|
||||
"testDefinition.list.testStartError": "Failed to start test run",
|
||||
"testDefinition.list.testStarted": "Test run started",
|
||||
"testDefinition.list.testCancelled": "Test run cancelled",
|
||||
"testDefinition.list.loadError": "Failed to load tests",
|
||||
"testDefinition.listRuns.status.new": "New",
|
||||
"testDefinition.listRuns.status.running": "Running",
|
||||
"testDefinition.listRuns.status.completed": "Completed",
|
||||
"testDefinition.listRuns.status.cancelled": "Cancelled",
|
||||
"testDefinition.listRuns.status.error": "Error",
|
||||
"testDefinition.listRuns.status.success": "Success",
|
||||
"testDefinition.listRuns.metricsOverTime": "Metrics over time",
|
||||
|
@ -2844,6 +2846,7 @@
|
|||
"testDefinition.runDetail.testCase.status": "Test case status",
|
||||
"testDefinition.runDetail.totalCases": "Total cases",
|
||||
"testDefinition.runTest": "Run Test",
|
||||
"testDefinition.cancelTestRun": "Cancel Test Run",
|
||||
"testDefinition.notImplemented": "This feature is not implemented yet!",
|
||||
"testDefinition.viewDetails": "View Details",
|
||||
"testDefinition.editTest": "Edit Test",
|
||||
|
|
|
@ -307,6 +307,15 @@ export const useTestDefinitionStore = defineStore(
|
|||
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 result = await testDefinitionsApi.deleteTestRun(rootStore.restApiContext, params);
|
||||
if (result.success) {
|
||||
|
@ -369,6 +378,7 @@ export const useTestDefinitionStore = defineStore(
|
|||
fetchTestRuns,
|
||||
getTestRun,
|
||||
startTestRun,
|
||||
cancelTestRun,
|
||||
deleteTestRun,
|
||||
cleanupPolling,
|
||||
};
|
||||
|
|
|
@ -48,6 +48,7 @@ function getTestExecution(testId: string): TestExecution {
|
|||
const lastRun = testDefinitionStore.lastRunByTestId[testId];
|
||||
if (!lastRun) {
|
||||
return {
|
||||
id: null,
|
||||
lastRun: null,
|
||||
errorRate: 0,
|
||||
metrics: {},
|
||||
|
@ -56,6 +57,7 @@ function getTestExecution(testId: string): TestExecution {
|
|||
}
|
||||
|
||||
const mockExecutions = {
|
||||
id: lastRun.id,
|
||||
lastRun: lastRun.updatedAt ?? '',
|
||||
errorRate: 0,
|
||||
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) {
|
||||
void router.push({ name: VIEWS.TEST_DEFINITION_RUNS, params: { testId } });
|
||||
}
|
||||
|
@ -165,6 +191,7 @@ onMounted(() => {
|
|||
@view-details="onViewDetails"
|
||||
@edit-test="onEditTest"
|
||||
@delete-test="onDeleteTest"
|
||||
@cancel-test-run="onCancelTestRun"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue