mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -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';
|
} 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>;
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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 }>(
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue