Merge branch 'master' of https://github.com/n8n-io/n8n into node-2015-google-calendar-confusing-errors

This commit is contained in:
Michael Kret 2025-01-07 14:36:10 +02:00
commit 501c58e04a
57 changed files with 4102 additions and 650 deletions

View file

@ -87,7 +87,7 @@ export class EnterpriseCredentialsService {
if (credential) {
// Decrypt the data if we found the credential with the `credential:update`
// scope.
decryptedData = this.credentialsService.decrypt(credential);
decryptedData = this.credentialsService.decrypt(credential, true);
} else {
// Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data.

View file

@ -542,7 +542,7 @@ export class CredentialsService {
if (sharing) {
// Decrypt the data if we found the credential with the `credential:update`
// scope.
decryptedData = this.decrypt(sharing.credentials);
decryptedData = this.decrypt(sharing.credentials, true);
} else {
// Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data.

View file

@ -9,7 +9,8 @@ import { jsonColumnType, WithTimestampsAndStringId } from './abstract-entity';
// Entity representing a node in a workflow under test, for which data should be mocked during test execution
export type MockedNodeItem = {
name: string;
name?: string;
id: string;
};
/**

View file

@ -16,6 +16,6 @@ export const testDefinitionPatchRequestBodySchema = z
description: z.string().optional(),
evaluationWorkflowId: z.string().min(1).optional(),
annotationTagId: z.string().min(1).optional(),
mockedNodes: z.array(z.object({ name: z.string() })).optional(),
mockedNodes: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
})
.strict();

View file

@ -121,13 +121,26 @@ export class TestDefinitionService {
relations: ['workflow'],
});
const existingNodeNames = new Set(existingTestDefinition.workflow.nodes.map((n) => n.name));
const existingNodeNames = new Map(
existingTestDefinition.workflow.nodes.map((n) => [n.name, n]),
);
const existingNodeIds = new Map(existingTestDefinition.workflow.nodes.map((n) => [n.id, n]));
attrs.mockedNodes.forEach((node) => {
if (!existingNodeNames.has(node.name)) {
throw new BadRequestError(`Pinned node not found in the workflow: ${node.name}`);
if (!existingNodeIds.has(node.id) || (node.name && !existingNodeNames.has(node.name))) {
throw new BadRequestError(
`Pinned node not found in the workflow: ${node.id} (${node.name})`,
);
}
});
// Update the node names OR node ids if they are not provided
attrs.mockedNodes = attrs.mockedNodes.map((node) => {
return {
id: node.id ?? (node.name && existingNodeNames.get(node.name)?.id),
name: node.name ?? (node.id && existingNodeIds.get(node.id)?.name),
};
});
}
// Update the test definition

View file

@ -7,13 +7,24 @@ const wfUnderTestJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
);
const wfUnderTestRenamedNodesJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test-renamed-nodes.json'), {
encoding: 'utf-8',
}),
);
const executionDataJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
);
describe('createPinData', () => {
test('should create pin data from past execution data', () => {
const mockedNodes = ['When clicking Test workflow'].map((name) => ({ name }));
const mockedNodes = [
{
id: '72256d90-3a67-4e29-b032-47df4e5768af',
name: 'When clicking Test workflow',
},
];
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -25,7 +36,7 @@ describe('createPinData', () => {
});
test('should not create pin data for non-existing mocked nodes', () => {
const mockedNodes = ['Non-existing node'].map((name) => ({ name }));
const mockedNodes = ['non-existing-ID'].map((id) => ({ id }));
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -33,9 +44,17 @@ describe('createPinData', () => {
});
test('should create pin data for all mocked nodes', () => {
const mockedNodes = ['When clicking Test workflow', 'Edit Fields', 'Code'].map((name) => ({
name,
}));
const mockedNodes = [
{
id: '72256d90-3a67-4e29-b032-47df4e5768af', // 'When clicking Test workflow'
},
{
id: '319f29bc-1dd4-4122-b223-c584752151a4', // 'Edit Fields'
},
{
id: 'd2474215-63af-40a4-a51e-0ea30d762621', // 'Code'
},
];
const pinData = createPinData(wfUnderTestJson, mockedNodes, executionDataJson);
@ -53,4 +72,33 @@ describe('createPinData', () => {
expect(pinData).toEqual({});
});
test('should create pin data for all mocked nodes with renamed nodes', () => {
const mockedNodes = [
{
id: '72256d90-3a67-4e29-b032-47df4e5768af', // 'Manual Run'
},
{
id: '319f29bc-1dd4-4122-b223-c584752151a4', // 'Set Attribute'
},
{
id: 'd2474215-63af-40a4-a51e-0ea30d762621', // 'Code'
},
];
const pinData = createPinData(
wfUnderTestRenamedNodesJson,
mockedNodes,
executionDataJson,
wfUnderTestJson, // Pass original workflow JSON as pastWorkflowData
);
expect(pinData).toEqual(
expect.objectContaining({
'Manual Run': expect.anything(),
'Set Attribute': expect.anything(),
Code: expect.anything(),
}),
);
});
});

View file

@ -0,0 +1,78 @@
{
"name": "Workflow Under Test",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-80, 0],
"id": "72256d90-3a67-4e29-b032-47df4e5768af",
"name": "Manual Run"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "acfeecbe-443c-4220-b63b-d44d69216902",
"name": "foo",
"value": "bar",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [140, 0],
"id": "319f29bc-1dd4-4122-b223-c584752151a4",
"name": "Set Attribute"
},
{
"parameters": {
"jsCode": "for (const item of $input.all()) {\n item.json.random = Math.random();\n}\n\nreturn $input.all();"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [380, 0],
"id": "d2474215-63af-40a4-a51e-0ea30d762621",
"name": "Code"
}
],
"connections": {
"Manual Run": {
"main": [
[
{
"node": "Set attribute",
"type": "main",
"index": 0
}
]
]
},
"Set attribute": {
"main": [
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
},
"Wait": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
}
}
}

View file

@ -27,6 +27,12 @@ const wfUnderTestJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }),
);
const wfUnderTestRenamedNodesJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.under-test-renamed-nodes.json'), {
encoding: 'utf-8',
}),
);
const wfEvaluationJson = JSON.parse(
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
);
@ -60,6 +66,7 @@ const executionMocks = [
status: 'success',
executionData: {
data: stringify(executionDataJson),
workflowData: wfUnderTestJson,
},
}),
mock<ExecutionEntity>({
@ -68,6 +75,7 @@ const executionMocks = [
status: 'success',
executionData: {
data: stringify(executionDataJson),
workflowData: wfUnderTestRenamedNodesJson,
},
}),
];
@ -250,7 +258,7 @@ describe('TestRunnerService', () => {
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [{ name: 'When clicking Test workflow' }],
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
}),
);
@ -347,7 +355,7 @@ describe('TestRunnerService', () => {
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [{ name: 'When clicking Test workflow' }],
mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }],
}),
);

View file

@ -1,13 +1,14 @@
import { Service } from '@n8n/di';
import { parse } from 'flatted';
import { NodeConnectionType, Workflow } from 'n8n-workflow';
import type {
IDataObject,
IRun,
IRunData,
IRunExecutionData,
IWorkflowBase,
IWorkflowExecutionDataProcess,
} from 'n8n-workflow';
import { NodeConnectionType, Workflow } from 'n8n-workflow';
import assert from 'node:assert';
import { ActiveExecutions } from '@/active-executions';
@ -94,11 +95,17 @@ export class TestRunnerService {
private async runTestCase(
workflow: WorkflowEntity,
pastExecutionData: IRunExecutionData,
pastExecutionWorkflowData: IWorkflowBase,
mockedNodes: MockedNodeItem[],
userId: string,
): Promise<IRun | undefined> {
// Create pin data from the past execution data
const pinData = createPinData(workflow, mockedNodes, pastExecutionData);
const pinData = createPinData(
workflow,
mockedNodes,
pastExecutionData,
pastExecutionWorkflowData,
);
// Prepare the data to run the workflow
const data: IWorkflowExecutionDataProcess = {
@ -127,6 +134,7 @@ export class TestRunnerService {
evaluationWorkflow: WorkflowEntity,
expectedData: IRunData,
actualData: IRunData,
testRunId?: string,
) {
// Prepare the evaluation wf input data.
// Provide both the expected data and the actual data
@ -139,7 +147,13 @@ export class TestRunnerService {
// Prepare the data to run the evaluation workflow
const data = await getRunData(evaluationWorkflow, [evaluationInputData]);
// FIXME: This is a hack to add the testRunId to the evaluation workflow execution data
// So that we can fetch all execution runs for a test run
if (testRunId && data.executionData) {
data.executionData.resultData.metadata = {
testRunId,
};
}
data.executionMode = 'evaluation';
// Trigger the evaluation workflow
@ -235,6 +249,7 @@ export class TestRunnerService {
const testCaseExecution = await this.runTestCase(
workflow,
executionData,
pastExecution.executionData.workflowData,
test.mockedNodes,
user.id,
);
@ -256,10 +271,9 @@ export class TestRunnerService {
evaluationWorkflow,
originalRunData,
testCaseRunData,
testRun.id,
);
assert(evalExecution);
// Extract the output of the last node executed in the evaluation workflow
metrics.addResults(this.extractEvaluationResult(evalExecution));
}

View file

@ -1,4 +1,5 @@
import type { IRunExecutionData, IPinData } from 'n8n-workflow';
import assert from 'assert';
import type { IRunExecutionData, IPinData, IWorkflowBase } from 'n8n-workflow';
import type { MockedNodeItem } from '@/databases/entities/test-definition.ee';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
@ -13,16 +14,33 @@ export function createPinData(
workflow: WorkflowEntity,
mockedNodes: MockedNodeItem[],
executionData: IRunExecutionData,
pastWorkflowData?: IWorkflowBase,
) {
const pinData = {} as IPinData;
const workflowNodeNames = new Set(workflow.nodes.map((node) => node.name));
const workflowNodeIds = new Map(workflow.nodes.map((node) => [node.id, node.name]));
// If the past workflow data is provided, use it to create a map between node IDs and node names
const pastWorkflowNodeIds = new Map<string, string>();
if (pastWorkflowData) {
for (const node of pastWorkflowData.nodes) {
pastWorkflowNodeIds.set(node.id, node.name);
}
}
for (const mockedNode of mockedNodes) {
if (workflowNodeNames.has(mockedNode.name)) {
const nodeData = executionData.resultData.runData[mockedNode.name];
assert(mockedNode.id, 'Mocked node ID is missing');
const nodeName = workflowNodeIds.get(mockedNode.id);
// If mocked node is still present in the workflow
if (nodeName) {
// Try to restore node name from past execution data (it might have been renamed between past execution and up-to-date workflow)
const pastNodeName = pastWorkflowNodeIds.get(mockedNode.id) ?? nodeName;
const nodeData = executionData.resultData.runData[pastNodeName];
if (nodeData?.[0]?.data?.main?.[0]) {
pinData[mockedNode.name] = nodeData[0]?.data?.main?.[0];
pinData[nodeName] = nodeData[0]?.data?.main?.[0];
}
}
}

View file

@ -2,6 +2,7 @@ import { Container } from '@n8n/di';
import { In } from '@n8n/typeorm';
import config from '@/config';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user';
@ -555,6 +556,22 @@ describe('GET /credentials/:id', () => {
expect(secondCredential.data).toBeDefined();
});
test('should not redact the data when `includeData:true` is passed', async () => {
const credentialService = Container.get(CredentialsService);
const redactSpy = jest.spyOn(credentialService, 'redact');
const savedCredential = await saveCredential(randomCredentialPayload(), {
user: owner,
});
const response = await authOwnerAgent
.get(`/credentials/${savedCredential.id}`)
.query({ includeData: true });
validateMainCredentialData(response.body.data);
expect(response.body.data.data).toBeDefined();
expect(redactSpy).not.toHaveBeenCalled();
});
test('should retrieve non-owned cred for owner', async () => {
const [member1, member2] = await createManyUsers(2, {
role: 'global:member',

View file

@ -4,6 +4,7 @@ import type { Scope } from '@sentry/node';
import { Credentials } from 'n8n-core';
import { randomString } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
@ -1272,6 +1273,23 @@ describe('GET /credentials/:id', () => {
expect(secondResponse.body.data.data).toBeDefined();
});
test('should not redact the data when `includeData:true` is passed', async () => {
const credentialService = Container.get(CredentialsService);
const redactSpy = jest.spyOn(credentialService, 'redact');
const savedCredential = await saveCredential(randomCredentialPayload(), {
user: owner,
role: 'credential:owner',
});
const response = await authOwnerAgent
.get(`/credentials/${savedCredential.id}`)
.query({ includeData: true });
validateMainCredentialData(response.body.data);
expect(response.body.data.data).toBeDefined();
expect(redactSpy).not.toHaveBeenCalled();
});
test('should retrieve owned cred for member', async () => {
const savedCredential = await saveCredential(randomCredentialPayload(), {
user: member,

View file

@ -405,13 +405,14 @@ describe('PATCH /evaluation/test-definitions/:id', () => {
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
mockedNodes: [
{
id: 'uuid-1234',
name: 'Schedule Trigger',
},
],
});
expect(resp.statusCode).toBe(200);
expect(resp.body.data.mockedNodes).toEqual([{ name: 'Schedule Trigger' }]);
expect(resp.body.data.mockedNodes).toEqual([{ id: 'uuid-1234', name: 'Schedule Trigger' }]);
});
test('should return error if pinned nodes are invalid', async () => {

View file

@ -31,6 +31,10 @@ const props = defineProps({
multiple: {
type: Boolean,
},
multipleLimit: {
type: Number,
default: 0,
},
filterMethod: {
type: Function,
},
@ -120,6 +124,7 @@ defineExpose({
<ElSelect
v-bind="{ ...$props, ...listeners }"
ref="innerSelect"
:multiple-limit="props.multipleLimit"
:model-value="props.modelValue ?? undefined"
:size="computedSize"
:popper-class="props.popperClass"

View file

@ -1,5 +1,6 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import { makeRestApiRequest, request } from '@/utils/apiUtils';
export interface TestDefinitionRecord {
id: string;
name: string;
@ -9,7 +10,10 @@ export interface TestDefinitionRecord {
description?: string | null;
updatedAt?: string;
createdAt?: string;
annotationTag?: string | null;
mockedNodes?: Array<{ name: string }>;
}
interface CreateTestDefinitionParams {
name: string;
workflowId: string;
@ -21,31 +25,63 @@ export interface UpdateTestDefinitionParams {
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
description?: string | null;
mockedNodes?: Array<{ name: string }>;
}
export interface UpdateTestResponse {
createdAt: string;
updatedAt: string;
id: string;
name: string;
workflowId: string;
description: string | null;
annotationTag: string | null;
evaluationWorkflowId: string | null;
annotationTagId: string | null;
description?: string | null;
annotationTag?: string | null;
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
}
export interface TestRunRecord {
id: string;
testDefinitionId: string;
status: 'new' | 'running' | 'completed' | 'error';
metrics?: Record<string, number>;
createdAt: string;
updatedAt: string;
runAt: string;
completedAt: string;
}
interface GetTestRunParams {
testDefinitionId: string;
runId: string;
}
interface DeleteTestRunParams {
testDefinitionId: string;
runId: string;
}
const endpoint = '/evaluation/test-definitions';
const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) =>
`${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`;
export async function getTestDefinitions(context: IRestApiContext) {
export async function getTestDefinitions(
context: IRestApiContext,
params?: { workflowId?: string },
) {
let url = endpoint;
if (params?.workflowId) {
url += `?filter=${JSON.stringify({ workflowId: params.workflowId })}`;
}
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
context,
'GET',
endpoint,
url,
);
}
export async function getTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ id: string }>(context, 'GET', `${endpoint}/${id}`);
return await makeRestApiRequest<TestDefinitionRecord>(context, 'GET', `${endpoint}/${id}`);
}
export async function createTestDefinition(
@ -71,3 +107,125 @@ export async function updateTestDefinition(
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
}
// Metrics
export interface TestMetricRecord {
id: string;
name: string;
testDefinitionId: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateTestMetricParams {
testDefinitionId: string;
name: string;
}
export interface UpdateTestMetricParams {
name: string;
id: string;
testDefinitionId: string;
}
export interface DeleteTestMetricParams {
testDefinitionId: string;
id: string;
}
export const getTestMetrics = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestMetricRecord[]>(
context,
'GET',
getMetricsEndpoint(testDefinitionId),
);
};
export const getTestMetric = async (
context: IRestApiContext,
testDefinitionId: string,
id: string,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'GET',
getMetricsEndpoint(testDefinitionId, id),
);
};
export const createTestMetric = async (
context: IRestApiContext,
params: CreateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'POST',
getMetricsEndpoint(params.testDefinitionId),
{ name: params.name },
);
};
export const updateTestMetric = async (
context: IRestApiContext,
params: UpdateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'PATCH',
getMetricsEndpoint(params.testDefinitionId, params.id),
{ name: params.name },
);
};
export const deleteTestMetric = async (
context: IRestApiContext,
params: DeleteTestMetricParams,
) => {
return await makeRestApiRequest(
context,
'DELETE',
getMetricsEndpoint(params.testDefinitionId, params.id),
);
};
const getRunsEndpoint = (testDefinitionId: string, runId?: string) =>
`${endpoint}/${testDefinitionId}/runs${runId ? `/${runId}` : ''}`;
// Get all test runs for a test definition
export const getTestRuns = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestRunRecord[]>(
context,
'GET',
getRunsEndpoint(testDefinitionId),
);
};
// Get specific test run
export const getTestRun = async (context: IRestApiContext, params: GetTestRunParams) => {
return await makeRestApiRequest<TestRunRecord>(
context,
'GET',
getRunsEndpoint(params.testDefinitionId, params.runId),
);
};
// Start a new test run
export const startTestRun = async (context: IRestApiContext, testDefinitionId: string) => {
const response = await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${endpoint}/${testDefinitionId}/run`,
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 }>(
context,
'DELETE',
getRunsEndpoint(params.testDefinitionId, params.runId),
);
};

View file

@ -43,6 +43,25 @@ const executionToReturnTo = ref('');
const dirtyState = ref(false);
const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON, false);
// Track the routes that are used for the tabs
// This is used to determine which tab to show when the route changes
// TODO: It might be easier to manage this in the router config, by passing meta information to the routes
// This would allow us to specify it just once on the root route, and then have the tabs be determined for children
const testDefinitionRoutes: VIEWS[] = [
VIEWS.TEST_DEFINITION,
VIEWS.TEST_DEFINITION_EDIT,
VIEWS.TEST_DEFINITION_RUNS,
VIEWS.TEST_DEFINITION_RUNS_DETAIL,
VIEWS.TEST_DEFINITION_RUNS_COMPARE,
];
const workflowRoutes: VIEWS[] = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
const executionRoutes: VIEWS[] = [
VIEWS.EXECUTION_HOME,
VIEWS.WORKFLOW_EXECUTIONS,
VIEWS.EXECUTION_PREVIEW,
];
const tabBarItems = computed(() => {
const items = [
{ value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') },
@ -92,24 +111,30 @@ onMounted(async () => {
syncTabsWithRoute(route);
});
function isViewRoute(name: unknown): name is VIEWS {
return (
typeof name === 'string' &&
[testDefinitionRoutes, workflowRoutes, executionRoutes].flat().includes(name as VIEWS)
);
}
function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void {
if (to.matched.some((record) => record.name === VIEWS.TEST_DEFINITION)) {
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION;
}
if (
to.name === VIEWS.EXECUTION_HOME ||
to.name === VIEWS.WORKFLOW_EXECUTIONS ||
to.name === VIEWS.EXECUTION_PREVIEW
) {
activeHeaderTab.value = MAIN_HEADER_TABS.EXECUTIONS;
} else if (
to.name === VIEWS.WORKFLOW ||
to.name === VIEWS.NEW_WORKFLOW ||
to.name === VIEWS.EXECUTION_DEBUG
) {
activeHeaderTab.value = MAIN_HEADER_TABS.WORKFLOW;
// Map route types to their corresponding tab in the header
const routeTabMapping = [
{ routes: testDefinitionRoutes, tab: MAIN_HEADER_TABS.TEST_DEFINITION },
{ routes: executionRoutes, tab: MAIN_HEADER_TABS.EXECUTIONS },
{ routes: workflowRoutes, tab: MAIN_HEADER_TABS.WORKFLOW },
];
// Update the active tab based on the current route
if (to.name && isViewRoute(to.name)) {
const matchingTab = routeTabMapping.find(({ routes }) => routes.includes(to.name as VIEWS));
if (matchingTab) {
activeHeaderTab.value = matchingTab.tab;
}
}
// Store the current workflow ID, but only if it's not a new workflow
if (to.params.name !== 'new' && typeof to.params.name === 'string') {
workflowToReturnTo.value = to.params.name;
}

View file

@ -122,7 +122,7 @@ const showAddFirstProject = computed(
},
]"
:disabled="isCreatingProject"
type="tertiary"
type="secondary"
icon="plus"
data-test-id="add-first-project-button"
@click="globalEntityCreation.createProject"
@ -187,7 +187,6 @@ const showAddFirstProject = computed(
}
.addFirstProjectBtn {
border: 1px solid var(--color-background-dark);
font-size: var(--font-size-xs);
padding: var(--spacing-3xs);
margin: 0 var(--spacing-m) var(--spacing-m);

View file

@ -16,7 +16,10 @@ interface TagsDropdownProps {
allTags: ITag[];
isLoading: boolean;
tagsById: Record<string, ITag>;
createEnabled?: boolean;
manageEnabled?: boolean;
createTag?: (name: string) => Promise<ITag>;
multipleLimit?: number;
}
const i18n = useI18n();
@ -27,6 +30,10 @@ const props = withDefaults(defineProps<TagsDropdownProps>(), {
placeholder: '',
modelValue: () => [],
eventBus: null,
createEnabled: true,
manageEnabled: true,
createTag: undefined,
multipleLimit: 0,
});
const emit = defineEmits<{
@ -59,6 +66,17 @@ const appliedTags = computed<string[]>(() => {
return props.modelValue.filter((id: string) => props.tagsById[id]);
});
const containerClasses = computed(() => {
return { 'tags-container': true, focused: focused.value };
});
const dropdownClasses = computed(() => ({
'tags-dropdown': true,
[`tags-dropdown-${dropdownId}`]: true,
'tags-dropdown-create-enabled': props.createEnabled,
'tags-dropdown-manage-enabled': props.manageEnabled,
}));
watch(
() => props.allTags,
() => {
@ -189,7 +207,7 @@ onClickOutside(
</script>
<template>
<div ref="container" :class="{ 'tags-container': true, focused }" @keydown.stop>
<div ref="container" :class="containerClasses" @keydown.stop>
<N8nSelect
ref="selectRef"
:teleported="true"
@ -199,16 +217,17 @@ onClickOutside(
:filter-method="filterOptions"
filterable
multiple
:multiple-limit="props.multipleLimit"
:reserve-keyword="false"
loading-text="..."
:popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId].join(' ')"
:popper-class="dropdownClasses"
data-test-id="tags-dropdown"
@update:model-value="onTagsUpdated"
@visible-change="onVisibleChange"
@remove-tag="onRemoveTag"
>
<N8nOption
v-if="options.length === 0 && filter"
v-if="createEnabled && options.length === 0 && filter"
:key="CREATE_KEY"
ref="createRef"
:value="CREATE_KEY"
@ -220,7 +239,7 @@ onClickOutside(
</span>
</N8nOption>
<N8nOption v-else-if="options.length === 0" value="message" disabled>
<span>{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
<span v-if="createEnabled">{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
<span v-if="allTags.length > 0">{{
i18n.baseText('tagsDropdown.noMatchingTagsExist')
}}</span>
@ -237,7 +256,7 @@ onClickOutside(
data-test-id="tag"
/>
<N8nOption :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
<N8nOption v-if="manageEnabled" :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
<font-awesome-icon icon="cog" />
<span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span>
</N8nOption>
@ -313,7 +332,7 @@ onClickOutside(
}
}
&:after {
.tags-dropdown-manage-enabled &:after {
content: ' ';
display: block;
min-height: $--item-height;

View file

@ -1,18 +1,15 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { EditableField } from '../types';
export interface EvaluationHeaderProps {
modelValue: {
value: string;
isEditing: boolean;
tempValue: string;
};
startEditing: (field: string) => void;
saveChanges: (field: string) => void;
handleKeydown: (e: KeyboardEvent, field: string) => void;
modelValue: EditableField<string>;
startEditing: (field: 'name') => void;
saveChanges: (field: 'name') => void;
handleKeydown: (e: KeyboardEvent, field: 'name') => void;
}
defineEmits<{ 'update:modelValue': [value: EvaluationHeaderProps['modelValue']] }>();
defineEmits<{ 'update:modelValue': [value: EditableField<string>] }>();
defineProps<EvaluationHeaderProps>();
const locale = useI18n();

View file

@ -2,12 +2,14 @@
import { useI18n } from '@/composables/useI18n';
import { ElCollapseTransition } from 'element-plus';
import { ref, nextTick } from 'vue';
import N8nTooltip from 'n8n-design-system/components/N8nTooltip';
interface EvaluationStep {
title: string;
warning?: boolean;
small?: boolean;
expanded?: boolean;
tooltip?: string;
}
const props = withDefaults(defineProps<EvaluationStep>(), {
@ -15,12 +17,14 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
warning: false,
small: false,
expanded: true,
tooltip: '',
});
const locale = useI18n();
const isExpanded = ref(props.expanded);
const contentRef = ref<HTMLElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const isTooltipVisible = ref(false);
const toggleExpand = async () => {
isExpanded.value = !isExpanded.value;
@ -31,11 +35,32 @@ const toggleExpand = async () => {
}
}
};
const showTooltip = () => {
isTooltipVisible.value = true;
};
const hideTooltip = () => {
isTooltipVisible.value = false;
};
</script>
<template>
<div ref="containerRef" :class="[$style.evaluationStep, small && $style.small]">
<div :class="$style.content">
<div
ref="containerRef"
:class="[$style.evaluationStep, small && $style.small]"
data-test-id="evaluation-step"
>
<N8nTooltip :disabled="!tooltip" placement="right" :offset="25" :visible="isTooltipVisible">
<template #content>
{{ tooltip }}
</template>
<!-- This empty div is needed to ensure the tooltip trigger area spans the full width of the step.
Without it, the tooltip would only show when hovering over the content div, which is narrower.
The contentPlaceholder creates an invisible full-width area that can trigger the tooltip. -->
<div :class="$style.contentPlaceholder"></div>
</N8nTooltip>
<div :class="$style.content" @mouseenter="showTooltip" @mouseleave="hideTooltip">
<div :class="$style.header">
<div :class="[$style.icon, warning && $style.warning]">
<slot name="icon" />
@ -47,6 +72,7 @@ const toggleExpand = async () => {
:class="$style.collapseButton"
:aria-expanded="isExpanded"
:aria-controls="'content-' + title.replace(/\s+/g, '-')"
data-test-id="evaluation-step-collapse-button"
@click="toggleExpand"
>
{{
@ -59,7 +85,7 @@ const toggleExpand = async () => {
</div>
<ElCollapseTransition v-if="$slots.cardContent">
<div v-show="isExpanded" :class="$style.cardContentWrapper">
<div ref="contentRef" :class="$style.cardContent">
<div ref="contentRef" :class="$style.cardContent" data-test-id="evaluation-step-content">
<slot name="cardContent" />
</div>
</div>
@ -85,6 +111,14 @@ const toggleExpand = async () => {
width: 80%;
}
}
.contentPlaceholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
.icon {
display: flex;
align-items: center;

View file

@ -1,22 +1,30 @@
<script setup lang="ts">
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n';
export interface MetricsInputProps {
modelValue: string[];
modelValue: Array<Partial<TestMetricRecord>>;
}
const props = defineProps<MetricsInputProps>();
const emit = defineEmits<{ 'update:modelValue': [value: MetricsInputProps['modelValue']] }>();
const emit = defineEmits<{
'update:modelValue': [value: MetricsInputProps['modelValue']];
deleteMetric: [metric: Partial<TestMetricRecord>];
}>();
const locale = useI18n();
function addNewMetric() {
emit('update:modelValue', [...props.modelValue, '']);
emit('update:modelValue', [...props.modelValue, { name: '' }]);
}
function updateMetric(index: number, value: string) {
function updateMetric(index: number, name: string) {
const newMetrics = [...props.modelValue];
newMetrics[index] = value;
newMetrics[index].name = name;
emit('update:modelValue', newMetrics);
}
function onDeleteMetric(metric: Partial<TestMetricRecord>) {
emit('deleteMetric', metric);
}
</script>
<template>
@ -27,14 +35,15 @@ function updateMetric(index: number, value: string) {
:class="$style.metricField"
>
<div :class="$style.metricsContainer">
<div v-for="(metric, index) in modelValue" :key="index">
<div v-for="(metric, index) in modelValue" :key="index" :class="$style.metricItem">
<N8nInput
:ref="`metric_${index}`"
data-test-id="evaluation-metric-item"
:model-value="metric"
:model-value="metric.name"
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
@update:model-value="(value: string) => updateMetric(index, value)"
/>
<n8n-icon-button icon="trash" type="text" @click="onDeleteMetric(metric)" />
</div>
<n8n-button
type="tertiary"
@ -54,6 +63,11 @@ function updateMetric(index: number, value: string) {
gap: var(--spacing-xs);
}
.metricItem {
display: flex;
align-items: center;
}
.metricField {
width: 100%;
margin-top: var(--spacing-xs);

View file

@ -0,0 +1,194 @@
<script setup lang="ts">
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { computed, onMounted, ref, useCssModule } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import { createEventBus, N8nTooltip } from 'n8n-design-system';
import type { CanvasConnectionPort, CanvasEventBusEvents, CanvasNodeData } from '@/types';
import { useVueFlow } from '@vue-flow/core';
import { useI18n } from '@/composables/useI18n';
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const route = useRoute();
const router = useRouter();
const locale = useI18n();
const { resetWorkspace, initializeWorkspace } = useCanvasOperations({ router });
const eventBus = createEventBus<CanvasEventBusEvents>();
const style = useCssModule();
const uuid = crypto.randomUUID();
const props = defineProps<{
modelValue: Array<{ name: string }>;
}>();
const emit = defineEmits<{
'update:modelValue': [value: Array<{ name: string }>];
}>();
const isLoading = ref(true);
const workflowId = computed(() => route.params.name as string);
const testId = computed(() => route.params.testId as string);
const workflow = computed(() => workflowsStore.getWorkflowById(workflowId.value));
const workflowObject = computed(() => workflowsStore.getCurrentWorkflow(true));
const canvasId = computed(() => `${uuid}-${testId.value}`);
const { onNodesInitialized, fitView, zoomTo } = useVueFlow({ id: canvasId.value });
const nodes = computed(() => {
return workflow.value.nodes ?? [];
});
const connections = computed(() => workflow.value.connections);
const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping({
nodes,
connections,
workflowObject,
});
async function loadData() {
workflowsStore.resetState();
resetWorkspace();
const loadingPromise = Promise.all([
nodeTypesStore.getNodeTypes(),
workflowsStore.fetchWorkflow(workflowId.value),
]);
await loadingPromise;
initializeWorkspace(workflow.value);
disableAllNodes();
}
function getNodeNameById(id: string) {
return mappedNodes.value.find((node) => node.id === id)?.data?.name;
}
function updateNodeClasses(nodeIds: string[], isPinned: boolean) {
eventBus.emit('nodes:action', {
ids: nodeIds,
action: 'update:node:class',
payload: {
className: style.pinnedNode,
add: isPinned,
},
});
eventBus.emit('nodes:action', {
ids: nodeIds,
action: 'update:node:class',
payload: {
className: style.notPinnedNode,
add: !isPinned,
},
});
}
function disableAllNodes() {
const ids = mappedNodes.value.map((node) => node.id);
updateNodeClasses(ids, false);
const pinnedNodes = props.modelValue
.map((node) => {
const matchedNode = mappedNodes.value.find(
(mappedNode) => mappedNode?.data?.name === node.name,
);
return matchedNode?.id ?? null;
})
.filter((n) => n !== null);
if (pinnedNodes.length > 0) {
updateNodeClasses(pinnedNodes, true);
}
}
function onPinButtonClick(data: CanvasNodeData) {
const nodeName = getNodeNameById(data.id);
if (!nodeName) return;
const isPinned = props.modelValue.some((node) => node.name === nodeName);
const updatedNodes = isPinned
? props.modelValue.filter((node) => node.name !== nodeName)
: [...props.modelValue, { name: nodeName }];
emit('update:modelValue', updatedNodes);
updateNodeClasses([data.id], !isPinned);
}
function isPinButtonVisible(outputs: CanvasConnectionPort[]) {
return outputs.length === 1;
}
onNodesInitialized(async () => {
await fitView();
isLoading.value = false;
await zoomTo(0.7, { duration: 400 });
});
onMounted(loadData);
</script>
<template>
<div :class="$style.container">
<N8nSpinner v-if="isLoading" size="xlarge" type="dots" :class="$style.spinner" />
<Canvas
:id="canvasId"
:loading="isLoading"
:class="{ [$style.canvas]: true }"
:nodes="mappedNodes"
:connections="mappedConnections"
:show-bug-reporting-button="false"
:read-only="true"
:event-bus="eventBus"
>
<template #nodeToolbar="{ data, outputs }">
<div :class="$style.pinButtonContainer">
<N8nTooltip v-if="isPinButtonVisible(outputs)" placement="left">
<template #content>
{{ locale.baseText('testDefinition.edit.nodesPinning.pinButtonTooltip') }}
</template>
<n8n-icon-button
type="tertiary"
size="large"
icon="thumbtack"
:class="$style.pinButton"
@click="onPinButtonClick(data)"
/>
</N8nTooltip>
</div>
</template>
</Canvas>
</div>
</template>
<style lang="scss" module>
.container {
width: 100vw;
height: 100%;
}
.pinButtonContainer {
position: absolute;
right: 0;
display: flex;
justify-content: flex-end;
bottom: 100%;
}
.pinButton {
cursor: pointer;
color: var(--canvas-node--border-color);
border: none;
}
.notPinnedNode,
.pinnedNode {
:global(.n8n-node-icon) > div {
filter: contrast(40%) brightness(1.5) grayscale(100%);
}
}
.pinnedNode {
--canvas-node--border-color: hsla(247, 49%, 55%, 1);
:global(.n8n-node-icon) > div {
filter: contrast(40%) brightness(1.5) grayscale(100%);
}
}
.spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>

View file

@ -3,40 +3,48 @@ import { useI18n } from '@/composables/useI18n';
import type { ITag } from '@/Interface';
import { createEventBus } from 'n8n-design-system';
import { computed } from 'vue';
import type { EditableField } from '../types';
export interface TagsInputProps {
modelValue?: {
isEditing: boolean;
appliedTagIds: string[];
};
modelValue: EditableField<string[]>;
allTags: ITag[];
tagsById: Record<string, ITag>;
isLoading: boolean;
startEditing: (field: string) => void;
saveChanges: (field: string) => void;
cancelEditing: (field: string) => void;
startEditing: (field: 'tags') => void;
saveChanges: (field: 'tags') => void;
cancelEditing: (field: 'tags') => void;
createTag?: (name: string) => Promise<ITag>;
}
const props = withDefaults(defineProps<TagsInputProps>(), {
modelValue: () => ({
isEditing: false,
appliedTagIds: [],
value: [],
tempValue: [],
}),
createTag: undefined,
});
const emit = defineEmits<{ 'update:modelValue': [value: TagsInputProps['modelValue']] }>();
const locale = useI18n();
const tagsEventBus = createEventBus();
/**
* Compute the tag name by ID
*/
const getTagName = computed(() => (tagId: string) => {
return props.tagsById[tagId]?.name ?? '';
});
/**
* Update the tempValue of the tags when the dropdown changes.
* This does not finalize the changes; that happens on blur or hitting enter.
*/
function updateTags(tags: string[]) {
const newTags = tags[0] ? [tags[0]] : [];
emit('update:modelValue', {
...props.modelValue,
appliedTagIds: newTags,
tempValue: tags,
});
}
</script>
@ -48,12 +56,13 @@ function updateTags(tags: string[]) {
:bold="false"
size="small"
>
<!-- Read-only view -->
<div v-if="!modelValue.isEditing" :class="$style.tagsRead" @click="startEditing('tags')">
<n8n-text v-if="modelValue.appliedTagIds.length === 0" size="small">
<n8n-text v-if="modelValue.value.length === 0" size="small">
{{ locale.baseText('testDefinition.edit.selectTag') }}
</n8n-text>
<n8n-tag
v-for="tagId in modelValue.appliedTagIds"
v-for="tagId in modelValue.value"
:key="tagId"
:text="getTagName(tagId)"
data-test-id="evaluation-tag-field"
@ -66,24 +75,26 @@ function updateTags(tags: string[]) {
transparent
/>
</div>
<!-- Editing view -->
<TagsDropdown
v-else
:model-value="modelValue.appliedTagIds"
:model-value="modelValue.tempValue"
:placeholder="locale.baseText('executionAnnotationView.chooseOrCreateATag')"
:create-enabled="false"
:create-enabled="modelValue.tempValue.length === 0"
:all-tags="allTags"
:is-loading="isLoading"
:tags-by-id="tagsById"
data-test-id="workflow-tags-dropdown"
:event-bus="tagsEventBus"
:create-tag="createTag"
:manage-enabled="false"
:multiple-limit="1"
@update:model-value="updateTags"
@esc="cancelEditing('tags')"
@blur="saveChanges('tags')"
/>
</n8n-input-label>
<n8n-text size="small" color="text-light">{{
locale.baseText('testDefinition.edit.tagsHelpText')
}}</n8n-text>
</div>
</template>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
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';
@ -20,21 +21,25 @@ const emit = defineEmits<{
const actions = [
{
icon: 'play',
id: 'run',
event: () => emit('run-test', props.test.id),
tooltip: locale.baseText('testDefinition.runTest'),
},
{
icon: 'list',
id: 'view',
event: () => emit('view-details', props.test.id),
tooltip: locale.baseText('testDefinition.viewDetails'),
},
{
icon: 'pen',
id: 'edit',
event: () => emit('edit-test', props.test.id),
tooltip: locale.baseText('testDefinition.editTest'),
},
{
icon: 'trash',
id: 'delete',
event: () => emit('delete-test', props.test.id),
tooltip: locale.baseText('testDefinition.deleteTest'),
},
@ -42,20 +47,27 @@ const actions = [
</script>
<template>
<div :class="$style.testItem" @click="$emit('view-details', test.id)">
<div
:class="$style.testItem"
:data-test-id="`test-item-${test.id}`"
@click="$emit('view-details', test.id)"
>
<div :class="$style.testInfo">
<div :class="$style.testName">
{{ test.name }}
<n8n-tag v-if="test.tagName" :text="test.tagName" />
</div>
<div :class="$style.testCases">
{{ locale.baseText('testDefinition.list.testCases', { adjustToNumber: test.testCases }) }}
<n8n-loading v-if="!test.execution.lastRun" :loading="true" :rows="1" />
<span v-else>{{
locale.baseText('testDefinition.list.lastRun', {
interpolate: { lastRun: test.execution.lastRun },
})
}}</span>
<n8n-text size="small">
{{ locale.baseText('testDefinition.list.testRuns', { adjustToNumber: test.testCases }) }}
</n8n-text>
<template v-if="test.execution.status === 'running'">
{{ locale.baseText('testDefinition.list.running') }}
<n8n-spinner />
</template>
<span v-else-if="test.execution.lastRun">
{{ locale.baseText('testDefinition.list.lastRun') }}
<TimeAgo :date="test.execution.lastRun" />
</span>
</div>
</div>
@ -68,7 +80,7 @@ const actions = [
}}
</div>
<div v-for="(value, key) in test.execution.metrics" :key="key" :class="$style.metric">
{{ key }}: {{ value ?? '-' }}
{{ key }}: {{ value.toFixed(2) ?? '-' }}
</div>
</div>
@ -80,6 +92,7 @@ const actions = [
<component
:is="n8nIconButton"
:icon="action.icon"
:data-test-id="`${action.id}-test-button-${test.id}`"
type="tertiary"
size="mini"
@click.stop="action.event"
@ -115,7 +128,6 @@ const actions = [
align-items: center;
gap: var(--spacing-2xs);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-4xs);
font-size: var(--font-size-s);
}

View file

@ -12,7 +12,7 @@ const locale = useI18n();
</script>
<template>
<div :class="$style.testsList">
<div :class="$style.testsList" data-test-id="test-definition-list">
<div :class="$style.testsHeader">
<n8n-button
:label="locale.baseText('testDefinition.list.createNew')"

View file

@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed, watchEffect } from 'vue';
import { Line } from 'vue-chartjs';
import { useMetricsChart } from '../composables/useMetricsChart';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n';
import type { AppliedThemeOption } from '@/Interface';
const emit = defineEmits<{
'update:selectedMetric': [value: string];
}>();
const props = defineProps<{
selectedMetric: string;
runs: TestRunRecord[];
theme?: AppliedThemeOption;
}>();
const locale = useI18n();
const metricsChart = useMetricsChart(props.theme);
const availableMetrics = computed(() => {
return props.runs.reduce((acc, run) => {
const metricKeys = Object.keys(run.metrics ?? {});
return [...new Set([...acc, ...metricKeys])];
}, [] as string[]);
});
const chartData = computed(() => metricsChart.generateChartData(props.runs, props.selectedMetric));
const chartOptions = computed(() =>
metricsChart.generateChartOptions({
metric: props.selectedMetric,
xTitle: locale.baseText('testDefinition.listRuns.runDate'),
}),
);
watchEffect(() => {
if (props.runs.length > 0 && !props.selectedMetric) {
emit('update:selectedMetric', availableMetrics.value[0]);
}
});
</script>
<template>
<div v-if="availableMetrics.length > 0" :class="$style.metricsChartContainer">
<div :class="$style.chartHeader">
<N8nText>{{ locale.baseText('testDefinition.listRuns.metricsOverTime') }}</N8nText>
<N8nSelect
:model-value="selectedMetric"
:class="$style.metricSelect"
placeholder="Select metric"
@update:model-value="emit('update:selectedMetric', $event)"
>
<N8nOption
v-for="metric in availableMetrics"
:key="metric"
:label="metric"
:value="metric"
/>
</N8nSelect>
</div>
<div :class="$style.chartWrapper">
<Line
v-if="availableMetrics.length > 0"
:key="selectedMetric"
:data="chartData"
:options="chartOptions"
:class="$style.metricsChart"
/>
</div>
</div>
</template>
<style lang="scss" module>
.metricsChartContainer {
margin: var(--spacing-m) 0;
background: var(--color-background-xlight);
border-radius: var(--border-radius-large);
box-shadow: var(--box-shadow-base);
.chartHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-m);
padding: var(--spacing-s);
border-bottom: 1px solid var(--color-foreground-base);
}
.chartTitle {
font-size: var(--font-size-l);
font-weight: var(--font-weight-bold);
color: var(--color-text-base);
}
.metricSelect {
max-width: 15rem;
}
.chartWrapper {
position: relative;
height: 400px;
width: 100%;
padding: var(--spacing-s);
}
}
</style>

View file

@ -0,0 +1,119 @@
<script setup lang="ts">
import type { TestRunRecord } from '@/api/testDefinition.ee';
import { computed, ref } from 'vue';
import type { TestDefinitionTableColumn } from '../shared/TestDefinitionTable.vue';
import TestDefinitionTable from '../shared/TestDefinitionTable.vue';
import { convertToDisplayDate } from '@/utils/typesUtils';
import { VIEWS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits<{
getRunDetail: [run: TestRunRecord];
selectionChange: [runs: TestRunRecord[]];
deleteRuns: [runs: TestRunRecord[]];
}>();
const props = defineProps<{
runs: TestRunRecord[];
selectable?: boolean;
}>();
const locale = useI18n();
const navigateToRunDetail = (run: TestRunRecord) => emit('getRunDetail', run);
const selectedRows = ref<TestRunRecord[]>([]);
const metrics = computed(() => {
return props.runs.reduce((acc, run) => {
const metricKeys = Object.keys(run.metrics ?? {});
return [...new Set([...acc, ...metricKeys])];
}, [] as string[]);
});
const columns = computed((): Array<TestDefinitionTableColumn<TestRunRecord>> => {
return [
{
prop: 'runNumber',
label: locale.baseText('testDefinition.listRuns.runNumber'),
width: 200,
route: (row: TestRunRecord) => ({
name: VIEWS.TEST_DEFINITION_RUNS_DETAIL,
params: { testId: row.testDefinitionId, runId: row.id },
}),
formatter: (row: TestRunRecord) => `${row.id}`,
},
{
prop: 'status',
label: locale.baseText('testDefinition.listRuns.status'),
filters: [
{ text: locale.baseText('testDefinition.listRuns.status.new'), value: 'new' },
{ 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' },
],
filterMethod: (value: string, row: TestRunRecord) => row.status === value,
},
{
prop: 'date',
label: locale.baseText('testDefinition.listRuns.runDate'),
sortable: true,
formatter: (row: TestRunRecord) => convertToDisplayDate(new Date(row.runAt).getTime()),
},
...metrics.value.map((metric) => ({
prop: `metrics.${metric}`,
label: metric,
sortable: true,
formatter: (row: TestRunRecord) => `${row.metrics?.[metric]?.toFixed(2) ?? '-'}`,
})),
];
});
function onSelectionChange(runs: TestRunRecord[]) {
selectedRows.value = runs;
emit('selectionChange', runs);
}
function deleteRuns() {
emit('deleteRuns', selectedRows.value);
}
</script>
<template>
<div :class="$style.container">
<div :class="$style.footer">
<n8n-button
v-show="selectedRows.length > 0"
type="danger"
:class="$style.activator"
:size="'medium'"
:icon="'trash'"
data-test-id="delete-runs-button"
@click="deleteRuns"
>
{{
locale.baseText('testDefinition.listRuns.deleteRuns', {
adjustToNumber: selectedRows.length,
})
}}
</n8n-button>
</div>
<TestDefinitionTable
:data="runs"
:columns="columns"
selectable
@row-click="navigateToRunDetail"
@selection-change="onSelectionChange"
/>
<N8nText :class="$style.runsTableTotal">{{
locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length })
}}</N8nText>
</div>
</template>
<style module lang="scss">
.container {
display: flex;
flex-direction: column;
gap: 10px;
}
</style>

View file

@ -0,0 +1,145 @@
import type { ChartData, ChartOptions } from 'chart.js';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import dateFormat from 'dateformat';
import type { AppliedThemeOption } from '@/Interface';
const THEME_COLORS = {
light: {
primary: 'rgb(255, 110, 92)',
text: {
primary: 'rgb(68, 68, 68)',
secondary: 'rgb(102, 102, 102)',
},
background: 'rgb(255, 255, 255)',
grid: 'rgba(68, 68, 68, 0.1)',
},
dark: {
primary: 'rgb(255, 110, 92)',
text: {
primary: 'rgb(255, 255, 255)',
secondary: 'rgba(255, 255, 255, 0.7)',
},
background: 'rgb(32, 32, 32)',
grid: 'rgba(255, 255, 255, 0.1)',
},
};
export function useMetricsChart(mode: AppliedThemeOption = 'light') {
const colors = THEME_COLORS[mode];
const toRGBA = (color: string, alpha: number) => {
if (color.includes('rgba')) return color;
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
};
function generateChartData(runs: TestRunRecord[], metric: string): ChartData<'line'> {
const sortedRuns = [...runs]
.sort((a, b) => new Date(a.runAt).getTime() - new Date(b.runAt).getTime())
.filter((run) => run.metrics?.[metric]);
return {
labels: sortedRuns.map((run) => {
return dateFormat(run.runAt, 'yyyy-mm-dd HH:MM');
}),
datasets: [
{
label: metric,
data: sortedRuns.map((run) => run.metrics?.[metric] ?? 0),
borderColor: colors.primary,
backgroundColor: toRGBA(colors.primary, 0.1),
borderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: colors.primary,
pointBorderColor: colors.primary,
pointHoverBackgroundColor: colors.background,
pointHoverBorderColor: colors.primary,
tension: 0.4,
fill: true,
},
],
};
}
function generateChartOptions(params: { metric: string; xTitle: string }): ChartOptions<'line'> {
return {
responsive: true,
maintainAspectRatio: false,
devicePixelRatio: 2,
interaction: {
mode: 'index' as const,
intersect: false,
},
scales: {
y: {
beginAtZero: true,
grid: {
color: colors.grid,
},
ticks: {
padding: 8,
color: colors.text.primary,
},
title: {
display: true,
text: params.metric,
padding: 16,
color: colors.text.primary,
},
},
x: {
grid: {
display: false,
},
ticks: {
maxRotation: 45,
minRotation: 45,
color: colors.text.primary,
},
title: {
display: true,
text: params.xTitle,
padding: 16,
color: colors.text.primary,
},
},
},
plugins: {
tooltip: {
backgroundColor: colors.background,
titleColor: colors.text.primary,
titleFont: {
weight: '600',
},
bodyColor: colors.text.secondary,
bodySpacing: 4,
padding: 12,
borderColor: toRGBA(colors.primary, 0.2),
borderWidth: 1,
displayColors: true,
callbacks: {
title: (tooltipItems) => tooltipItems[0].label,
label: (context) => `${params.metric}: ${context.parsed.y.toFixed(2)}`,
},
},
legend: {
display: false,
},
},
animation: {
duration: 750,
easing: 'easeInOutQuart',
},
transitions: {
active: {
animation: {
duration: 300,
},
},
},
};
}
return {
generateChartData,
generateChartOptions,
};
}

View file

@ -1,27 +1,10 @@
import { ref, computed } from 'vue';
import type { ComponentPublicInstance } from 'vue';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
import type { ComponentPublicInstance, ComputedRef } from 'vue';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue';
import type { N8nInput } from 'n8n-design-system';
import type { UpdateTestDefinitionParams } from '@/api/testDefinition.ee';
interface EditableField {
value: string;
isEditing: boolean;
tempValue: string;
}
export interface IEvaluationFormState {
name: EditableField;
description: string;
tags: {
isEditing: boolean;
appliedTagIds: string[];
};
evaluationWorkflow: INodeParameterResourceLocator;
metrics: string[];
}
import type { EditableField, EditableFormState, EvaluationFormState } from '../types';
type FormRefs = {
nameInput: ComponentPublicInstance<typeof N8nInput>;
@ -29,64 +12,75 @@ type FormRefs = {
};
export function useTestDefinitionForm() {
// Stores
const evaluationsStore = useTestDefinitionStore();
// Form state
const state = ref<IEvaluationFormState>({
description: '',
// State initialization
const state = ref<EvaluationFormState>({
name: {
value: `My Test [${new Date().toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })}]`,
isEditing: false,
value: `My Test ${evaluationsStore.allTestDefinitions.length + 1}`,
tempValue: '',
isEditing: false,
},
tags: {
value: [],
tempValue: [],
isEditing: false,
appliedTagIds: [],
},
description: '',
evaluationWorkflow: {
mode: 'list',
value: '',
__rl: true,
},
metrics: [''],
metrics: [],
mockedNodes: [],
});
// Loading states
const isSaving = ref(false);
const fieldsIssues = ref<Array<{ field: string; message: string }>>([]);
// Field refs
const fields = ref<FormRefs>({} as FormRefs);
// Methods
// A computed mapping of editable fields to their states
// This ensures TS knows the exact type of each field.
const editableFields: ComputedRef<{
name: EditableField<string>;
tags: EditableField<string[]>;
}> = computed(() => ({
name: state.value.name,
tags: state.value.tags,
}));
/**
* Load test data including metrics.
*/
const loadTestData = async (testId: string) => {
try {
await evaluationsStore.fetchAll({ force: true });
const testDefinition = evaluationsStore.testDefinitionsById[testId];
if (testDefinition) {
state.value = {
description: testDefinition.description ?? '',
name: {
value: testDefinition.name ?? '',
isEditing: false,
tempValue: '',
},
tags: {
isEditing: false,
appliedTagIds: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
},
evaluationWorkflow: {
mode: 'list',
value: testDefinition.evaluationWorkflowId ?? '',
__rl: true,
},
metrics: [''],
const metrics = await evaluationsStore.fetchMetrics(testId);
state.value.description = testDefinition.description ?? '';
state.value.name = {
value: testDefinition.name ?? '',
isEditing: false,
tempValue: '',
};
state.value.tags = {
isEditing: false,
value: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
tempValue: [],
};
state.value.evaluationWorkflow = {
mode: 'list',
value: testDefinition.evaluationWorkflowId ?? '',
__rl: true,
};
state.value.metrics = metrics;
state.value.mockedNodes = testDefinition.mockedNodes ?? [];
}
} catch (error) {
// TODO: Throw better errors
console.error('Failed to load test data', error);
}
};
@ -98,22 +92,43 @@ export function useTestDefinitionForm() {
fieldsIssues.value = [];
try {
// Prepare parameters for creating a new test
const params = {
name: state.value.name.value,
workflowId,
description: state.value.description,
};
const newTest = await evaluationsStore.create(params);
return newTest;
} catch (error) {
throw error;
return await evaluationsStore.create(params);
} finally {
isSaving.value = false;
}
};
const deleteMetric = async (metricId: string, testId: string) => {
await evaluationsStore.deleteMetric({ id: metricId, testDefinitionId: testId });
state.value.metrics = state.value.metrics.filter((metric) => metric.id !== metricId);
};
const updateMetrics = async (testId: string) => {
const promises = state.value.metrics.map(async (metric) => {
if (!metric.name) return;
if (!metric.id) {
const createdMetric = await evaluationsStore.createMetric({
name: metric.name,
testDefinitionId: testId,
});
metric.id = createdMetric.id;
} else {
await evaluationsStore.updateMetric({
name: metric.name,
id: metric.id,
testDefinitionId: testId,
});
}
});
await Promise.all(promises);
};
const updateTest = async (testId: string) => {
if (isSaving.value) return;
@ -121,74 +136,93 @@ export function useTestDefinitionForm() {
fieldsIssues.value = [];
try {
// Check if the test ID is provided
if (!testId) {
throw new Error('Test ID is required for updating a test');
}
// Prepare parameters for updating the existing test
const params: UpdateTestDefinitionParams = {
name: state.value.name.value,
description: state.value.description,
};
if (state.value.evaluationWorkflow.value) {
params.evaluationWorkflowId = state.value.evaluationWorkflow.value.toString();
}
const annotationTagId = state.value.tags.appliedTagIds[0];
const annotationTagId = state.value.tags.value[0];
if (annotationTagId) {
params.annotationTagId = annotationTagId;
}
// Update the existing test
if (state.value.mockedNodes.length > 0) {
params.mockedNodes = state.value.mockedNodes;
}
return await evaluationsStore.update({ ...params, id: testId });
} catch (error) {
throw error;
} finally {
isSaving.value = false;
}
};
const startEditing = async (field: string) => {
if (field === 'name') {
state.value.name.tempValue = state.value.name.value;
state.value.name.isEditing = true;
} else {
state.value.tags.isEditing = true;
/**
* Start editing an editable field by copying `value` to `tempValue`.
*/
function startEditing<T extends keyof EditableFormState>(field: T) {
const fieldObj = editableFields.value[field];
if (fieldObj.isEditing) {
// Already editing, do nothing
return;
}
};
const saveChanges = (field: string) => {
if (field === 'name') {
state.value.name.value = state.value.name.tempValue;
state.value.name.isEditing = false;
if (Array.isArray(fieldObj.value)) {
fieldObj.tempValue = [...fieldObj.value];
} else {
state.value.tags.isEditing = false;
fieldObj.tempValue = fieldObj.value;
}
};
fieldObj.isEditing = true;
}
/**
* Save changes by copying `tempValue` back into `value`.
*/
function saveChanges<T extends keyof EditableFormState>(field: T) {
const fieldObj = editableFields.value[field];
fieldObj.value = Array.isArray(fieldObj.tempValue)
? [...fieldObj.tempValue]
: fieldObj.tempValue;
fieldObj.isEditing = false;
}
const cancelEditing = (field: string) => {
if (field === 'name') {
state.value.name.isEditing = false;
state.value.name.tempValue = '';
/**
* Cancel editing and revert `tempValue` from `value`.
*/
function cancelEditing<T extends keyof EditableFormState>(field: T) {
const fieldObj = editableFields.value[field];
if (Array.isArray(fieldObj.value)) {
fieldObj.tempValue = [...fieldObj.value];
} else {
state.value.tags.isEditing = false;
fieldObj.tempValue = fieldObj.value;
}
};
fieldObj.isEditing = false;
}
const handleKeydown = (event: KeyboardEvent, field: string) => {
/**
* Handle keyboard events during editing.
*/
function handleKeydown<T extends keyof EditableFormState>(event: KeyboardEvent, field: T) {
if (event.key === 'Escape') {
cancelEditing(field);
} else if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
saveChanges(field);
}
};
}
return {
state,
fields,
isSaving: computed(() => isSaving.value),
fieldsIssues: computed(() => fieldsIssues.value),
deleteMetric,
updateMetrics,
loadTestData,
createTest,
updateTest,

View file

@ -0,0 +1,74 @@
<script setup lang="ts" generic="T">
import { useI18n } from '@/composables/useI18n';
import type { TestDefinitionTableColumn } from './TestDefinitionTable.vue';
import { useRouter } from 'vue-router';
defineProps<{
column: TestDefinitionTableColumn<T>;
row: T;
}>();
defineEmits<{
click: [];
}>();
const locale = useI18n();
const router = useRouter();
interface WithStatus {
status: string;
}
function hasStatus(row: unknown): row is WithStatus {
return typeof row === 'object' && row !== null && 'status' in row;
}
const statusThemeMap: Record<string, string> = {
new: 'info',
running: 'warning',
completed: 'success',
error: 'danger',
success: 'success',
};
const statusLabelMap: Record<string, string> = {
new: locale.baseText('testDefinition.listRuns.status.new'),
running: locale.baseText('testDefinition.listRuns.status.running'),
completed: locale.baseText('testDefinition.listRuns.status.completed'),
error: locale.baseText('testDefinition.listRuns.status.error'),
success: locale.baseText('testDefinition.listRuns.status.success'),
};
function hasProperty(row: unknown, prop: string): row is Record<string, unknown> {
return typeof row === 'object' && row !== null && prop in row;
}
const getCellContent = (column: TestDefinitionTableColumn<T>, row: T) => {
if (column.formatter) {
return column.formatter(row);
}
return hasProperty(row, column.prop) ? row[column.prop] : undefined;
};
</script>
<template>
<div v-if="column.route">
<a v-if="column.openInNewTab" :href="router.resolve(column.route(row)).href" target="_blank">
{{ getCellContent(column, row) }}
</a>
<router-link v-else :to="column.route(row)">
{{ getCellContent(column, row) }}
</router-link>
</div>
<N8nBadge
v-else-if="column.prop === 'status' && hasStatus(row)"
:theme="statusThemeMap[row.status]"
class="mr-4xs"
>
{{ statusLabelMap[row.status] }}
</N8nBadge>
<div v-else>
{{ getCellContent(column, row) }}
</div>
</template>

View file

@ -0,0 +1,95 @@
<script setup lang="ts" generic="T">
import type { RouteLocationRaw } from 'vue-router';
import TableCell from './TableCell.vue';
import { ElTable, ElTableColumn } from 'element-plus';
import { ref } from 'vue';
import type { TableInstance } from 'element-plus';
/**
* A reusable table component for displaying test definition data
* @template T - The type of data being displayed in the table rows
*/
/**
* Configuration for a table column
* @template TRow - The type of data in each table row
*/
export type TestDefinitionTableColumn<TRow> = {
prop: string;
label: string;
width?: number;
sortable?: boolean;
filters?: Array<{ text: string; value: string }>;
filterMethod?: (value: string, row: TRow) => boolean;
route?: (row: TRow) => RouteLocationRaw;
openInNewTab?: boolean;
formatter?: (row: TRow) => string;
};
withDefaults(
defineProps<{
data: T[];
columns: Array<TestDefinitionTableColumn<T>>;
showControls?: boolean;
defaultSort?: { prop: string; order: 'ascending' | 'descending' };
selectable?: boolean;
selectableFilter?: (row: T) => boolean;
}>(),
{
defaultSort: () => ({ prop: 'date', order: 'ascending' }),
selectable: false,
selectableFilter: () => true,
},
);
const tableRef = ref<TableInstance>();
const selectedRows = ref<T[]>([]);
const emit = defineEmits<{
rowClick: [row: T];
selectionChange: [rows: T[]];
}>();
const handleSelectionChange = (rows: T[]) => {
selectedRows.value = rows;
emit('selectionChange', rows);
};
</script>
<template>
<ElTable
ref="tableRef"
:default-sort="defaultSort"
:data="data"
style="width: 100%"
:border="true"
max-height="800"
resizable
@selection-change="handleSelectionChange"
>
<ElTableColumn
v-if="selectable"
type="selection"
:selectable="selectableFilter"
width="55"
data-test-id="table-column-select"
/>
<ElTableColumn
v-for="column in columns"
:key="column.prop"
v-bind="column"
style="width: 100%"
:resizable="true"
data-test-id="table-column"
>
<template #default="{ row }">
<TableCell
:column="column"
:row="row"
@click="$emit('rowClick', row)"
data-test-id="table-cell"
/>
</template>
</ElTableColumn>
</ElTable>
</template>

View file

@ -6,11 +6,11 @@ import userEvent from '@testing-library/user-event';
const renderComponent = createComponentRenderer(MetricsInput);
describe('MetricsInput', () => {
let props: { modelValue: string[] };
let props: { modelValue: Array<{ name: string }> };
beforeEach(() => {
props = {
modelValue: ['Metric 1', 'Metric 2'],
modelValue: [{ name: 'Metric 1' }, { name: 'Metric 2' }],
};
});
@ -25,14 +25,18 @@ describe('MetricsInput', () => {
it('should update a metric when typing in the input', async () => {
const { getAllByPlaceholderText, emitted } = renderComponent({
props: {
modelValue: [''],
modelValue: [{ name: '' }],
},
});
const inputs = getAllByPlaceholderText('Enter metric name');
await userEvent.type(inputs[0], 'Updated Metric 1');
expect(emitted('update:modelValue')).toBeTruthy();
expect(emitted('update:modelValue')).toEqual('Updated Metric 1'.split('').map((c) => [[c]]));
// Every character typed triggers an update event. Let's check the last emission.
const allEmits = emitted('update:modelValue');
expect(allEmits).toBeTruthy();
// The last emission should contain the fully updated name
const lastEmission = allEmits[allEmits.length - 1];
expect(lastEmission).toEqual([[{ name: 'Updated Metric 1' }]]);
});
it('should render correctly with no initial metrics', () => {
@ -47,10 +51,89 @@ describe('MetricsInput', () => {
const { getByText, emitted } = renderComponent({ props });
const addButton = getByText('New metric');
addButton.click();
addButton.click();
addButton.click();
await userEvent.click(addButton);
await userEvent.click(addButton);
await userEvent.click(addButton);
expect(emitted('update:modelValue')).toHaveProperty('length', 3);
// Each click adds a new metric
const updateEvents = emitted('update:modelValue');
expect(updateEvents).toHaveLength(3);
// Check the structure of one of the emissions
// Initial: [{ name: 'Metric 1' }, { name: 'Metric 2' }]
// After first click: [{ name: 'Metric 1' }, { name: 'Metric 2' }, { name: '' }]
expect(updateEvents[0]).toEqual([[{ name: 'Metric 1' }, { name: 'Metric 2' }, { name: '' }]]);
});
it('should emit "deleteMetric" event when a delete button is clicked', async () => {
const { getAllByRole, emitted } = renderComponent({ props });
// Each metric row has a delete button, identified by "button"
const deleteButtons = getAllByRole('button', { name: '' });
expect(deleteButtons).toHaveLength(props.modelValue.length);
// Click on the delete button for the second metric
await userEvent.click(deleteButtons[1]);
expect(emitted('deleteMetric')).toBeTruthy();
expect(emitted('deleteMetric')[0]).toEqual([{ name: 'Metric 2' }]);
});
it('should emit multiple update events as the user types and reflect the final name correctly', async () => {
const { getAllByPlaceholderText, emitted } = renderComponent({
props: {
modelValue: [{ name: '' }],
},
});
const inputs = getAllByPlaceholderText('Enter metric name');
await userEvent.type(inputs[0], 'ABC');
const allEmits = emitted('update:modelValue');
expect(allEmits).toBeTruthy();
// Each character typed should emit a new value
expect(allEmits.length).toBe(3);
expect(allEmits[2]).toEqual([[{ name: 'ABC' }]]);
});
it('should not break if metrics are empty and still allow adding a new metric', async () => {
props.modelValue = [];
const { queryAllByRole, getByText, emitted } = renderComponent({ props });
// No metrics initially
const inputs = queryAllByRole('textbox');
expect(inputs).toHaveLength(0);
const addButton = getByText('New metric');
await userEvent.click(addButton);
const updates = emitted('update:modelValue');
expect(updates).toBeTruthy();
expect(updates[0]).toEqual([[{ name: '' }]]);
// After adding one metric, we should now have an input
const { getAllByPlaceholderText } = renderComponent({
props: { modelValue: [{ name: '' }] },
});
const updatedInputs = getAllByPlaceholderText('Enter metric name');
expect(updatedInputs).toHaveLength(1);
});
it('should handle deleting the first metric and still display remaining metrics correctly', async () => {
const { getAllByPlaceholderText, getAllByRole, rerender, emitted } = renderComponent({
props,
});
const inputs = getAllByPlaceholderText('Enter metric name');
expect(inputs).toHaveLength(2);
const deleteButtons = getAllByRole('button', { name: '' });
await userEvent.click(deleteButtons[0]);
expect(emitted('deleteMetric')).toBeTruthy();
expect(emitted('deleteMetric')[0]).toEqual([{ name: 'Metric 1' }]);
await rerender({ modelValue: [{ name: 'Metric 2' }] });
const updatedInputs = getAllByPlaceholderText('Enter metric name');
expect(updatedInputs).toHaveLength(1);
expect(updatedInputs[0]).toHaveValue('Metric 2');
});
});

View file

@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { useMetricsChart } from '../composables/useMetricsChart';
import type { TestRunRecord } from '@/api/testDefinition.ee';
describe('useMetricsChart', () => {
const mockRuns: TestRunRecord[] = [
{
id: '1',
testDefinitionId: 'test1',
status: 'completed',
createdAt: '2025-01-06T10:00:00Z',
updatedAt: '2025-01-06T10:00:00Z',
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: { responseTime: 100, successRate: 95 },
},
{
id: '2',
testDefinitionId: 'test1',
status: 'completed',
createdAt: '2025-01-06T10:00:00Z',
updatedAt: '2025-01-06T10:00:00Z',
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: { responseTime: 150, successRate: 98 },
},
] as TestRunRecord[];
describe('generateChartData', () => {
it('should generate correct chart data structure', () => {
const { generateChartData } = useMetricsChart('light');
const result = generateChartData(mockRuns, 'responseTime');
expect(result.labels).toHaveLength(2);
expect(result.datasets).toHaveLength(1);
expect(result.datasets[0].data).toEqual([100, 150]);
});
it('should sort runs by date', () => {
const unsortedRuns = [
{
id: '1',
testDefinitionId: 'test1',
status: 'completed',
createdAt: '2025-01-06T10:05:00Z',
updatedAt: '2025-01-06T10:05:00Z',
completedAt: '2025-01-06T10:05:00Z',
runAt: '2025-01-06T10:05:00Z',
metrics: { responseTime: 150 },
},
{
id: '2',
testDefinitionId: 'test1',
status: 'completed',
createdAt: '2025-01-06T10:00:00Z',
updatedAt: '2025-01-06T10:00:00Z',
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: { responseTime: 100 },
},
] as TestRunRecord[];
const { generateChartData } = useMetricsChart('light');
const result = generateChartData(unsortedRuns, 'responseTime');
expect(result.datasets[0].data).toEqual([100, 150]);
});
it('should filter out runs without specified metric', () => {
const runsWithMissingMetrics = [
{
id: '1',
testDefinitionId: 'test1',
status: 'completed',
createdAt: '2025-01-06T10:00:00Z',
updatedAt: '2025-01-06T10:00:00Z',
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: { responseTime: 100 },
},
{
id: '2',
testDefinitionId: 'test1',
status: 'completed',
createdAt: '2025-01-06T10:00:00Z',
updatedAt: '2025-01-06T10:00:00Z',
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: {},
},
] as TestRunRecord[];
const { generateChartData } = useMetricsChart('light');
const result = generateChartData(runsWithMissingMetrics, 'responseTime');
expect(result.labels).toHaveLength(1);
expect(result.datasets[0].data).toEqual([100]);
});
it('should handle dark theme colors', () => {
const { generateChartData } = useMetricsChart('dark');
const result = generateChartData(mockRuns, 'responseTime');
expect(result.datasets[0].pointHoverBackgroundColor).toBe('rgb(32, 32, 32)');
});
});
describe('generateChartOptions', () => {
it('should generate correct chart options structure', () => {
const { generateChartOptions } = useMetricsChart('light');
const result = generateChartOptions({ metric: 'responseTime', xTitle: 'Time' });
expect(result.scales?.y?.title?.text).toBe('responseTime');
expect(result.scales?.x?.title?.text).toBe('Time');
expect(result.responsive).toBe(true);
expect(result.maintainAspectRatio).toBe(false);
});
it('should apply correct theme colors', () => {
const { generateChartOptions } = useMetricsChart('dark');
const result = generateChartOptions({ metric: 'responseTime', xTitle: 'Time' });
expect(result.scales?.y?.ticks?.color).toBe('rgb(255, 255, 255)');
expect(result.plugins?.tooltip?.backgroundColor).toBe('rgb(32, 32, 32)');
});
});
});

View file

@ -12,18 +12,21 @@ const TEST_DEF_A: TestDefinitionRecord = {
evaluationWorkflowId: '456',
workflowId: '123',
annotationTagId: '789',
annotationTag: null,
};
const TEST_DEF_B: TestDefinitionRecord = {
id: '2',
name: 'Test Definition B',
workflowId: '123',
description: 'Description B',
annotationTag: null,
};
const TEST_DEF_NEW: TestDefinitionRecord = {
id: '3',
workflowId: '123',
name: 'New Test Definition',
description: 'New Description',
annotationTag: null,
};
beforeEach(() => {
@ -35,44 +38,78 @@ afterEach(() => {
vi.clearAllMocks();
});
describe('useTestDefinitionForm', async () => {
it('should initialize with default props', async () => {
describe('useTestDefinitionForm', () => {
it('should initialize with default props', () => {
const { state } = useTestDefinitionForm();
expect(state.value.description).toEqual('');
expect(state.value.description).toBe('');
expect(state.value.name.value).toContain('My Test');
expect(state.value.tags.appliedTagIds).toEqual([]);
expect(state.value.metrics).toEqual(['']);
expect(state.value.evaluationWorkflow.value).toEqual('');
expect(state.value.tags.value).toEqual([]);
expect(state.value.metrics).toEqual([]);
expect(state.value.evaluationWorkflow.value).toBe('');
});
it('should load test data', async () => {
const { loadTestData, state } = useTestDefinitionForm();
const fetchSpy = vi.fn();
const fetchSpy = vi.spyOn(useTestDefinitionStore(), 'fetchAll');
const fetchMetricsSpy = vi.spyOn(useTestDefinitionStore(), 'fetchMetrics').mockResolvedValue([
{
id: 'metric1',
name: 'Metric 1',
testDefinitionId: TEST_DEF_A.id,
},
]);
const evaluationsStore = mockedStore(useTestDefinitionStore);
expect(state.value.description).toEqual('');
expect(state.value.name.value).toContain('My Test');
evaluationsStore.testDefinitionsById = {
[TEST_DEF_A.id]: TEST_DEF_A,
[TEST_DEF_B.id]: TEST_DEF_B,
};
evaluationsStore.fetchAll = fetchSpy;
await loadTestData(TEST_DEF_A.id);
expect(fetchSpy).toBeCalled();
expect(fetchMetricsSpy).toBeCalledWith(TEST_DEF_A.id);
expect(state.value.name.value).toEqual(TEST_DEF_A.name);
expect(state.value.description).toEqual(TEST_DEF_A.description);
expect(state.value.tags.appliedTagIds).toEqual([TEST_DEF_A.annotationTagId]);
expect(state.value.tags.value).toEqual([TEST_DEF_A.annotationTagId]);
expect(state.value.evaluationWorkflow.value).toEqual(TEST_DEF_A.evaluationWorkflowId);
expect(state.value.metrics).toEqual([
{ id: 'metric1', name: 'Metric 1', testDefinitionId: TEST_DEF_A.id },
]);
});
it('should gracefully handle loadTestData when no test definition found', async () => {
const { loadTestData, state } = useTestDefinitionForm();
const fetchSpy = vi.spyOn(useTestDefinitionStore(), 'fetchAll');
const evaluationsStore = mockedStore(useTestDefinitionStore);
evaluationsStore.testDefinitionsById = {};
await loadTestData('unknown-id');
expect(fetchSpy).toBeCalled();
// Should remain unchanged since no definition found
expect(state.value.description).toBe('');
expect(state.value.name.value).toContain('My Test');
expect(state.value.tags.value).toEqual([]);
expect(state.value.metrics).toEqual([]);
});
it('should handle errors while loading test data', async () => {
const { loadTestData } = useTestDefinitionForm();
const fetchSpy = vi
.spyOn(useTestDefinitionStore(), 'fetchAll')
.mockRejectedValue(new Error('Fetch Failed'));
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await loadTestData(TEST_DEF_A.id);
expect(fetchSpy).toBeCalled();
expect(consoleErrorSpy).toBeCalledWith('Failed to load test data', expect.any(Error));
consoleErrorSpy.mockRestore();
});
it('should save a new test', async () => {
const { createTest, state } = useTestDefinitionForm();
const createSpy = vi.fn().mockResolvedValue(TEST_DEF_NEW);
const evaluationsStore = mockedStore(useTestDefinitionStore);
evaluationsStore.create = createSpy;
const createSpy = vi.spyOn(useTestDefinitionStore(), 'create').mockResolvedValue(TEST_DEF_NEW);
state.value.name.value = TEST_DEF_NEW.name;
state.value.description = TEST_DEF_NEW.description ?? '';
@ -86,12 +123,24 @@ describe('useTestDefinitionForm', async () => {
expect(newTest).toEqual(TEST_DEF_NEW);
});
it('should handle errors when creating a new test', async () => {
const { createTest } = useTestDefinitionForm();
const createSpy = vi
.spyOn(useTestDefinitionStore(), 'create')
.mockRejectedValue(new Error('Create Failed'));
await expect(createTest('123')).rejects.toThrow('Create Failed');
expect(createSpy).toBeCalled();
});
it('should update an existing test', async () => {
const { updateTest, state } = useTestDefinitionForm();
const updateSpy = vi.fn().mockResolvedValue(TEST_DEF_B);
const evaluationsStore = mockedStore(useTestDefinitionStore);
evaluationsStore.update = updateSpy;
const updatedBTest = {
...TEST_DEF_B,
updatedAt: '2022-01-01T00:00:00.000Z',
createdAt: '2022-01-01T00:00:00.000Z',
};
const updateSpy = vi.spyOn(useTestDefinitionStore(), 'update').mockResolvedValue(updatedBTest);
state.value.name.value = TEST_DEF_B.name;
state.value.description = TEST_DEF_B.description ?? '';
@ -102,75 +151,183 @@ describe('useTestDefinitionForm', async () => {
name: TEST_DEF_B.name,
description: TEST_DEF_B.description,
});
expect(updatedTest).toEqual(TEST_DEF_B);
expect(updatedTest).toEqual(updatedBTest);
});
it('should start editing a field', async () => {
it('should throw an error if no testId is provided when updating a test', async () => {
const { updateTest } = useTestDefinitionForm();
await expect(updateTest('')).rejects.toThrow('Test ID is required for updating a test');
});
it('should handle errors when updating a test', async () => {
const { updateTest, state } = useTestDefinitionForm();
const updateSpy = vi
.spyOn(useTestDefinitionStore(), 'update')
.mockRejectedValue(new Error('Update Failed'));
state.value.name.value = 'Test';
state.value.description = 'Some description';
await expect(updateTest(TEST_DEF_A.id)).rejects.toThrow('Update Failed');
expect(updateSpy).toBeCalled();
});
it('should delete a metric', async () => {
const { state, deleteMetric } = useTestDefinitionForm();
const evaluationsStore = mockedStore(useTestDefinitionStore);
const deleteMetricSpy = vi.spyOn(evaluationsStore, 'deleteMetric');
state.value.metrics = [
{
id: 'metric1',
name: 'Metric 1',
testDefinitionId: '1',
},
{
id: 'metric2',
name: 'Metric 2',
testDefinitionId: '1',
},
];
await deleteMetric('metric1', TEST_DEF_A.id);
expect(deleteMetricSpy).toBeCalledWith({ id: 'metric1', testDefinitionId: TEST_DEF_A.id });
expect(state.value.metrics).toEqual([
{ id: 'metric2', name: 'Metric 2', testDefinitionId: '1' },
]);
});
it('should update metrics', async () => {
const { state, updateMetrics } = useTestDefinitionForm();
const evaluationsStore = mockedStore(useTestDefinitionStore);
const updateMetricSpy = vi.spyOn(evaluationsStore, 'updateMetric');
const createMetricSpy = vi
.spyOn(evaluationsStore, 'createMetric')
.mockResolvedValue({ id: 'metric_new', name: 'Metric 2', testDefinitionId: TEST_DEF_A.id });
state.value.metrics = [
{
id: 'metric1',
name: 'Metric 1',
testDefinitionId: TEST_DEF_A.id,
},
{
id: '',
name: 'Metric 2',
testDefinitionId: TEST_DEF_A.id,
}, // New metric that needs creation
];
await updateMetrics(TEST_DEF_A.id);
expect(createMetricSpy).toHaveBeenCalledWith({
name: 'Metric 2',
testDefinitionId: TEST_DEF_A.id,
});
expect(updateMetricSpy).toHaveBeenCalledWith({
name: 'Metric 1',
id: 'metric1',
testDefinitionId: TEST_DEF_A.id,
});
expect(state.value.metrics).toEqual([
{ id: 'metric1', name: 'Metric 1', testDefinitionId: TEST_DEF_A.id },
{ id: 'metric_new', name: 'Metric 2', testDefinitionId: TEST_DEF_A.id },
]);
});
it('should start editing a field', () => {
const { state, startEditing } = useTestDefinitionForm();
await startEditing('name');
startEditing('name');
expect(state.value.name.isEditing).toBe(true);
expect(state.value.name.tempValue).toBe(state.value.name.value);
await startEditing('tags');
startEditing('tags');
expect(state.value.tags.isEditing).toBe(true);
expect(state.value.tags.tempValue).toEqual(state.value.tags.value);
});
it('should save changes to a field', async () => {
it('should do nothing if startEditing is called while already editing', () => {
const { state, startEditing } = useTestDefinitionForm();
state.value.name.isEditing = true;
state.value.name.tempValue = 'Original Name';
startEditing('name');
// Should remain unchanged because it was already editing
expect(state.value.name.isEditing).toBe(true);
expect(state.value.name.tempValue).toBe('Original Name');
});
it('should save changes to a field', () => {
const { state, startEditing, saveChanges } = useTestDefinitionForm();
await startEditing('name');
// Name
startEditing('name');
state.value.name.tempValue = 'New Name';
saveChanges('name');
expect(state.value.name.isEditing).toBe(false);
expect(state.value.name.value).toBe('New Name');
await startEditing('tags');
state.value.tags.appliedTagIds = ['123'];
// Tags
startEditing('tags');
state.value.tags.tempValue = ['123'];
saveChanges('tags');
expect(state.value.tags.isEditing).toBe(false);
expect(state.value.tags.appliedTagIds).toEqual(['123']);
expect(state.value.tags.value).toEqual(['123']);
});
it('should cancel editing a field', async () => {
it('should cancel editing a field', () => {
const { state, startEditing, cancelEditing } = useTestDefinitionForm();
await startEditing('name');
const originalName = state.value.name.value;
startEditing('name');
state.value.name.tempValue = 'New Name';
cancelEditing('name');
expect(state.value.name.isEditing).toBe(false);
expect(state.value.name.tempValue).toBe('');
expect(state.value.name.tempValue).toBe(originalName);
await startEditing('tags');
state.value.tags.appliedTagIds = ['123'];
const originalTags = [...state.value.tags.value];
startEditing('tags');
state.value.tags.tempValue = ['123'];
cancelEditing('tags');
expect(state.value.tags.isEditing).toBe(false);
expect(state.value.tags.tempValue).toEqual(originalTags);
});
it('should handle keydown - Escape', async () => {
it('should handle keydown - Escape', () => {
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
await startEditing('name');
startEditing('name');
handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'name');
expect(state.value.name.isEditing).toBe(false);
await startEditing('tags');
startEditing('tags');
handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'tags');
expect(state.value.tags.isEditing).toBe(false);
});
it('should handle keydown - Enter', async () => {
it('should handle keydown - Enter', () => {
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
await startEditing('name');
startEditing('name');
state.value.name.tempValue = 'New Name';
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'name');
expect(state.value.name.isEditing).toBe(false);
expect(state.value.name.value).toBe('New Name');
await startEditing('tags');
state.value.tags.appliedTagIds = ['123'];
startEditing('tags');
state.value.tags.tempValue = ['123'];
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'tags');
expect(state.value.tags.isEditing).toBe(false);
expect(state.value.tags.value).toEqual(['123']);
});
it('should not save changes when shift+Enter is pressed', () => {
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
startEditing('name');
state.value.name.tempValue = 'New Name With Shift';
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }), 'name');
expect(state.value.name.isEditing).toBe(true);
expect(state.value.name.value).not.toBe('New Name With Shift');
});
});

View file

@ -1,7 +1,29 @@
import type { TestMetricRecord, TestRunRecord } from '@/api/testDefinition.ee';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
export interface EditableField<T = string> {
value: T;
tempValue: T;
isEditing: boolean;
}
export interface EditableFormState {
name: EditableField<string>;
tags: EditableField<string[]>;
}
export interface EvaluationFormState extends EditableFormState {
description: string;
evaluationWorkflow: INodeParameterResourceLocator;
metrics: TestMetricRecord[];
mockedNodes: Array<{ name: string }>;
}
export interface TestExecution {
lastRun: string | null;
errorRate: number | null;
metrics: Record<string, number>;
status: TestRunRecord['status'];
}
export interface TestListItem {

View file

@ -15,7 +15,7 @@ interface TagsDropdownWrapperProps {
const props = withDefaults(defineProps<TagsDropdownWrapperProps>(), {
placeholder: '',
modelValue: () => [],
createEnabled: false,
createEnabled: true,
eventBus: null,
});

View file

@ -80,6 +80,7 @@ const props = withDefaults(
executing?: boolean;
keyBindings?: boolean;
showBugReportingButton?: boolean;
loading?: boolean;
}>(),
{
id: 'canvas',
@ -90,6 +91,7 @@ const props = withDefaults(
readOnly: false,
executing: false,
keyBindings: true,
loading: false,
},
);
@ -131,7 +133,7 @@ const isPaneReady = ref(false);
const classes = computed(() => ({
[$style.canvas]: true,
[$style.ready]: isPaneReady.value,
[$style.ready]: !props.loading && isPaneReady.value,
}));
/**
@ -695,7 +697,11 @@ provide(CanvasKey, {
@update:outputs="onUpdateNodeOutputs"
@move="onUpdateNodePosition"
@add="onClickNodeAdd"
/>
>
<template v-if="$slots.nodeToolbar" #toolbar="toolbarProps">
<slot name="nodeToolbar" v-bind="toolbarProps" />
</template>
</Node>
</template>
<template #edge-canvas-edge="edgeProps">

View file

@ -39,6 +39,14 @@ type Props = NodeProps<CanvasNodeData> & {
hovered?: boolean;
};
const slots = defineSlots<{
toolbar?: (props: {
inputs: (typeof mainInputs)['value'];
outputs: (typeof mainOutputs)['value'];
data: CanvasNodeData;
}) => void;
}>();
const emit = defineEmits<{
add: [id: string, handle: string];
delete: [id: string];
@ -62,6 +70,10 @@ const contextMenu = useContextMenu();
const { connectingHandle } = useCanvas();
/*
Toolbar slot classes
*/
const nodeClasses = ref<string[]>([]);
const inputs = computed(() => props.data.inputs);
const outputs = computed(() => props.data.outputs);
const connections = computed(() => props.data.connections);
@ -83,6 +95,7 @@ const classes = computed(() => ({
[style.showToolbar]: showToolbar.value,
hovered: props.hovered,
selected: props.selected,
...Object.fromEntries([...nodeClasses.value].map((c) => [c, true])),
}));
/**
@ -92,7 +105,7 @@ const classes = computed(() => ({
const canvasNodeEventBus = ref(createEventBus<CanvasNodeEventBusEvents>());
function emitCanvasNodeEvent(event: CanvasEventBusEvents['nodes:action']) {
if (event.ids.includes(props.id)) {
if (event.ids.includes(props.id) && canvasNodeEventBus.value) {
canvasNodeEventBus.value.emit(event.action, event.payload);
}
}
@ -233,6 +246,12 @@ function onMove(position: XYPosition) {
emit('move', props.id, position);
}
function onUpdateClass({ className, add = true }: CanvasNodeEventBusEvents['update:node:class']) {
nodeClasses.value = add
? [...new Set([...nodeClasses.value, className])]
: nodeClasses.value.filter((c) => c !== className);
}
/**
* Provide
*/
@ -282,10 +301,12 @@ watch(outputs, (newValue, oldValue) => {
onMounted(() => {
props.eventBus?.on('nodes:action', emitCanvasNodeEvent);
canvasNodeEventBus.value?.on('update:node:class', onUpdateClass);
});
onBeforeUnmount(() => {
props.eventBus?.off('nodes:action', emitCanvasNodeEvent);
canvasNodeEventBus.value?.off('update:node:class', onUpdateClass);
});
</script>
@ -323,8 +344,13 @@ onBeforeUnmount(() => {
/>
</template>
<template v-if="slots.toolbar">
<slot name="toolbar" :inputs="mainInputs" :outputs="mainOutputs" :data="data" />
</template>
<CanvasNodeToolbar
v-if="nodeTypeDescription"
v-else-if="nodeTypeDescription"
data-test-id="canvas-node-toolbar"
:read-only="readOnly"
:class="$style.canvasNodeToolbar"
@delete="onDelete"

View file

@ -53,6 +53,7 @@ export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare';
export const PERSONALIZATION_MODAL_KEY = 'personalization';
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
export const NODE_PINNING_MODAL_KEY = 'nodePinning';
export const NPS_SURVEY_MODAL_KEY = 'npsSurvey';
export const WORKFLOW_ACTIVE_MODAL_KEY = 'activation';
export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall';
@ -497,6 +498,9 @@ export const enum VIEWS {
WORKFLOW_EXECUTIONS = 'WorkflowExecutions',
TEST_DEFINITION = 'TestDefinition',
TEST_DEFINITION_EDIT = 'TestDefinitionEdit',
TEST_DEFINITION_RUNS = 'TestDefinitionRuns',
TEST_DEFINITION_RUNS_COMPARE = 'TestDefinitionRunsCompare',
TEST_DEFINITION_RUNS_DETAIL = 'TestDefinitionRunsDetail',
NEW_TEST_DEFINITION = 'NewTestDefinition',
USAGE = 'Usage',
LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView',

View file

@ -2557,7 +2557,7 @@
"projects.menu.overview": "Overview",
"projects.menu.title": "Projects",
"projects.menu.personal": "Personal",
"projects.menu.addFirstProject": "Add first project",
"projects.menu.addFirstProject": "Add project",
"projects.settings": "Project settings",
"projects.settings.newProjectName": "My project",
"projects.settings.iconPicker.button.tooltip": "Choose project icon",
@ -2787,24 +2787,58 @@
"testDefinition.edit.testSaved": "Test saved",
"testDefinition.edit.testSaveFailed": "Failed to save test",
"testDefinition.edit.description": "Description",
"testDefinition.edit.description.tooltip": "Add details about what this test evaluates and what success looks like",
"testDefinition.edit.tagName": "Tag name",
"testDefinition.edit.step.intro": "When running a test",
"testDefinition.edit.step.executions": "Fetch 5 past executions",
"testDefinition.edit.step.executions": "Fetch past executions | Fetch {count} past execution | Fetch {count} past executions",
"testDefinition.edit.step.executions.tooltip": "Select which tagged executions to use as test cases. Each execution will be replayed to compare performance",
"testDefinition.edit.step.nodes": "Mock nodes",
"testDefinition.edit.step.mockedNodes": "No nodes mocked | {count} node mocked | {count} nodes mocked",
"testDefinition.edit.step.nodes.tooltip": "Replace specific nodes with test data to isolate what you're testing",
"testDefinition.edit.step.reRunExecutions": "Re-run executions",
"testDefinition.edit.step.reRunExecutions.tooltip": "Each test case will be re-run using the current workflow version",
"testDefinition.edit.step.compareExecutions": "Compare each past and new execution",
"testDefinition.edit.step.compareExecutions.tooltip": "Select which workflow to use for running the comparison tests",
"testDefinition.edit.step.metrics": "Summarise metrics",
"testDefinition.edit.step.metrics.tooltip": "Define which output fields to track and compare between test runs",
"testDefinition.edit.step.collapse": "Collapse",
"testDefinition.edit.step.expand": "Expand",
"testDefinition.edit.selectNodes": "Select nodes",
"testDefinition.edit.runExecution": "Run execution",
"testDefinition.edit.pastRuns": "Past runs",
"testDefinition.edit.pastRuns.total": "No runs | {count} run | {count} runs",
"testDefinition.edit.nodesPinning.pinButtonTooltip": "Pin execution data of this node during test run",
"testDefinition.list.testDeleted": "Test deleted",
"testDefinition.list.tests": "Tests",
"testDefinition.list.createNew": "Create new test",
"testDefinition.list.actionDescription": "Replay past executions to check whether performance has changed",
"testDefinition.list.actionButton": "Create Test",
"testDefinition.list.testCases": "No test cases | {count} test case | {count} test cases",
"testDefinition.list.lastRun": "Ran {lastRun}",
"testDefinition.list.testRuns": "No test runs | {count} test run | {count} test runs",
"testDefinition.list.lastRun": "Ran",
"testDefinition.list.running": "Running",
"testDefinition.list.errorRate": "Error rate: {errorRate}",
"testDefinition.list.testStartError": "Failed to start test run",
"testDefinition.list.testStarted": "Test run started",
"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.error": "Error",
"testDefinition.listRuns.status.success": "Success",
"testDefinition.listRuns.metricsOverTime": "Metrics over time",
"testDefinition.listRuns.status": "Status",
"testDefinition.listRuns.runNumber": "Run #",
"testDefinition.listRuns.runDate": "Run date",
"testDefinition.listRuns.runStatus": "Run status",
"testDefinition.listRuns.noRuns": "No test runs",
"testDefinition.listRuns.noRuns.description": "Run a test to see the results here",
"testDefinition.listRuns.deleteRuns": "No runs to delete | Delete {count} run | Delete {count} runs",
"testDefinition.listRuns.noRuns.button": "Run Test",
"testDefinition.runDetail.ranAt": "Ran at",
"testDefinition.runDetail.testCase": "Test case",
"testDefinition.runDetail.testCase.id": "Test case ID",
"testDefinition.runDetail.testCase.status": "Test case status",
"testDefinition.runDetail.totalCases": "Total cases",
"testDefinition.runTest": "Run Test",
"testDefinition.notImplemented": "This feature is not implemented yet!",
"testDefinition.viewDetails": "View Details",

View file

@ -18,6 +18,8 @@ import type { RouterMiddleware } from '@/types/router';
import { initializeAuthenticatedFeatures, initializeCore } from '@/init';
import { tryToParseNumber } from '@/utils/typesUtils';
import { projectsRoutes } from '@/routes/projects.routes';
import TestDefinitionRunsListView from './views/TestDefinition/TestDefinitionRunsListView.vue';
import TestDefinitionRunDetailView from './views/TestDefinition/TestDefinitionRunDetailView.vue';
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
const ErrorView = async () => await import('./views/ErrorView.vue');
@ -299,6 +301,32 @@ export const routes: RouteRecordRaw[] = [
middleware: ['authenticated'],
},
},
{
path: ':testId/runs',
name: VIEWS.TEST_DEFINITION_RUNS,
components: {
default: TestDefinitionRunsListView,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
},
{
path: ':testId/runs/:runId',
name: VIEWS.TEST_DEFINITION_RUNS_DETAIL,
components: {
default: TestDefinitionRunDetailView,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
},
},
],
},
{

View file

@ -172,6 +172,7 @@ export const useExecutionsStore = defineStore('executions', () => {
executionsCount.value = data.count;
executionsCountEstimated.value = data.estimated;
return data;
} catch (e) {
throw e;
} finally {

View file

@ -2,21 +2,49 @@ import { createPinia, setActivePinia } from 'pinia';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; // Adjust the import path as necessary
import { useRootStore } from '@/stores/root.store';
import { usePostHog } from '@/stores/posthog.store';
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
const { createTestDefinition, deleteTestDefinition, getTestDefinitions, updateTestDefinition } =
vi.hoisted(() => ({
getTestDefinitions: vi.fn(),
createTestDefinition: vi.fn(),
updateTestDefinition: vi.fn(),
deleteTestDefinition: vi.fn(),
}));
const {
createTestDefinition,
deleteTestDefinition,
getTestDefinitions,
updateTestDefinition,
getTestMetrics,
createTestMetric,
updateTestMetric,
deleteTestMetric,
getTestRuns,
getTestRun,
startTestRun,
deleteTestRun,
} = vi.hoisted(() => ({
getTestDefinitions: vi.fn(),
createTestDefinition: vi.fn(),
updateTestDefinition: vi.fn(),
deleteTestDefinition: vi.fn(),
getTestMetrics: vi.fn(),
createTestMetric: vi.fn(),
updateTestMetric: vi.fn(),
deleteTestMetric: vi.fn(),
getTestRuns: vi.fn(),
getTestRun: vi.fn(),
startTestRun: vi.fn(),
deleteTestRun: vi.fn(),
}));
vi.mock('@/api/testDefinition.ee', () => ({
createTestDefinition,
deleteTestDefinition,
getTestDefinitions,
updateTestDefinition,
getTestMetrics,
createTestMetric,
updateTestMetric,
deleteTestMetric,
getTestRuns,
getTestRun,
startTestRun,
deleteTestRun,
}));
vi.mock('@/stores/root.store', () => ({
@ -44,6 +72,23 @@ const TEST_DEF_NEW: TestDefinitionRecord = {
description: 'New Description',
};
const TEST_METRIC = {
id: 'metric1',
name: 'Test Metric',
testDefinitionId: '1',
};
const TEST_RUN: TestRunRecord = {
id: 'run1',
testDefinitionId: '1',
status: 'completed',
metrics: { metric1: 0.75 },
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
runAt: '2024-01-01',
completedAt: '2024-01-01',
};
describe('testDefinition.store.ee', () => {
let store: ReturnType<typeof useTestDefinitionStore>;
let rootStoreMock: ReturnType<typeof useRootStore>;
@ -64,6 +109,11 @@ describe('testDefinition.store.ee', () => {
createTestDefinition.mockResolvedValue(TEST_DEF_NEW);
deleteTestDefinition.mockResolvedValue({ success: true });
getTestRuns.mockResolvedValue([TEST_RUN]);
getTestRun.mockResolvedValue(TEST_RUN);
startTestRun.mockResolvedValue({ success: true });
deleteTestRun.mockResolvedValue({ success: true });
});
test('Initialization', () => {
@ -72,193 +122,460 @@ describe('testDefinition.store.ee', () => {
expect(store.hasTestDefinitions).toBe(false);
});
test('Fetching Test Definitions', async () => {
expect(store.isLoading).toBe(false);
describe('Test Definitions', () => {
test('Fetching Test Definitions', async () => {
expect(store.isLoading).toBe(false);
const result = await store.fetchAll();
const result = await store.fetchAll({ workflowId: '123' });
expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext);
expect(store.testDefinitionsById).toEqual({
'1': TEST_DEF_A,
'2': TEST_DEF_B,
expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext, {
workflowId: '123',
});
expect(store.testDefinitionsById).toEqual({
'1': TEST_DEF_A,
'2': TEST_DEF_B,
});
expect(store.isLoading).toBe(false);
expect(result).toEqual({
count: 2,
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
});
});
expect(store.isLoading).toBe(false);
expect(result).toEqual({
count: 2,
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
test('Fetching Test Definitions with force flag', async () => {
expect(store.isLoading).toBe(false);
const result = await store.fetchAll({ force: true, workflowId: '123' });
expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext, {
workflowId: '123',
});
expect(store.testDefinitionsById).toEqual({
'1': TEST_DEF_A,
'2': TEST_DEF_B,
});
expect(store.isLoading).toBe(false);
expect(result).toEqual({
count: 2,
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
});
});
test('Fetching Test Definitions when already fetched', async () => {
store.fetchedAll = true;
const result = await store.fetchAll();
expect(getTestDefinitions).not.toHaveBeenCalled();
expect(store.testDefinitionsById).toEqual({});
expect(result).toEqual({
count: 0,
testDefinitions: [],
});
});
test('Upserting Test Definitions - New Definition', () => {
const newDefinition = TEST_DEF_NEW;
store.upsertTestDefinitions([newDefinition]);
expect(store.testDefinitionsById).toEqual({
'3': TEST_DEF_NEW,
});
});
test('Upserting Test Definitions - Existing Definition', () => {
store.testDefinitionsById = {
'1': TEST_DEF_A,
};
const updatedDefinition = {
id: '1',
name: 'Updated Test Definition A',
description: 'Updated Description A',
workflowId: '123',
};
store.upsertTestDefinitions([updatedDefinition]);
expect(store.testDefinitionsById).toEqual({
1: updatedDefinition,
});
});
test('Creating a Test Definition', async () => {
const params = {
name: 'New Test Definition',
workflowId: 'test-workflow-id',
evaluationWorkflowId: 'test-evaluation-workflow-id',
description: 'New Description',
};
const result = await store.create(params);
expect(createTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
expect(store.testDefinitionsById).toEqual({
'3': TEST_DEF_NEW,
});
expect(result).toEqual(TEST_DEF_NEW);
});
test('Updating a Test Definition', async () => {
store.testDefinitionsById = {
'1': TEST_DEF_A,
'2': TEST_DEF_B,
};
const params = {
id: '1',
name: 'Updated Test Definition A',
description: 'Updated Description A',
workflowId: '123',
};
updateTestDefinition.mockResolvedValue(params);
const result = await store.update(params);
expect(updateTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1', {
name: 'Updated Test Definition A',
description: 'Updated Description A',
workflowId: '123',
});
expect(store.testDefinitionsById).toEqual({
'1': params,
'2': TEST_DEF_B,
});
expect(result).toEqual(params);
});
test('Deleting a Test Definition', () => {
store.testDefinitionsById = {
'1': TEST_DEF_A,
'2': TEST_DEF_B,
};
store.deleteTestDefinition('1');
expect(store.testDefinitionsById).toEqual({
'2': TEST_DEF_B,
});
});
test('Deleting a Test Definition by ID', async () => {
store.testDefinitionsById = {
'1': TEST_DEF_A,
};
const result = await store.deleteById('1');
expect(deleteTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
expect(store.testDefinitionsById).toEqual({});
expect(result).toBe(true);
});
});
test('Fetching Test Definitions with force flag', async () => {
expect(store.isLoading).toBe(false);
describe('Metrics', () => {
test('Fetching Metrics for a Test Definition', async () => {
getTestMetrics.mockResolvedValue([TEST_METRIC]);
const result = await store.fetchAll({ force: true });
const metrics = await store.fetchMetrics('1');
expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext);
expect(store.testDefinitionsById).toEqual({
'1': TEST_DEF_A,
'2': TEST_DEF_B,
expect(getTestMetrics).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
expect(store.metricsById).toEqual({
metric1: TEST_METRIC,
});
expect(metrics).toEqual([TEST_METRIC]);
});
expect(store.isLoading).toBe(false);
expect(result).toEqual({
count: 2,
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
test('Creating a Metric', async () => {
createTestMetric.mockResolvedValue(TEST_METRIC);
const params = {
name: 'Test Metric',
testDefinitionId: '1',
};
const result = await store.createMetric(params);
expect(createTestMetric).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
expect(store.metricsById).toEqual({
metric1: TEST_METRIC,
});
expect(result).toEqual(TEST_METRIC);
});
test('Updating a Metric', async () => {
const updatedMetric = { ...TEST_METRIC, name: 'Updated Metric' };
updateTestMetric.mockResolvedValue(updatedMetric);
const result = await store.updateMetric(updatedMetric);
expect(updateTestMetric).toHaveBeenCalledWith(rootStoreMock.restApiContext, updatedMetric);
expect(store.metricsById).toEqual({
metric1: updatedMetric,
});
expect(result).toEqual(updatedMetric);
});
test('Deleting a Metric', async () => {
store.metricsById = {
metric1: TEST_METRIC,
};
const params = { id: 'metric1', testDefinitionId: '1' };
deleteTestMetric.mockResolvedValue(undefined);
await store.deleteMetric(params);
expect(deleteTestMetric).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
expect(store.metricsById).toEqual({});
});
test('Getting Metrics by Test ID', () => {
const metric1 = { ...TEST_METRIC, id: 'metric1', testDefinitionId: '1' };
const metric2 = { ...TEST_METRIC, id: 'metric2', testDefinitionId: '1' };
const metric3 = { ...TEST_METRIC, id: 'metric3', testDefinitionId: '2' };
store.metricsById = {
metric1,
metric2,
metric3,
};
const metricsForTest1 = store.metricsByTestId['1'];
expect(metricsForTest1).toEqual([metric1, metric2]);
const metricsForTest2 = store.metricsByTestId['2'];
expect(metricsForTest2).toEqual([metric3]);
});
});
test('Fetching Test Definitions when already fetched', async () => {
store.fetchedAll = true;
describe('Computed Properties', () => {
test('hasTestDefinitions', () => {
store.testDefinitionsById = {};
const result = await store.fetchAll();
expect(store.hasTestDefinitions).toBe(false);
store.testDefinitionsById = {
'1': TEST_DEF_A,
};
expect(getTestDefinitions).not.toHaveBeenCalled();
expect(store.testDefinitionsById).toEqual({});
expect(result).toEqual({
count: 0,
testDefinitions: [],
expect(store.hasTestDefinitions).toBe(true);
});
test('isFeatureEnabled', () => {
posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(false);
expect(store.isFeatureEnabled).toBe(false);
posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(true);
expect(store.isFeatureEnabled).toBe(true);
});
test('allTestDefinitionsByWorkflowId', () => {
store.testDefinitionsById = {
'1': { ...TEST_DEF_A, workflowId: 'workflow1' },
'2': { ...TEST_DEF_B, workflowId: 'workflow1' },
'3': { ...TEST_DEF_NEW, workflowId: 'workflow2' },
};
expect(store.allTestDefinitionsByWorkflowId).toEqual({
workflow1: [
{ ...TEST_DEF_A, workflowId: 'workflow1' },
{ ...TEST_DEF_B, workflowId: 'workflow1' },
],
workflow2: [{ ...TEST_DEF_NEW, workflowId: 'workflow2' }],
});
});
test('lastRunByTestId', () => {
const olderRun = {
...TEST_RUN,
id: 'run2',
testDefinitionId: '1',
updatedAt: '2023-12-31',
};
const newerRun = {
...TEST_RUN,
id: 'run3',
testDefinitionId: '2',
updatedAt: '2024-01-02',
};
store.testRunsById = {
run1: { ...TEST_RUN, testDefinitionId: '1' },
run2: olderRun,
run3: newerRun,
};
expect(store.lastRunByTestId).toEqual({
'1': TEST_RUN,
'2': newerRun,
});
});
test('lastRunByTestId with no runs', () => {
store.testRunsById = {};
expect(store.lastRunByTestId).toEqual({});
});
});
test('Upserting Test Definitions - New Definition', () => {
const newDefinition = TEST_DEF_NEW;
describe('Error Handling', () => {
test('create', async () => {
createTestDefinition.mockRejectedValue(new Error('Create failed'));
store.upsertTestDefinitions([newDefinition]);
await expect(
store.create({ name: 'New Test Definition', workflowId: 'test-workflow-id' }),
).rejects.toThrow('Create failed');
});
expect(store.testDefinitionsById).toEqual({
'3': TEST_DEF_NEW,
test('update', async () => {
updateTestDefinition.mockRejectedValue(new Error('Update failed'));
await expect(store.update({ id: '1', name: 'Updated Test Definition A' })).rejects.toThrow(
'Update failed',
);
});
test('deleteById', async () => {
deleteTestDefinition.mockResolvedValue({ success: false });
const result = await store.deleteById('1');
expect(result).toBe(false);
});
});
test('Upserting Test Definitions - Existing Definition', () => {
store.testDefinitionsById = {
'1': TEST_DEF_A,
};
describe('Test Runs', () => {
test('Fetching Test Runs', async () => {
const result = await store.fetchTestRuns('1');
const updatedDefinition = {
id: '1',
name: 'Updated Test Definition A',
description: 'Updated Description A',
workflowId: '123',
};
expect(getTestRuns).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
expect(store.testRunsById).toEqual({
run1: TEST_RUN,
});
expect(result).toEqual([TEST_RUN]);
});
store.upsertTestDefinitions([updatedDefinition]);
test('Getting specific Test Run', async () => {
const params = { testDefinitionId: '1', runId: 'run1' };
const result = await store.getTestRun(params);
expect(store.testDefinitionsById).toEqual({
1: updatedDefinition,
expect(getTestRun).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
expect(store.testRunsById).toEqual({
run1: TEST_RUN,
});
expect(result).toEqual(TEST_RUN);
});
test('Starting Test Run', async () => {
const result = await store.startTestRun('1');
expect(startTestRun).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
expect(result).toEqual({ success: true });
});
test('Deleting Test Run', async () => {
store.testRunsById = { run1: TEST_RUN };
const params = { testDefinitionId: '1', runId: 'run1' };
const result = await store.deleteTestRun(params);
expect(deleteTestRun).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
expect(store.testRunsById).toEqual({});
expect(result).toEqual({ success: true });
});
test('Getting Test Runs by Test ID', () => {
store.testRunsById = {
run1: TEST_RUN,
run2: { ...TEST_RUN, id: 'run2', testDefinitionId: '2' },
};
const runs = store.testRunsByTestId['1'];
expect(runs).toEqual([TEST_RUN]);
});
});
test('Deleting Test Definitions', () => {
store.testDefinitionsById = {
'1': TEST_DEF_A,
'2': TEST_DEF_B,
};
store.deleteTestDefinition('1');
expect(store.testDefinitionsById).toEqual({
'2': TEST_DEF_B,
describe('Polling Mechanism', () => {
beforeEach(() => {
vi.useFakeTimers();
});
});
test('Creating a Test Definition', async () => {
const params = {
name: 'New Test Definition',
workflowId: 'test-workflow-id',
evaluationWorkflowId: 'test-evaluation-workflow-id',
description: 'New Description',
};
const result = await store.create(params);
expect(createTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, params);
expect(store.testDefinitionsById).toEqual({
'3': TEST_DEF_NEW,
afterEach(() => {
vi.useRealTimers();
});
expect(result).toEqual(TEST_DEF_NEW);
});
test('Updating a Test Definition', async () => {
store.testDefinitionsById = {
'1': TEST_DEF_A,
'2': TEST_DEF_B,
};
test('should start polling for running test runs', async () => {
const runningTestRun = {
...TEST_RUN,
status: 'running',
};
const params = {
id: '1',
name: 'Updated Test Definition A',
description: 'Updated Description A',
workflowId: '123',
};
updateTestDefinition.mockResolvedValue(params);
getTestRuns.mockResolvedValueOnce([runningTestRun]);
const result = await store.update(params);
// First call returns running status
getTestRun.mockResolvedValueOnce({
...runningTestRun,
status: 'running',
});
expect(updateTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1', {
name: 'Updated Test Definition A',
description: 'Updated Description A',
workflowId: '123',
// Second call returns completed status
getTestRun.mockResolvedValueOnce({
...runningTestRun,
status: 'completed',
});
await store.fetchTestRuns('1');
expect(store.testRunsById).toEqual({
run1: runningTestRun,
});
// Advance timer to trigger the first poll
await vi.advanceTimersByTimeAsync(1000);
// Verify first poll happened
expect(getTestRun).toHaveBeenCalledWith(rootStoreMock.restApiContext, {
testDefinitionId: '1',
runId: 'run1',
});
// Advance timer again
await vi.advanceTimersByTimeAsync(1000);
// Verify polling stopped after status changed to completed
expect(getTestRun).toHaveBeenCalledTimes(2);
});
expect(store.testDefinitionsById).toEqual({
'1': params,
'2': TEST_DEF_B,
test('should cleanup polling timeouts', async () => {
const runningTestRun = {
...TEST_RUN,
status: 'running',
};
getTestRuns.mockResolvedValueOnce([runningTestRun]);
getTestRun.mockResolvedValue({
...runningTestRun,
status: 'running',
});
await store.fetchTestRuns('1');
// Wait for the first poll to complete
await vi.runOnlyPendingTimersAsync();
// Clear mock calls from initial setup
getTestRun.mockClear();
store.cleanupPolling();
// Advance timer
await vi.advanceTimersByTimeAsync(1000);
// Verify no more polling happened after cleanup
expect(getTestRun).not.toHaveBeenCalled();
});
expect(result).toEqual(params);
});
test('Deleting a Test Definition by ID', async () => {
store.testDefinitionsById = {
'1': TEST_DEF_A,
};
const result = await store.deleteById('1');
expect(deleteTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
expect(store.testDefinitionsById).toEqual({});
expect(result).toBe(true);
});
test('Computed Properties - hasTestDefinitions', () => {
store.testDefinitionsById = {};
expect(store.hasTestDefinitions).toBe(false);
store.testDefinitionsById = {
'1': TEST_DEF_A,
};
expect(store.hasTestDefinitions).toBe(true);
});
test('Computed Properties - isFeatureEnabled', () => {
posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(false);
expect(store.isFeatureEnabled).toBe(false);
posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(true);
expect(store.isFeatureEnabled).toBe(true);
});
test('Error Handling - create', async () => {
createTestDefinition.mockRejectedValue(new Error('Create failed'));
await expect(
store.create({ name: 'New Test Definition', workflowId: 'test-workflow-id' }),
).rejects.toThrow('Create failed');
});
test('Error Handling - update', async () => {
updateTestDefinition.mockRejectedValue(new Error('Update failed'));
await expect(store.update({ id: '1', name: 'Updated Test Definition A' })).rejects.toThrow(
'Update failed',
);
});
test('Error Handling - deleteById', async () => {
deleteTestDefinition.mockResolvedValue({ success: false });
const result = await store.deleteById('1');
expect(result).toBe(false);
});
});

View file

@ -2,7 +2,7 @@ import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useRootStore } from './root.store';
import * as testDefinitionsApi from '@/api/testDefinition.ee';
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
import { usePostHog } from './posthog.store';
import { STORES, WORKFLOW_EVALUATION_EXPERIMENT } from '@/constants';
@ -13,6 +13,9 @@ export const useTestDefinitionStore = defineStore(
const testDefinitionsById = ref<Record<string, TestDefinitionRecord>>({});
const loading = ref(false);
const fetchedAll = ref(false);
const metricsById = ref<Record<string, testDefinitionsApi.TestMetricRecord>>({});
const testRunsById = ref<Record<string, TestRunRecord>>({});
const pollingTimeouts = ref<Record<string, NodeJS.Timeout>>({});
// Store instances
const posthogStore = usePostHog();
@ -25,6 +28,19 @@ export const useTestDefinitionStore = defineStore(
);
});
const allTestDefinitionsByWorkflowId = computed(() => {
return Object.values(testDefinitionsById.value).reduce(
(acc: Record<string, TestDefinitionRecord[]>, test) => {
if (!acc[test.workflowId]) {
acc[test.workflowId] = [];
}
acc[test.workflowId].push(test);
return acc;
},
{},
);
});
// Enable with `window.featureFlags.override('025_workflow_evaluation', true)`
const isFeatureEnabled = computed(() =>
posthogStore.isFeatureEnabled(WORKFLOW_EVALUATION_EXPERIMENT),
@ -34,6 +50,56 @@ export const useTestDefinitionStore = defineStore(
const hasTestDefinitions = computed(() => Object.keys(testDefinitionsById.value).length > 0);
const metricsByTestId = computed(() => {
return Object.values(metricsById.value).reduce(
(acc: Record<string, testDefinitionsApi.TestMetricRecord[]>, metric) => {
if (!acc[metric.testDefinitionId]) {
acc[metric.testDefinitionId] = [];
}
acc[metric.testDefinitionId].push(metric);
return acc;
},
{},
);
});
const testRunsByTestId = computed(() => {
return Object.values(testRunsById.value).reduce(
(acc: Record<string, TestRunRecord[]>, run) => {
if (!acc[run.testDefinitionId]) {
acc[run.testDefinitionId] = [];
}
acc[run.testDefinitionId].push(run);
return acc;
},
{},
);
});
const lastRunByTestId = computed(() => {
const grouped = Object.values(testRunsById.value).reduce(
(acc: Record<string, TestRunRecord[]>, run) => {
if (!acc[run.testDefinitionId]) {
acc[run.testDefinitionId] = [];
}
acc[run.testDefinitionId].push(run);
return acc;
},
{},
);
return Object.entries(grouped).reduce(
(acc: Record<string, TestRunRecord | null>, [testId, runs]) => {
acc[testId] =
runs.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)[0] || null;
return acc;
},
{},
);
});
// Methods
const setAllTestDefinitions = (definitions: TestDefinitionRecord[]) => {
testDefinitionsById.value = definitions.reduce(
@ -69,13 +135,32 @@ export const useTestDefinitionStore = defineStore(
testDefinitionsById.value = rest;
};
const fetchRunsForAllTests = async () => {
const testDefinitions = Object.values(testDefinitionsById.value);
try {
await Promise.all(testDefinitions.map(async (testDef) => await fetchTestRuns(testDef.id)));
} catch (error) {
console.error('Error fetching test runs:', error);
}
};
const fetchTestDefinition = async (id: string) => {
const testDefinition = await testDefinitionsApi.getTestDefinition(
rootStore.restApiContext,
id,
);
testDefinitionsById.value[testDefinition.id] = testDefinition;
return testDefinition;
};
/**
* Fetches all test definitions from the API.
* @param {boolean} force - If true, fetches the definitions from the API even if they were already fetched before.
*/
const fetchAll = async (params?: { force?: boolean }) => {
const { force = false } = params ?? {};
if (!force && fetchedAll.value) {
const fetchAll = async (params?: { force?: boolean; workflowId?: string }) => {
const { force = false, workflowId } = params ?? {};
if (!force && fetchedAll.value && !workflowId) {
const testDefinitions = Object.values(testDefinitionsById.value);
return {
count: testDefinitions.length,
@ -87,10 +172,13 @@ export const useTestDefinitionStore = defineStore(
try {
const retrievedDefinitions = await testDefinitionsApi.getTestDefinitions(
rootStore.restApiContext,
{ workflowId },
);
setAllTestDefinitions(retrievedDefinitions.testDefinitions);
fetchedAll.value = true;
await fetchRunsForAllTests();
return retrievedDefinitions;
} finally {
loading.value = false;
@ -147,24 +235,142 @@ export const useTestDefinitionStore = defineStore(
return result.success;
};
const fetchMetrics = async (testId: string) => {
loading.value = true;
try {
const metrics = await testDefinitionsApi.getTestMetrics(rootStore.restApiContext, testId);
metrics.forEach((metric) => {
metricsById.value[metric.id] = metric;
});
return metrics;
} finally {
loading.value = false;
}
};
const createMetric = async (params: {
name: string;
testDefinitionId: string;
}): Promise<testDefinitionsApi.TestMetricRecord> => {
const metric = await testDefinitionsApi.createTestMetric(rootStore.restApiContext, params);
metricsById.value[metric.id] = metric;
return metric;
};
const updateMetric = async (
params: testDefinitionsApi.TestMetricRecord,
): Promise<testDefinitionsApi.TestMetricRecord> => {
const metric = await testDefinitionsApi.updateTestMetric(rootStore.restApiContext, params);
metricsById.value[metric.id] = metric;
return metric;
};
const deleteMetric = async (
params: testDefinitionsApi.DeleteTestMetricParams,
): Promise<void> => {
await testDefinitionsApi.deleteTestMetric(rootStore.restApiContext, params);
const { [params.id]: deleted, ...rest } = metricsById.value;
metricsById.value = rest;
};
// Test Runs Methods
const fetchTestRuns = async (testDefinitionId: string) => {
loading.value = true;
try {
const runs = await testDefinitionsApi.getTestRuns(
rootStore.restApiContext,
testDefinitionId,
);
runs.forEach((run) => {
testRunsById.value[run.id] = run;
if (['running', 'new'].includes(run.status)) {
startPollingTestRun(testDefinitionId, run.id);
}
});
return runs;
} finally {
loading.value = false;
}
};
const getTestRun = async (params: { testDefinitionId: string; runId: string }) => {
const run = await testDefinitionsApi.getTestRun(rootStore.restApiContext, params);
testRunsById.value[run.id] = run;
return run;
};
const startTestRun = async (testDefinitionId: string) => {
const result = await testDefinitionsApi.startTestRun(
rootStore.restApiContext,
testDefinitionId,
);
return result;
};
const deleteTestRun = async (params: { testDefinitionId: string; runId: string }) => {
const result = await testDefinitionsApi.deleteTestRun(rootStore.restApiContext, params);
if (result.success) {
const { [params.runId]: deleted, ...rest } = testRunsById.value;
testRunsById.value = rest;
}
return result;
};
// TODO: This is a temporary solution to poll for test run status.
// We should use a more efficient polling mechanism in the future.
const startPollingTestRun = (testDefinitionId: string, runId: string) => {
const poll = async () => {
const run = await getTestRun({ testDefinitionId, runId });
if (['running', 'new'].includes(run.status)) {
pollingTimeouts.value[runId] = setTimeout(poll, 1000);
} else {
delete pollingTimeouts.value[runId];
}
};
void poll();
};
const cleanupPolling = () => {
Object.values(pollingTimeouts.value).forEach((timeout) => {
clearTimeout(timeout);
});
pollingTimeouts.value = {};
};
return {
// State
fetchedAll,
testDefinitionsById,
testRunsById,
// Computed
allTestDefinitions,
allTestDefinitionsByWorkflowId,
isLoading,
hasTestDefinitions,
isFeatureEnabled,
metricsById,
metricsByTestId,
testRunsByTestId,
lastRunByTestId,
// Methods
fetchTestDefinition,
fetchAll,
create,
update,
deleteById,
upsertTestDefinitions,
deleteTestDefinition,
fetchMetrics,
createMetric,
updateMetric,
deleteMetric,
fetchTestRuns,
getTestRun,
startTestRun,
deleteTestRun,
cleanupPolling,
};
},
{},

View file

@ -16,6 +16,7 @@ import {
LOG_STREAM_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
NODE_PINNING_MODAL_KEY,
STORES,
TAGS_MANAGER_MODAL_KEY,
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
@ -45,6 +46,7 @@ import type {
NotificationOptions,
ModalState,
ModalKey,
AppliedThemeOption,
} from '@/Interface';
import { defineStore } from 'pinia';
import { useRootStore } from '@/stores/root.store';
@ -98,6 +100,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
CREDENTIAL_SELECT_MODAL_KEY,
DUPLICATE_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
NODE_PINNING_MODAL_KEY,
INVITE_USER_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
@ -184,8 +187,15 @@ export const useUIStore = defineStore(STORES.UI, () => {
const rootStore = useRootStore();
const userStore = useUsersStore();
// Keep track of the preferred theme and update it when the system preference changes
const preferredTheme = getPreferredTheme();
const preferredSystemTheme = ref<AppliedThemeOption>(preferredTheme.theme);
preferredTheme.mediaQuery?.addEventListener('change', () => {
preferredSystemTheme.value = getPreferredTheme().theme;
});
const appliedTheme = computed(() => {
return theme.value === 'system' ? getPreferredTheme() : theme.value;
return theme.value === 'system' ? preferredSystemTheme.value : theme.value;
});
const contextBasedTranslationKeys = computed(() => {

View file

@ -31,9 +31,11 @@ export function updateTheme(theme: ThemeOption) {
}
}
export function getPreferredTheme(): AppliedThemeOption {
const isDarkMode =
!!window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)')?.matches;
export function getPreferredTheme(): { theme: AppliedThemeOption; mediaQuery: MediaQueryList } {
const isDarkModeQuery = !!window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
return isDarkMode ? 'dark' : 'light';
return {
theme: isDarkModeQuery?.matches ? 'dark' : 'light',
mediaQuery: isDarkModeQuery,
};
}

View file

@ -144,6 +144,7 @@ export interface CanvasInjectionData {
export type CanvasNodeEventBusEvents = {
'update:sticky:color': never;
'update:node:active': never;
'update:node:class': { className: string; add?: boolean };
};
export type CanvasEventBusEvents = {

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { computed, onMounted, watch, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import { NODE_PINNING_MODAL_KEY, VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n';
import { useAnnotationTagsStore } from '@/stores/tags.store';
@ -14,6 +14,12 @@ import EvaluationStep from '@/components/TestDefinition/EditDefinition/Evaluatio
import TagsInput from '@/components/TestDefinition/EditDefinition/TagsInput.vue';
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue';
import type { TestMetricRecord, TestRunRecord } from '@/api/testDefinition.ee';
import Modal from '@/components/Modal.vue';
import type { ModalState } from '@/Interface';
import { useUIStore } from '@/stores/ui.store';
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
const props = defineProps<{
testId?: string;
@ -24,31 +30,50 @@ const route = useRoute();
const locale = useI18n();
const { debounce } = useDebounce();
const toast = useToast();
const { isLoading, allTags, tagsById, fetchAll } = useAnnotationTagsStore();
const testId = computed(() => props.testId ?? (route.params.testId as string));
const currentWorkflowId = computed(() => route.params.name as string);
const buttonLabel = computed(() =>
testId.value
? locale.baseText('testDefinition.edit.updateTest')
: locale.baseText('testDefinition.edit.saveTest'),
);
const testDefinitionStore = useTestDefinitionStore();
const tagsStore = useAnnotationTagsStore();
const uiStore = useUIStore();
const {
state,
fieldsIssues,
isSaving,
cancelEditing,
loadTestData,
createTest,
updateTest,
startEditing,
saveChanges,
cancelEditing,
handleKeydown,
deleteMetric,
updateMetrics,
} = useTestDefinitionForm();
const isLoading = computed(() => tagsStore.isLoading);
const allTags = computed(() => tagsStore.allTags);
const tagsById = computed(() => tagsStore.tagsById);
const testId = computed(() => props.testId ?? (route.params.testId as string));
const currentWorkflowId = computed(() => route.params.name as string);
const tagUsageCount = computed(
() => tagsStore.tagsById[state.value.tags.value[0]]?.usageCount ?? 0,
);
const nodePinningModal = ref<ModalState | null>(null);
const modalContentWidth = ref(0);
onMounted(async () => {
await fetchAll();
if (!testDefinitionStore.isFeatureEnabled) {
toast.showMessage({
title: locale.baseText('testDefinition.notImplemented'),
type: 'warning',
});
void router.push({
name: VIEWS.WORKFLOW,
params: { name: router.currentRoute.value.params.name },
});
return; // Add early return to prevent loading if feature is disabled
}
void tagsStore.fetchAll({ withUsageCount: true });
if (testId.value) {
await loadTestData(testId.value);
} else {
@ -64,16 +89,12 @@ async function onSaveTest() {
} else {
savedTest = await createTest(currentWorkflowId.value);
}
if (savedTest && route.name === VIEWS.TEST_DEFINITION_EDIT) {
if (savedTest && route.name === VIEWS.NEW_TEST_DEFINITION) {
await router.replace({
name: VIEWS.TEST_DEFINITION_EDIT,
params: { testId: savedTest.id },
});
}
toast.showMessage({
title: locale.baseText('testDefinition.edit.testSaved'),
type: 'success',
});
} catch (e: unknown) {
toast.showError(e, locale.baseText('testDefinition.edit.testSaveFailed'));
}
@ -83,12 +104,69 @@ function hasIssues(key: string) {
return fieldsIssues.value.some((issue) => issue.field === key);
}
watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: true });
async function onDeleteMetric(deletedMetric: Partial<TestMetricRecord>) {
if (deletedMetric.id) {
await deleteMetric(deletedMetric.id, testId.value);
}
}
async function handleCreateTag(tagName: string) {
try {
const newTag = await tagsStore.create(tagName);
return newTag;
} catch (error) {
toast.showError(error, 'Error', error.message);
throw error;
}
}
async function openPinningModal() {
uiStore.openModal(NODE_PINNING_MODAL_KEY);
}
async function runTest() {
await testDefinitionStore.startTestRun(testId.value);
await testDefinitionStore.fetchTestRuns(testId.value);
}
const runs = computed(() =>
Object.values(testDefinitionStore.testRunsById ?? {}).filter(
(run) => run.testDefinitionId === testId.value,
),
);
async function onDeleteRuns(runs: TestRunRecord[]) {
await Promise.all(
runs.map(async (run) => {
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id });
}),
);
}
// Debounced watchers for auto-saving
watch(
() => state.value.metrics,
debounce(async () => await updateMetrics(testId.value), { debounceTime: 400 }),
{ deep: true },
);
watch(
() => [
state.value.description,
state.value.name,
state.value.tags,
state.value.evaluationWorkflow,
state.value.mockedNodes,
],
debounce(onSaveTest, { debounceTime: 400 }),
{ deep: true },
);
</script>
<template>
<div :class="$style.container">
<div :class="$style.content">
<div :class="$style.formContent">
<!-- Name -->
<EvaluationHeader
v-model="state.name"
:class="{ 'has-issues': hasIssues('name') }"
@ -96,24 +174,33 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
/>
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.description')"
:expanded="false"
>
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
<template #cardContent>
<DescriptionInput v-model="state.description" />
</template>
</EvaluationStep>
<div :class="$style.panelIntro">{{ locale.baseText('testDefinition.edit.step.intro') }}</div>
<BlockArrow :class="$style.introArrow" />
<div :class="$style.panelBlock">
<!-- Description -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.executions')"
:title="locale.baseText('testDefinition.edit.description')"
:expanded="false"
:tooltip="locale.baseText('testDefinition.edit.description.tooltip')"
>
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
<template #cardContent>
<DescriptionInput v-model="state.description" />
</template>
</EvaluationStep>
<div :class="$style.panelIntro">
{{ locale.baseText('testDefinition.edit.step.intro') }}
</div>
<BlockArrow :class="$style.introArrow" />
<!-- Select Executions -->
<EvaluationStep
:class="$style.step"
:title="
locale.baseText('testDefinition.edit.step.executions', {
adjustToNumber: tagUsageCount,
})
"
:tooltip="locale.baseText('testDefinition.edit.step.executions.tooltip')"
>
<template #icon><font-awesome-icon icon="history" size="lg" /></template>
<template #cardContent>
@ -126,6 +213,7 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
:start-editing="startEditing"
:save-changes="saveChanges"
:cancel-editing="cancelEditing"
:create-tag="handleCreateTag"
/>
</template>
</EvaluationStep>
@ -133,29 +221,46 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
<BlockArrow />
<BlockArrow />
</div>
<!-- Mocked Nodes -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.nodes')"
:title="
locale.baseText('testDefinition.edit.step.mockedNodes', {
adjustToNumber: state.mockedNodes?.length ?? 0,
})
"
:small="true"
:expanded="false"
:expanded="true"
:tooltip="locale.baseText('testDefinition.edit.step.nodes.tooltip')"
>
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
<template #cardContent>{{
locale.baseText('testDefinition.edit.step.mockedNodes', { adjustToNumber: 0 })
}}</template>
<template #cardContent>
<n8n-button
size="small"
data-test-id="select-nodes-button"
:label="locale.baseText('testDefinition.edit.selectNodes')"
type="tertiary"
@click="openPinningModal"
/>
</template>
</EvaluationStep>
<!-- Re-run Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
:small="true"
:tooltip="locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip')"
>
<template #icon><font-awesome-icon icon="redo" size="lg" /></template>
</EvaluationStep>
<!-- Compare Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.compareExecutions')"
:tooltip="locale.baseText('testDefinition.edit.step.compareExecutions.tooltip')"
>
<template #icon><font-awesome-icon icon="equals" size="lg" /></template>
<template #cardContent>
@ -166,27 +271,69 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
</template>
</EvaluationStep>
<!-- Metrics -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.metrics')"
:tooltip="locale.baseText('testDefinition.edit.step.metrics.tooltip')"
>
<template #icon><font-awesome-icon icon="chart-bar" size="lg" /></template>
<template #cardContent>
<MetricsInput v-model="state.metrics" :class="{ 'has-issues': hasIssues('metrics') }" />
<MetricsInput
v-model="state.metrics"
:class="{ 'has-issues': hasIssues('metrics') }"
@delete-metric="onDeleteMetric"
/>
</template>
</EvaluationStep>
</div>
<div :class="$style.footer">
<n8n-button
type="primary"
data-test-id="run-test-button"
:label="buttonLabel"
:loading="isSaving"
@click="onSaveTest"
/>
</div>
<n8n-button
v-if="state.evaluationWorkflow.value && state.tags.value.length > 0"
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('testDefinition.runTest')"
type="primary"
@click="runTest"
/>
<n8n-button
v-else
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="'Save Test'"
type="primary"
@click="onSaveTest"
/>
</div>
<!-- Past Runs Table -->
<div v-if="runs.length > 0" :class="$style.runsTable">
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
locale.baseText('testDefinition.edit.pastRuns')
}}</N8nHeading>
<TestRunsTable
:runs="runs"
:selectable="true"
data-test-id="past-runs-table"
@delete-runs="onDeleteRuns"
/>
</div>
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
<template #header>
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
locale.baseText('testDefinition.edit.selectNodes')
}}</N8nHeading>
</template>
<template #content>
<NodesPinning
v-model="state.mockedNodes"
:width="modalContentWidth"
data-test-id="nodes-pinning-modal"
/>
</template>
</Modal>
</div>
</template>
@ -194,22 +341,40 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
.container {
width: 100%;
height: 100%;
overflow: hidden;
padding: var(--spacing-s);
display: grid;
grid-template-columns: minmax(auto, 24rem) 1fr;
gap: var(--spacing-2xl);
}
.content {
min-width: 0;
.formContent {
width: 100%;
min-width: fit-content;
padding-bottom: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.runsTableTotal {
display: block;
margin-bottom: var(--spacing-xs);
}
.runsTable {
flex-shrink: 1;
max-width: 100%;
max-height: 80vh;
overflow: auto;
}
.runsTableHeading {
display: block;
margin-bottom: var(--spacing-xl);
}
.panelBlock {
max-width: var(--evaluation-edit-panel-width, 24rem);
display: grid;
justify-items: end;
overflow-y: auto;
min-height: 0;
}
.panelIntro {
font-size: var(--font-size-m);
@ -231,7 +396,7 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
justify-self: center;
}
.evaluationArrows {
--arrow-height: 11rem;
--arrow-height: 13.8rem;
display: flex;
justify-content: space-between;
width: 100%;
@ -267,4 +432,11 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
justify-items: end;
align-items: start;
}
.mockedNodesLabel {
min-height: 1.5rem;
display: block;
}
.runTestButton {
margin-top: var(--spacing-m);
}
</style>

View file

@ -19,17 +19,22 @@ const toast = useToast();
const locale = useI18n();
const tests = computed<TestListItem[]>(() => {
return testDefinitionStore.allTestDefinitions
return (
testDefinitionStore.allTestDefinitionsByWorkflowId[
router.currentRoute.value.params.name as string
] ?? []
)
.filter((test): test is TestDefinitionRecord => test.id !== undefined)
.sort((a, b) => new Date(b?.updatedAt ?? '').getTime() - new Date(a?.updatedAt ?? '').getTime())
.map((test) => ({
id: test.id,
name: test.name ?? '',
tagName: test.annotationTagId ? getTagName(test.annotationTagId) : '',
testCases: 0, // TODO: This should come from the API
testCases: testDefinitionStore.testRunsByTestId[test.id]?.length ?? 0,
execution: getTestExecution(test.id),
}));
});
const hasTests = computed(() => tests.value.length > 0);
const allTags = computed(() => tagsStore.allTags);
@ -39,21 +44,25 @@ function getTagName(tagId: string) {
return matchingTag?.name ?? '';
}
// TODO: Replace with actual API call once implemented
function getTestExecution(_testId: string): TestExecution {
function getTestExecution(testId: string): TestExecution {
const lastRun = testDefinitionStore.lastRunByTestId[testId];
if (!lastRun) {
return {
lastRun: null,
errorRate: 0,
metrics: {},
status: 'new',
};
}
const mockExecutions = {
lastRun: 'an hour ago',
lastRun: lastRun.updatedAt ?? '',
errorRate: 0,
metrics: { metric1: 0.12, metric2: 0.99, metric3: 0.87 },
metrics: lastRun.metrics ?? {},
status: lastRun.status ?? 'running',
};
return (
mockExecutions || {
lastRun: null,
errorRate: null,
metrics: { metric1: null, metric2: null, metric3: null },
}
);
return mockExecutions;
}
// Action handlers
@ -61,20 +70,27 @@ function onCreateTest() {
void router.push({ name: VIEWS.NEW_TEST_DEFINITION });
}
function onRunTest(_testId: string) {
// TODO: Implement test run logic
toast.showMessage({
title: locale.baseText('testDefinition.notImplemented'),
type: 'warning',
});
async function onRunTest(testId: string) {
try {
const result = await testDefinitionStore.startTestRun(testId);
if (result.success) {
toast.showMessage({
title: locale.baseText('testDefinition.list.testStarted'),
type: 'success',
});
// Optionally fetch the updated test runs
await testDefinitionStore.fetchTestRuns(testId);
} else {
throw new Error('Test run failed to start');
}
} catch (error) {
toast.showError(error, locale.baseText('testDefinition.list.testStartError'));
}
}
function onViewDetails(_testId: string) {
// TODO: Implement test details view
toast.showMessage({
title: locale.baseText('testDefinition.notImplemented'),
type: 'warning',
});
async function onViewDetails(testId: string) {
void router.push({ name: VIEWS.TEST_DEFINITION_RUNS, params: { testId } });
}
function onEditTest(testId: number) {
@ -92,12 +108,22 @@ async function onDeleteTest(testId: string) {
// Load initial data
async function loadInitialData() {
isLoading.value = true;
try {
await tagsStore.fetchAll();
await testDefinitionStore.fetchAll();
} finally {
isLoading.value = false;
if (!isLoading.value) {
// Add guard to prevent multiple loading states
isLoading.value = true;
try {
await Promise.all([
tagsStore.fetchAll(),
testDefinitionStore.fetchAll({
workflowId: router.currentRoute.value.params.name as string,
}),
]);
isLoading.value = false;
} catch (error) {
toast.showError(error, locale.baseText('testDefinition.list.loadError'));
} finally {
isLoading.value = false;
}
}
}
@ -112,7 +138,9 @@ onMounted(() => {
name: VIEWS.WORKFLOW,
params: { name: router.currentRoute.value.params.name },
});
return; // Add early return to prevent loading if feature is disabled
}
void loadInitialData();
});
</script>
@ -124,7 +152,11 @@ onMounted(() => {
</div>
<template v-else>
<EmptyState v-if="!hasTests" @create-test="onCreateTest" />
<EmptyState
v-if="!hasTests"
data-test-id="test-definition-empty-state"
@create-test="onCreateTest"
/>
<TestsList
v-else
:tests="tests"

View file

@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useRouter } from 'vue-router';
import { convertToDisplayDate } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
import { N8nCard, N8nText } from 'n8n-design-system';
import TestDefinitionTable from '@/components/TestDefinition/shared/TestDefinitionTable.vue';
import type { TestDefinitionTableColumn } from '@/components/TestDefinition/shared/TestDefinitionTable.vue';
import { useExecutionsStore } from '@/stores/executions.store';
import { get } from 'lodash-es';
import type { ExecutionSummaryWithScopes } from '@/Interface';
import { VIEWS } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useToast } from '@/composables/useToast';
interface TestCase extends ExecutionSummaryWithScopes {
metrics: Record<string, number>;
}
const router = useRouter();
const toast = useToast();
const testDefinitionStore = useTestDefinitionStore();
const executionsStore = useExecutionsStore();
const workflowStore = useWorkflowsStore();
const locale = useI18n();
const isLoading = ref(true);
const testCases = ref<TestCase[]>([]);
const runId = computed(() => router.currentRoute.value.params.runId as string);
const testId = computed(() => router.currentRoute.value.params.testId as string);
const run = computed(() => testDefinitionStore.testRunsById[runId.value]);
const test = computed(() => testDefinitionStore.testDefinitionsById[testId.value]);
const workflow = computed(
() => workflowStore.workflowsById[test.value?.evaluationWorkflowId ?? ''],
);
const filteredTestCases = computed(() => {
return testCases.value;
});
const columns = computed(
(): Array<TestDefinitionTableColumn<TestCase>> => [
{
prop: 'id',
width: 200,
label: locale.baseText('testDefinition.runDetail.testCase'),
sortable: true,
route: (row: TestCase) => ({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: row.workflowId, executionId: row.id },
}),
formatter: (row: TestCase) => `[${row.id}] ${workflow.value?.name}`,
openInNewTab: true,
},
{
prop: 'status',
label: locale.baseText('testDefinition.listRuns.status'),
filters: [
{ text: locale.baseText('testDefinition.listRuns.status.new'), value: 'new' },
{ text: locale.baseText('testDefinition.listRuns.status.running'), value: 'running' },
{ text: locale.baseText('testDefinition.listRuns.status.success'), value: 'success' },
{ text: locale.baseText('testDefinition.listRuns.status.error'), value: 'error' },
],
filterMethod: (value: string, row: TestCase) => row.status === value,
},
...Object.keys(run.value?.metrics ?? {}).map((metric) => ({
prop: `metrics.${metric}`,
label: metric,
sortable: true,
filter: true,
formatter: (row: TestCase) => row.metrics[metric]?.toFixed(2) ?? '-',
})),
],
);
const metrics = computed(() => run.value?.metrics ?? {});
// Temporary workaround to fetch test cases by manually getting workflow executions
// TODO: Replace with dedicated API endpoint once available
const fetchExecutionTestCases = async () => {
if (!runId.value || !testId.value) return;
isLoading.value = true;
try {
const testRun = await testDefinitionStore.getTestRun({
testDefinitionId: testId.value,
runId: runId.value,
});
const testDefinition = await testDefinitionStore.fetchTestDefinition(testId.value);
// Fetch workflow executions that match this test run
const evaluationWorkflowExecutions = await executionsStore.fetchExecutions({
workflowId: testDefinition.evaluationWorkflowId ?? '',
metadata: [{ key: 'testRunId', value: testRun.id }],
});
// For each execution, fetch full details and extract metrics
const executionsData = await Promise.all(
evaluationWorkflowExecutions?.results.map(async (execution) => {
const executionData = await executionsStore.fetchExecution(execution.id);
const lastExecutedNode = executionData?.data?.resultData?.lastNodeExecuted;
if (!lastExecutedNode) {
throw new Error('Last executed node is required');
}
const metricsData = get(
executionData,
[
'data',
'resultData',
'runData',
lastExecutedNode,
'0',
'data',
'main',
'0',
'0',
'json',
],
{},
);
return {
...execution,
metrics: metricsData,
};
}),
);
testCases.value = executionsData ?? [];
} catch (error) {
toast.showError(error, 'Failed to load run details');
} finally {
isLoading.value = false;
}
};
onMounted(async () => {
await fetchExecutionTestCases();
});
</script>
<template>
<div :class="$style.container" data-test-id="test-definition-run-detail">
<div :class="$style.header">
<button :class="$style.backButton" @click="router.back()">
<i class="mr-xs"><font-awesome-icon icon="arrow-left" /></i>
<n8n-heading size="large" :bold="true">{{ test?.name }}</n8n-heading>
<i class="ml-xs mr-xs"><font-awesome-icon icon="chevron-right" /></i>
<n8n-heading size="large" :bold="true"
>{{ locale.baseText('testDefinition.listRuns.runNumber') }}{{ run?.id }}</n8n-heading
>
</button>
</div>
<div :class="$style.cardGrid">
<N8nCard :class="$style.summaryCard">
<div :class="$style.stat">
<N8nText size="small">
{{ locale.baseText('testDefinition.runDetail.totalCases') }}
</N8nText>
<N8nText size="large">{{ testCases.length }}</N8nText>
</div>
</N8nCard>
<N8nCard :class="$style.summaryCard">
<div :class="$style.stat">
<N8nText size="small">
{{ locale.baseText('testDefinition.runDetail.ranAt') }}
</N8nText>
<N8nText size="medium">{{
convertToDisplayDate(new Date(run?.runAt).getTime())
}}</N8nText>
</div>
</N8nCard>
<N8nCard :class="$style.summaryCard">
<div :class="$style.stat">
<N8nText size="small">
{{ locale.baseText('testDefinition.listRuns.status') }}
</N8nText>
<N8nText size="large" :class="run?.status.toLowerCase()">
{{ run?.status }}
</N8nText>
</div>
</N8nCard>
<N8nCard v-for="(value, key) in metrics" :key="key" :class="$style.summaryCard">
<div :class="$style.stat">
<N8nText size="small">{{ key }}</N8nText>
<N8nText size="large">{{ value.toFixed(2) }}</N8nText>
</div>
</N8nCard>
</div>
<N8nCard>
<div v-if="isLoading" :class="$style.loading">
<n8n-loading :loading="true" :rows="5" />
</div>
<TestDefinitionTable
v-else
:data="filteredTestCases"
:columns="columns"
:default-sort="{ prop: 'id', order: 'descending' }"
/>
</N8nCard>
</div>
</template>
<style module lang="scss">
.container {
padding: var(--spacing-xl) var(--spacing-l);
height: 100%;
width: 100%;
max-width: var(--content-container-width);
}
.backButton {
display: flex;
align-items: center;
gap: var(--spacing-s);
border: none;
background: none;
cursor: pointer;
color: var(--color-text-base);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-s);
margin-bottom: var(--spacing-l);
.timestamp {
color: var(--color-text-base);
font-size: var(--font-size-s);
}
}
.summary {
margin-bottom: var(--spacing-m);
.summaryStats {
display: flex;
gap: var(--spacing-l);
}
}
.stat {
display: flex;
flex-direction: column;
}
.controls {
display: flex;
gap: var(--spacing-s);
margin-bottom: var(--spacing-s);
}
.downloadButton {
margin-bottom: var(--spacing-s);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.cardGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
gap: var(--spacing-xs);
margin-bottom: var(--spacing-m);
}
:global {
.new {
color: var(--color-info);
}
.running {
color: var(--color-warning);
}
.completed {
color: var(--color-success);
}
.error {
color: var(--color-danger);
}
}
</style>

View file

@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
import { VIEWS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
const router = useRouter();
const testDefinitionStore = useTestDefinitionStore();
const uiStore = useUIStore();
const locale = useI18n();
const toast = useToast();
const selectedMetric = ref();
const isLoading = ref(false);
const appliedTheme = computed(() => uiStore.appliedTheme);
const testId = computed(() => {
return router.currentRoute.value.params.testId as string;
});
async function loadInitialData() {
if (!isLoading.value) {
// Add guard to prevent multiple loading states
isLoading.value = true;
try {
await testDefinitionStore.fetchTestDefinition(testId.value);
await testDefinitionStore.fetchTestRuns(testId.value);
isLoading.value = false;
} catch (error) {
} finally {
isLoading.value = false;
}
}
}
// TODO: We're currently doing the filtering on the FE but there should be an endpoint to get the runs for a test
const runs = computed(() => {
return Object.values(testDefinitionStore.testRunsById ?? {}).filter(
(run) => run.testDefinitionId === testId.value,
);
});
const testDefinition = computed(() => {
return testDefinitionStore.testDefinitionsById[testId.value];
});
const getRunDetail = (run: TestRunRecord) => {
void router.push({
name: VIEWS.TEST_DEFINITION_RUNS_DETAIL,
params: { testId: testId.value, runId: run.id },
});
};
async function runTest() {
try {
const result = await testDefinitionStore.startTestRun(testId.value);
if (result.success) {
toast.showMessage({
title: locale.baseText('testDefinition.list.testStarted'),
type: 'success',
});
// Optionally fetch the updated test runs
await testDefinitionStore.fetchTestRuns(testId.value);
} else {
throw new Error('Test run failed to start');
}
} catch (error) {
toast.showError(error, locale.baseText('testDefinition.list.testStartError'));
}
}
async function onDeleteRuns(runsToDelete: TestRunRecord[]) {
await Promise.all(
runsToDelete.map(async (run) => {
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id });
}),
);
}
onMounted(async () => {
await loadInitialData();
});
</script>
<template>
<div :class="$style.container">
<router-link :to="{ name: VIEWS.TEST_DEFINITION }" :class="$style.backButton">
<i class="mr-xs"><font-awesome-icon icon="arrow-left" /></i>
<n8n-heading size="large" :bold="true">{{ testDefinition?.name }}</n8n-heading>
</router-link>
<N8nText :class="$style.description" size="medium">{{ testDefinition?.description }}</N8nText>
<template v-if="isLoading">
<N8nLoading :rows="5" />
<N8nLoading :rows="10" />
</template>
<template v-else-if="runs.length > 0">
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
<TestRunsTable :runs="runs" @get-run-detail="getRunDetail" @delete-runs="onDeleteRuns" />
</template>
<template v-else>
<N8nActionBox
:heading="locale.baseText('testDefinition.listRuns.noRuns')"
:description="locale.baseText('testDefinition.listRuns.noRuns.description')"
:button-text="locale.baseText('testDefinition.listRuns.noRuns.button')"
@click:button="runTest"
/>
</template>
</div>
</template>
<style module lang="scss">
.container {
padding: var(--spacing-xl) var(--spacing-l);
height: 100%;
width: 100%;
max-width: var(--content-container-width);
}
.backButton {
color: var(--color-text-base);
}
.description {
margin-top: var(--spacing-xs);
display: block;
}
</style>

View file

@ -1,3 +1,4 @@
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
@ -8,57 +9,89 @@ import { useToast } from '@/composables/useToast';
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { ref, nextTick } from 'vue';
import { cleanupAppModals, createAppModals, mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import type { TestRunRecord } from '@/api/testDefinition.ee';
vi.mock('vue-router');
vi.mock('@/composables/useToast');
vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm');
vi.mock('@/stores/tags.store');
vi.mock('@/stores/projects.store');
describe('TestDefinitionEditView', () => {
const renderComponent = createComponentRenderer(TestDefinitionEditView);
let createTestMock: Mock;
let updateTestMock: Mock;
let loadTestDataMock: Mock;
let deleteMetricMock: Mock;
let updateMetricsMock: Mock;
let showMessageMock: Mock;
let showErrorMock: Mock;
const renderComponentWithFeatureEnabled = ({
testRunsById = {},
}: { testRunsById?: Record<string, TestRunRecord> } = {}) => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const mockedTestDefinitionStore = mockedStore(useTestDefinitionStore);
mockedTestDefinitionStore.isFeatureEnabled = true;
mockedTestDefinitionStore.testRunsById = testRunsById;
return { ...renderComponent({ pinia }), mockedTestDefinitionStore };
};
beforeEach(() => {
setActivePinia(createPinia());
createAppModals();
// Default route mock: no testId
vi.mocked(useRoute).mockReturnValue({
params: {},
path: '/test-path',
name: 'test-route',
name: VIEWS.NEW_TEST_DEFINITION,
} as ReturnType<typeof useRoute>);
vi.mocked(useRouter).mockReturnValue({
push: vi.fn(),
replace: vi.fn(),
resolve: vi.fn().mockReturnValue({ href: '/test-href' }),
currentRoute: { value: { params: {} } },
} as unknown as ReturnType<typeof useRouter>);
createTestMock = vi.fn().mockResolvedValue({ id: 'newTestId' });
updateTestMock = vi.fn().mockResolvedValue({});
loadTestDataMock = vi.fn();
deleteMetricMock = vi.fn();
updateMetricsMock = vi.fn();
showMessageMock = vi.fn();
showErrorMock = vi.fn();
// const mockedTestDefinitionStore = mockedStore(useTestDefinitionStore);
vi.mocked(useToast).mockReturnValue({
showMessage: vi.fn(),
showError: vi.fn(),
showMessage: showMessageMock,
showError: showErrorMock,
} as unknown as ReturnType<typeof useToast>);
vi.mocked(useTestDefinitionForm).mockReturnValue({
state: ref({
name: { value: '', isEditing: false, tempValue: '' },
description: '',
tags: { appliedTagIds: [], isEditing: false },
evaluationWorkflow: { id: '1', name: 'Test Workflow' },
tags: { value: [], tempValue: [], isEditing: false },
evaluationWorkflow: { mode: 'list', value: '', __rl: true },
metrics: [],
}),
fieldsIssues: ref([]),
isSaving: ref(false),
loadTestData: vi.fn(),
saveTest: vi.fn(),
loadTestData: loadTestDataMock,
createTest: createTestMock,
updateTest: updateTestMock,
startEditing: vi.fn(),
saveChanges: vi.fn(),
cancelEditing: vi.fn(),
handleKeydown: vi.fn(),
deleteMetric: deleteMetricMock,
updateMetrics: updateMetricsMock,
} as unknown as ReturnType<typeof useTestDefinitionForm>);
vi.mocked(useAnnotationTagsStore).mockReturnValue({
isLoading: ref(false),
allTags: ref([]),
tagsById: ref({}),
fetchAll: vi.fn(),
} as unknown as ReturnType<typeof useAnnotationTagsStore>);
vi.mock('@/stores/projects.store', () => ({
useProjectsStore: vi.fn().mockReturnValue({
@ -71,138 +104,197 @@ describe('TestDefinitionEditView', () => {
afterEach(() => {
vi.clearAllMocks();
cleanupAppModals();
});
it('should load test data when testId is provided', async () => {
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
path: '/test-path',
name: 'test-route',
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const loadTestDataMock = vi.fn();
vi.mocked(useTestDefinitionForm).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(),
loadTestData: loadTestDataMock,
} as unknown as ReturnType<typeof useTestDefinitionForm>);
renderComponentWithFeatureEnabled();
renderComponent({
pinia: createTestingPinia(),
});
mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
await nextTick();
expect(loadTestDataMock).toHaveBeenCalledWith('1');
});
it('should not load test data when testId is not provided', async () => {
const loadTestDataMock = vi.fn();
vi.mocked(useTestDefinitionForm).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(),
loadTestData: loadTestDataMock,
} as unknown as ReturnType<typeof useTestDefinitionForm>);
// Here route returns no testId
vi.mocked(useRoute).mockReturnValue({
params: {},
name: VIEWS.NEW_TEST_DEFINITION,
} as unknown as ReturnType<typeof useRoute>);
renderComponentWithFeatureEnabled();
renderComponent({
pinia: createTestingPinia(),
});
await nextTick();
expect(loadTestDataMock).not.toHaveBeenCalled();
});
it('should save test and show success message on successful save', async () => {
const saveTestMock = vi.fn().mockResolvedValue({});
const routerPushMock = vi.fn();
const routerResolveMock = vi.fn().mockReturnValue({ href: '/test-href' });
vi.mocked(useTestDefinitionForm).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(),
createTest: saveTestMock,
} as unknown as ReturnType<typeof useTestDefinitionForm>);
it('should create a new test and show success message on save if no testId is present', async () => {
vi.mocked(useRoute).mockReturnValue({
params: {},
name: VIEWS.NEW_TEST_DEFINITION,
} as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled();
vi.mocked(useRouter).mockReturnValue({
push: routerPushMock,
resolve: routerResolveMock,
} as unknown as ReturnType<typeof useRouter>);
mockedStore(useAnnotationTagsStore).fetchAll.mockResolvedValue([]);
const { getByTestId } = renderComponent({
pinia: createTestingPinia(),
});
await nextTick();
const saveButton = getByTestId('run-test-button');
saveButton.click();
await nextTick();
expect(saveTestMock).toHaveBeenCalled();
expect(createTestMock).toHaveBeenCalled();
});
it('should show error message on failed save', async () => {
const saveTestMock = vi.fn().mockRejectedValue(new Error('Save failed'));
const showErrorMock = vi.fn();
vi.mocked(useTestDefinitionForm).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(),
createTest: saveTestMock,
} as unknown as ReturnType<typeof useTestDefinitionForm>);
vi.mocked(useToast).mockReturnValue({ showError: showErrorMock } as unknown as ReturnType<
typeof useToast
>);
const { getByTestId } = renderComponent({
pinia: createTestingPinia(),
});
await nextTick();
const saveButton = getByTestId('run-test-button');
saveButton.click();
await nextTick();
expect(saveTestMock).toHaveBeenCalled();
expect(showErrorMock).toHaveBeenCalled();
});
it('should display "Update Test" button when editing existing test', async () => {
it('should update test and show success message on save if testId is present', async () => {
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
path: '/test-path',
name: 'test-route',
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponent({
pinia: createTestingPinia(),
});
const { getByTestId } = renderComponentWithFeatureEnabled();
const saveButton = getByTestId('run-test-button');
saveButton.click();
await nextTick();
const updateButton = getByTestId('run-test-button');
expect(updateButton.textContent).toContain('Update test');
expect(updateTestMock).toHaveBeenCalledWith('1');
});
it('should display "Run Test" button when creating new test', async () => {
const { getByTestId } = renderComponent({
pinia: createTestingPinia(),
});
await nextTick();
it('should show error message on failed test creation', async () => {
createTestMock.mockRejectedValue(new Error('Save failed'));
vi.mocked(useRoute).mockReturnValue({
params: {},
name: VIEWS.NEW_TEST_DEFINITION,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled();
const saveButton = getByTestId('run-test-button');
expect(saveButton).toBeTruthy();
saveButton.click();
await nextTick();
expect(createTestMock).toHaveBeenCalled();
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
});
it('should display "Save Test" button when editing test without eval workflow and tags', async () => {
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled();
await nextTick();
const updateButton = getByTestId('run-test-button');
expect(updateButton.textContent?.toLowerCase()).toContain('save');
});
it('should display "Save Test" button when creating new test', async () => {
vi.mocked(useRoute).mockReturnValue({
params: {},
name: VIEWS.NEW_TEST_DEFINITION,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled();
const saveButton = getByTestId('run-test-button');
expect(saveButton.textContent?.toLowerCase()).toContain('save test');
});
it('should apply "has-issues" class to inputs with issues', async () => {
vi.mocked(useTestDefinitionForm).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(),
fieldsIssues: ref([{ field: 'name' }, { field: 'tags' }]),
fieldsIssues: ref([
{ field: 'name', message: 'Name is required' },
{ field: 'tags', message: 'Tag is required' },
]),
} as unknown as ReturnType<typeof useTestDefinitionForm>);
const { container } = renderComponent({
pinia: createTestingPinia(),
});
const { container } = renderComponentWithFeatureEnabled();
await nextTick();
expect(container.querySelector('.has-issues')).toBeTruthy();
const issueElements = container.querySelectorAll('.has-issues');
expect(issueElements.length).toBeGreaterThan(0);
});
it('should fetch all tags on mount', async () => {
const fetchAllMock = vi.fn();
vi.mocked(useAnnotationTagsStore).mockReturnValue({
...vi.mocked(useAnnotationTagsStore)(),
fetchAll: fetchAllMock,
} as unknown as ReturnType<typeof useAnnotationTagsStore>);
renderComponentWithFeatureEnabled();
await nextTick();
expect(mockedStore(useAnnotationTagsStore).fetchAll).toHaveBeenCalled();
});
renderComponent({
pinia: createTestingPinia(),
describe('Test Runs functionality', () => {
it('should display test runs table when runs exist', async () => {
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled({
testRunsById: {
run1: {
id: 'run1',
testDefinitionId: '1',
status: 'completed',
runAt: '2023-01-01',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
completedAt: '2023-01-01',
},
run2: {
id: 'run2',
testDefinitionId: '1',
status: 'running',
runAt: '2023-01-02',
createdAt: '2023-01-02',
updatedAt: '2023-01-02',
completedAt: '',
},
},
});
const runsTable = getByTestId('past-runs-table');
expect(runsTable).toBeTruthy();
});
await nextTick();
expect(fetchAllMock).toHaveBeenCalled();
it('should not display test runs table when no runs exist', async () => {
const { container } = renderComponentWithFeatureEnabled();
const runsTable = container.querySelector('[data-test-id="past-runs-table"]');
expect(runsTable).toBeFalsy();
});
it('should start a test run when run test button is clicked', async () => {
vi.mocked(useTestDefinitionForm).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(),
state: ref({
name: { value: 'Test', isEditing: false, tempValue: '' },
description: '',
tags: { value: ['tag1'], tempValue: [], isEditing: false },
evaluationWorkflow: { mode: 'list', value: 'workflow1', __rl: true },
metrics: [],
mockedNodes: [],
}),
} as unknown as ReturnType<typeof useTestDefinitionForm>);
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled();
await nextTick();
const runButton = getByTestId('run-test-button');
runButton.click();
await nextTick();
expect(mockedTestDefinitionStore.startTestRun).toHaveBeenCalledWith('1');
expect(mockedTestDefinitionStore.fetchTestRuns).toHaveBeenCalledWith('1');
});
});
});

View file

@ -0,0 +1,174 @@
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import TestDefinitionListView from '@/views/TestDefinition/TestDefinitionListView.vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from '@/composables/useToast';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { nextTick, ref } from 'vue';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
vi.mock('vue-router');
vi.mock('@/composables/useToast');
describe('TestDefinitionListView', () => {
const renderComponent = createComponentRenderer(TestDefinitionListView);
let showMessageMock: Mock;
let showErrorMock: Mock;
let startTestRunMock: Mock;
let fetchTestRunsMock: Mock;
let deleteByIdMock: Mock;
let fetchAllMock: Mock;
const mockTestDefinitions: TestDefinitionRecord[] = [
{
id: '1',
name: 'Test 1',
workflowId: 'workflow1',
updatedAt: '2023-01-01T00:00:00.000Z',
annotationTagId: 'tag1',
},
{
id: '2',
name: 'Test 2',
workflowId: 'workflow1',
updatedAt: '2023-01-02T00:00:00.000Z',
},
{
id: '3',
name: 'Test 3',
workflowId: 'workflow1',
updatedAt: '2023-01-03T00:00:00.000Z',
},
];
beforeEach(() => {
setActivePinia(createPinia());
vi.mocked(useRoute).mockReturnValue(
ref({
params: { name: 'workflow1' },
name: VIEWS.TEST_DEFINITION,
}) as unknown as ReturnType<typeof useRoute>,
);
vi.mocked(useRouter).mockReturnValue({
push: vi.fn(),
currentRoute: { value: { params: { name: 'workflow1' } } },
} as unknown as ReturnType<typeof useRouter>);
showMessageMock = vi.fn();
showErrorMock = vi.fn();
startTestRunMock = vi.fn().mockResolvedValue({ success: true });
fetchTestRunsMock = vi.fn();
deleteByIdMock = vi.fn();
fetchAllMock = vi.fn().mockResolvedValue({ testDefinitions: mockTestDefinitions });
vi.mocked(useToast).mockReturnValue({
showMessage: showMessageMock,
showError: showErrorMock,
} as unknown as ReturnType<typeof useToast>);
});
afterEach(() => {
vi.clearAllMocks();
});
const renderComponentWithFeatureEnabled = async (
{ testDefinitions }: { testDefinitions: TestDefinitionRecord[] } = {
testDefinitions: mockTestDefinitions,
},
) => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
// const tagsStore = mockedStore(useAnnotationTagsStore);
testDefinitionStore.isFeatureEnabled = true;
testDefinitionStore.fetchAll = fetchAllMock;
testDefinitionStore.startTestRun = startTestRunMock;
testDefinitionStore.fetchTestRuns = fetchTestRunsMock;
testDefinitionStore.deleteById = deleteByIdMock;
testDefinitionStore.allTestDefinitionsByWorkflowId = { workflow1: testDefinitions };
const component = renderComponent({ pinia });
await waitAllPromises();
return { ...component, testDefinitionStore };
};
it('should render empty state when no tests exist', async () => {
const { getByTestId } = await renderComponentWithFeatureEnabled({ testDefinitions: [] });
expect(getByTestId('test-definition-empty-state')).toBeTruthy();
});
it('should render tests list when tests exist', async () => {
const { getByTestId } = await renderComponentWithFeatureEnabled();
expect(getByTestId('test-definition-list')).toBeTruthy();
});
it('should load initial data on mount', async () => {
const { testDefinitionStore } = await renderComponentWithFeatureEnabled();
expect(testDefinitionStore.fetchAll).toHaveBeenCalledWith({
workflowId: 'workflow1',
});
expect(mockedStore(useAnnotationTagsStore).fetchAll).toHaveBeenCalled();
});
it('should start test run and show success message', async () => {
const { getByTestId } = await renderComponentWithFeatureEnabled();
const runButton = getByTestId('run-test-button-1');
runButton.click();
await nextTick();
expect(startTestRunMock).toHaveBeenCalledWith('1');
expect(fetchTestRunsMock).toHaveBeenCalledWith('1');
expect(showMessageMock).toHaveBeenCalledWith({
title: expect.any(String),
type: 'success',
});
});
it('should show error message on failed test run', async () => {
const { getByTestId, testDefinitionStore } = await renderComponentWithFeatureEnabled();
testDefinitionStore.startTestRun = vi.fn().mockRejectedValue(new Error('Run failed'));
const runButton = getByTestId('run-test-button-1');
runButton.click();
await nextTick();
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
});
it('should delete test and show success message', async () => {
const { getByTestId, testDefinitionStore } = await renderComponentWithFeatureEnabled();
const deleteButton = getByTestId('delete-test-button-1');
deleteButton.click();
await nextTick();
expect(testDefinitionStore.deleteById).toHaveBeenCalledWith('1');
expect(showMessageMock).toHaveBeenCalledWith({
title: expect.any(String),
type: 'success',
});
});
it('should sort tests by updated date in descending order', async () => {
const { container } = await renderComponentWithFeatureEnabled();
const testItems = container.querySelectorAll('[data-test-id^="test-item-"]');
expect(testItems[0].getAttribute('data-test-id')).toBe('test-item-3');
expect(testItems[1].getAttribute('data-test-id')).toBe('test-item-2');
expect(testItems[2].getAttribute('data-test-id')).toBe('test-item-1');
});
});

View file

@ -0,0 +1,267 @@
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import TestDefinitionRunDetailView from '@/views/TestDefinition/TestDefinitionRunDetailView.vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from '@/composables/useToast';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useExecutionsStore } from '@/stores/executions.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { nextTick, ref } from 'vue';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import type { IWorkflowDb } from '@/Interface';
vi.mock('vue-router');
vi.mock('@/composables/useToast');
describe('TestDefinitionRunDetailView', () => {
const renderComponent = createComponentRenderer(TestDefinitionRunDetailView);
let showErrorMock: Mock;
let getTestRunMock: Mock;
let fetchExecutionsMock: Mock;
let fetchExecutionMock: Mock;
const mockTestRun: TestRunRecord = {
id: 'run1',
status: 'completed',
runAt: '2023-01-01T00:00:00.000Z',
metrics: {
accuracy: 0.95,
precision: 0.88,
},
testDefinitionId: 'test1',
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
completedAt: '2023-01-01T00:00:00.000Z',
};
const mockTestDefinition = {
id: 'test1',
name: 'Test Definition 1',
evaluationWorkflowId: 'workflow1',
workflowId: 'workflow1',
};
const mockWorkflow = {
id: 'workflow1',
name: 'Evaluation Workflow',
};
const mockExecutions = {
results: [
{ id: 'exec1', status: 'success' },
{ id: 'exec2', status: 'error' },
],
};
beforeEach(() => {
setActivePinia(createPinia());
// Mock route with testId and runId
vi.mocked(useRoute).mockReturnValue(
ref({
params: { testId: 'test1', runId: 'run1' },
name: VIEWS.TEST_DEFINITION_RUNS,
}) as unknown as ReturnType<typeof useRoute>,
);
vi.mocked(useRouter).mockReturnValue({
back: vi.fn(),
currentRoute: { value: { params: { testId: 'test1', runId: 'run1' } } },
resolve: vi.fn().mockResolvedValue({ href: 'test-definition-run-detail' }),
} as unknown as ReturnType<typeof useRouter>);
showErrorMock = vi.fn();
getTestRunMock = vi.fn().mockResolvedValue(mockTestRun);
fetchExecutionsMock = vi.fn().mockResolvedValue(mockExecutions);
fetchExecutionMock = vi.fn().mockResolvedValue({
data: {
resultData: {
lastNodeExecuted: 'Node1',
runData: {
Node1: [{ data: { main: [[{ json: { accuracy: 0.95 } }]] } }],
},
},
},
});
vi.mocked(useToast).mockReturnValue({
showError: showErrorMock,
} as unknown as ReturnType<typeof useToast>);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should load run details on mount', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.testRunsById = { run1: mockTestRun };
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
testDefinitionStore.getTestRun = getTestRunMock;
const executionsStore = mockedStore(useExecutionsStore);
executionsStore.fetchExecutions = fetchExecutionsMock;
executionsStore.fetchExecution = fetchExecutionMock;
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.workflowsById = { workflow1: mockWorkflow as IWorkflowDb };
const { getByTestId } = renderComponent({ pinia });
await nextTick();
expect(getTestRunMock).toHaveBeenCalledWith({
testDefinitionId: 'test1',
runId: 'run1',
});
// expect(fetchExecutionsMock).toHaveBeenCalled();
expect(getByTestId('test-definition-run-detail')).toBeTruthy();
});
it('should display test run metrics', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.testRunsById = { run1: mockTestRun };
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
testDefinitionStore.getTestRun = getTestRunMock;
const { container } = renderComponent({ pinia });
await nextTick();
const metricsCards = container.querySelectorAll('.summaryCard');
expect(metricsCards.length).toBeGreaterThan(0);
expect(container.textContent).toContain('0.95'); // Check for accuracy metric
});
it('should handle errors when loading run details', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.getTestRun = vi.fn().mockRejectedValue(new Error('Failed to load'));
renderComponent({ pinia });
await nextTick();
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), 'Failed to load run details');
});
it('should navigate back when back button is clicked', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const router = useRouter();
const { getByTestId } = renderComponent({ pinia });
await nextTick();
const backButton = getByTestId('test-definition-run-detail').querySelector('.backButton');
backButton?.dispatchEvent(new Event('click'));
expect(router.back).toHaveBeenCalled();
});
// Test loading states
it('should show loading state while fetching data', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.getTestRun = vi
.fn()
.mockImplementation(async () => await new Promise(() => {})); // Never resolves
const { container } = renderComponent({ pinia });
await nextTick();
expect(container.querySelector('.loading')).toBeTruthy();
});
// Test metrics display
it('should correctly format and display all metrics', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testRunWithMultipleMetrics = {
...mockTestRun,
metrics: {
accuracy: 0.956789,
precision: 0.887654,
recall: 0.923456,
f1_score: 0.901234,
},
};
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.testRunsById = { run1: testRunWithMultipleMetrics };
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
const { container } = renderComponent({ pinia });
await nextTick();
// Check if the metrics are displayed correctly with 2 decimal places
expect(container.textContent).toContain('0.96');
expect(container.textContent).toContain('0.89');
expect(container.textContent).toContain('0.92');
expect(container.textContent).toContain('0.90');
});
// Test status display
it('should display correct status with appropriate styling', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testRunWithStatus: TestRunRecord = {
...mockTestRun,
status: 'error',
};
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.testRunsById = { run1: testRunWithStatus };
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
const { container } = renderComponent({ pinia });
await nextTick();
const statusElement = container.querySelector('.error');
expect(statusElement).toBeTruthy();
expect(statusElement?.textContent?.trim()).toBe('error');
});
// Test table data
it('should correctly populate the test cases table', async () => {
const pinia = createTestingPinia();
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
const executionsStore = mockedStore(useExecutionsStore);
// Mock all required store methods
testDefinitionStore.testRunsById = { run1: mockTestRun };
testDefinitionStore.testDefinitionsById = { test1: mockTestDefinition };
testDefinitionStore.getTestRun = getTestRunMock;
// Add this mock for fetchTestDefinition
testDefinitionStore.fetchTestDefinition = vi.fn().mockResolvedValue(mockTestDefinition);
executionsStore.fetchExecutions = fetchExecutionsMock;
executionsStore.fetchExecution = fetchExecutionMock;
const { container } = renderComponent({ pinia });
await nextTick();
// Wait for all promises to resolve
await waitAllPromises();
const tableRows = container.querySelectorAll('.el-table__row');
expect(tableRows.length).toBe(mockExecutions.results.length);
});
});

View file

@ -19,7 +19,6 @@ import {
WAIT_INDEFINITELY,
} from 'n8n-workflow';
import { type CompletionPageConfig } from './interfaces';
import { formDescription, formFields, formTitle } from '../Form/common.descriptions';
import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils';
@ -273,19 +272,19 @@ export class Form extends Node {
const method = context.getRequestObject().method;
if (operation === 'completion' && method === 'GET') {
const staticData = context.getWorkflowStaticData('node');
const id = `${context.getExecutionId()}-${context.getNode().name}`;
const config = staticData?.[id] as CompletionPageConfig;
delete staticData[id];
const completionTitle = context.getNodeParameter('completionTitle', '') as string;
const completionMessage = context.getNodeParameter('completionMessage', '') as string;
const redirectUrl = context.getNodeParameter('redirectUrl', '') as string;
const options = context.getNodeParameter('options', {}) as { formTitle: string };
if (config.redirectUrl) {
if (redirectUrl) {
res.send(
`<html><head><meta http-equiv="refresh" content="0; url=${config.redirectUrl}"></head></html>`,
`<html><head><meta http-equiv="refresh" content="0; url=${redirectUrl}"></head></html>`,
);
return { noWebhookResponse: true };
}
let title = config.pageTitle;
let title = options.formTitle;
if (!title) {
title = context.evaluateExpression(
`{{ $('${trigger?.name}').params.formTitle }}`,
@ -296,8 +295,8 @@ export class Form extends Node {
) as boolean;
res.render('form-trigger-completion', {
title: config.completionTitle,
message: config.completionMessage,
title: completionTitle,
message: completionMessage,
formTitle: title,
appendAttribution,
});
@ -419,28 +418,7 @@ export class Form extends Node {
);
}
if (operation !== 'completion') {
await context.putExecutionToWait(WAIT_INDEFINITELY);
} else {
const staticData = context.getWorkflowStaticData('node');
const completionTitle = context.getNodeParameter('completionTitle', 0, '') as string;
const completionMessage = context.getNodeParameter('completionMessage', 0, '') as string;
const redirectUrl = context.getNodeParameter('redirectUrl', 0, '') as string;
const options = context.getNodeParameter('options', 0, {}) as { formTitle: string };
const id = `${context.getExecutionId()}-${context.getNode().name}`;
const config: CompletionPageConfig = {
completionTitle,
completionMessage,
redirectUrl,
pageTitle: options.formTitle,
};
staticData[id] = config;
const waitTill = new Date(WAIT_INDEFINITELY);
await context.putExecutionToWait(waitTill);
}
await context.putExecutionToWait(WAIT_INDEFINITELY);
return [context.getInputData()];
}

View file

@ -32,11 +32,4 @@ export type FormTriggerData = {
buttonLabel?: string;
};
export type CompletionPageConfig = {
pageTitle?: string;
completionMessage?: string;
completionTitle?: string;
redirectUrl?: string;
};
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';

View file

@ -172,7 +172,7 @@ describe('Form Node', () => {
]);
});
it('should handle completion operation', async () => {
it('should handle completion operation and render completion page', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
if (paramName === 'operation') return 'completion';
@ -181,6 +181,7 @@ describe('Form Node', () => {
if (paramName === 'respondWith') return 'text';
if (paramName === 'completionTitle') return 'Test Title';
if (paramName === 'completionMessage') return 'Test Message';
if (paramName === 'redirectUrl') return '';
return {};
});
mockWebhookFunctions.getParentNodes.mockReturnValue([
@ -202,16 +203,55 @@ describe('Form Node', () => {
);
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>({ name: formCompletionNodeName }));
mockWebhookFunctions.getExecutionId.mockReturnValue(testExecutionId);
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
[`${testExecutionId}-${formCompletionNodeName}`]: { redirectUrl: '' },
});
const result = await form.webhook(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.render).toHaveBeenCalledWith(
'form-trigger-completion',
expect.any(Object),
expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: 'test',
formTitle: 'test',
message: 'Test Message',
title: 'Test Title',
});
});
it('should handle completion operation and redirect', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
if (paramName === 'operation') return 'completion';
if (paramName === 'useJson') return false;
if (paramName === 'jsonOutput') return '[]';
if (paramName === 'respondWith') return 'text';
if (paramName === 'completionTitle') return 'Test Title';
if (paramName === 'completionMessage') return 'Test Message';
if (paramName === 'redirectUrl') return 'https://n8n.io';
return {};
});
mockWebhookFunctions.getParentNodes.mockReturnValue([
{
type: 'n8n-nodes-base.formTrigger',
name: 'Form Trigger',
typeVersion: 2.1,
disabled: false,
},
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
const mockResponseObject = {
render: jest.fn(),
redirect: jest.fn(),
send: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>({ name: formCompletionNodeName }));
const result = await form.webhook(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.send).toHaveBeenCalledWith(
'<html><head><meta http-equiv="refresh" content="0; url=https://n8n.io"></head></html>',
);
});
});