From 132aa0b9f12ab643acf618def369f599cd9a95e6 Mon Sep 17 00:00:00 2001 From: oleg Date: Wed, 27 Nov 2024 07:47:41 +0100 Subject: [PATCH] feat(editor): Add workflow evaluation edit and list views (no-changelog) (#11719) --- .../NavigationDropdown.vue | 2 +- .../editor-ui/src/api/testDefinition.ee.ts | 73 +++++ .../src/components/MainHeader/MainHeader.vue | 29 +- .../editor-ui/src/components/TagsDropdown.vue | 4 +- .../EditDefinition/BlockArrow.vue | 40 +++ .../EditDefinition/DescriptionInput.vue | 40 +++ .../EditDefinition/EvaluationHeader.vue | 100 +++++++ .../EditDefinition/EvaluationStep.vue | 142 +++++++++ .../EditDefinition/MetricsInput.vue | 75 +++++ .../EditDefinition/TagsInput.vue | 102 +++++++ .../EditDefinition/WorkflowSelector.vue | 43 +++ .../ListDefinition/EmptyState.vue | 40 +++ .../ListDefinition/TestItem.vue | 147 ++++++++++ .../ListDefinition/TestsList.vue | 36 +++ .../composables/useTestDefinitionForm.ts | 200 +++++++++++++ .../TestDefinition/tests/MetricsInput.test.ts | 56 ++++ .../tests/useTestDefinitionForm.test.ts | 176 ++++++++++++ .../src/components/TestDefinition/types.ts | 13 + .../global/GlobalExecutionsList.vue | 2 +- .../src/components/layouts/PageViewLayout.vue | 2 +- packages/editor-ui/src/constants.ts | 7 + packages/editor-ui/src/n8n-theme.scss | 1 + .../src/plugins/i18n/locales/en.json | 45 ++- packages/editor-ui/src/plugins/icons/index.ts | 2 + packages/editor-ui/src/router.ts | 53 ++++ .../stores/testDefinition.store.ee.test.ts | 264 +++++++++++++++++ .../src/stores/testDefinition.store.ee.ts | 171 +++++++++++ .../editor-ui/src/views/ProjectSettings.vue | 4 +- .../editor-ui/src/views/TemplatesView.vue | 2 +- .../TestDefinition/TestDefinitionEditView.vue | 270 ++++++++++++++++++ .../TestDefinition/TestDefinitionListView.vue | 153 ++++++++++ .../tests/TestDefinitionEditView.test.ts | 208 ++++++++++++++ 32 files changed, 2490 insertions(+), 12 deletions(-) create mode 100644 packages/editor-ui/src/api/testDefinition.ee.ts create mode 100644 packages/editor-ui/src/components/TestDefinition/EditDefinition/BlockArrow.vue create mode 100644 packages/editor-ui/src/components/TestDefinition/EditDefinition/DescriptionInput.vue create mode 100644 packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationHeader.vue create mode 100644 packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationStep.vue create mode 100644 packages/editor-ui/src/components/TestDefinition/EditDefinition/MetricsInput.vue create mode 100644 packages/editor-ui/src/components/TestDefinition/EditDefinition/TagsInput.vue create mode 100644 packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue create mode 100644 packages/editor-ui/src/components/TestDefinition/ListDefinition/EmptyState.vue create mode 100644 packages/editor-ui/src/components/TestDefinition/ListDefinition/TestItem.vue create mode 100644 packages/editor-ui/src/components/TestDefinition/ListDefinition/TestsList.vue create mode 100644 packages/editor-ui/src/components/TestDefinition/composables/useTestDefinitionForm.ts create mode 100644 packages/editor-ui/src/components/TestDefinition/tests/MetricsInput.test.ts create mode 100644 packages/editor-ui/src/components/TestDefinition/tests/useTestDefinitionForm.test.ts create mode 100644 packages/editor-ui/src/components/TestDefinition/types.ts create mode 100644 packages/editor-ui/src/stores/testDefinition.store.ee.test.ts create mode 100644 packages/editor-ui/src/stores/testDefinition.store.ee.ts create mode 100644 packages/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue create mode 100644 packages/editor-ui/src/views/TestDefinition/TestDefinitionListView.vue create mode 100644 packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts diff --git a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue index ba905e75cffb1..1329d9a9ed23d 100644 --- a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue +++ b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue @@ -1,6 +1,6 @@ + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationHeader.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationHeader.vue new file mode 100644 index 0000000000000..3f21e6a399b2d --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationHeader.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationStep.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationStep.vue new file mode 100644 index 0000000000000..f942613a35150 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationStep.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/MetricsInput.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/MetricsInput.vue new file mode 100644 index 0000000000000..e899c8be2dfa3 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/MetricsInput.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/TagsInput.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/TagsInput.vue new file mode 100644 index 0000000000000..91e4df626171c --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/TagsInput.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue new file mode 100644 index 0000000000000..87626d3ae3989 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue @@ -0,0 +1,43 @@ + + diff --git a/packages/editor-ui/src/components/TestDefinition/ListDefinition/EmptyState.vue b/packages/editor-ui/src/components/TestDefinition/ListDefinition/EmptyState.vue new file mode 100644 index 0000000000000..9f507c327f31e --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/ListDefinition/EmptyState.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestItem.vue b/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestItem.vue new file mode 100644 index 0000000000000..350c48f663f52 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestItem.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestsList.vue b/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestsList.vue new file mode 100644 index 0000000000000..e5e169dbe0e6c --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestsList.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/composables/useTestDefinitionForm.ts b/packages/editor-ui/src/components/TestDefinition/composables/useTestDefinitionForm.ts new file mode 100644 index 0000000000000..1a2ccd988ab34 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/composables/useTestDefinitionForm.ts @@ -0,0 +1,200 @@ +import { ref, computed } from 'vue'; +import type { ComponentPublicInstance } from 'vue'; +import type { INodeParameterResourceLocator } from 'n8n-workflow'; +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[]; +} + +type FormRefs = { + nameInput: ComponentPublicInstance; + tagsInput: ComponentPublicInstance; +}; + +export function useTestDefinitionForm() { + // Stores + const evaluationsStore = useTestDefinitionStore(); + + // Form state + const state = ref({ + description: '', + name: { + value: `My Test [${new Date().toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })}]`, + isEditing: false, + tempValue: '', + }, + tags: { + isEditing: false, + appliedTagIds: [], + }, + evaluationWorkflow: { + mode: 'list', + value: '', + __rl: true, + }, + metrics: [''], + }); + + // Loading states + const isSaving = ref(false); + const fieldsIssues = ref>([]); + + // Field refs + const fields = ref({} as FormRefs); + + // Methods + 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: [''], + }; + } + } catch (error) { + // TODO: Throw better errors + console.error('Failed to load test data', error); + } + }; + + const createTest = async (workflowId: string) => { + if (isSaving.value) return; + + isSaving.value = true; + 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; + } finally { + isSaving.value = false; + } + }; + + const updateTest = async (testId: string) => { + if (isSaving.value) return; + + isSaving.value = true; + 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]; + if (annotationTagId) { + params.annotationTagId = annotationTagId; + } + // Update the existing test + 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; + } + }; + + const saveChanges = (field: string) => { + if (field === 'name') { + state.value.name.value = state.value.name.tempValue; + state.value.name.isEditing = false; + } else { + state.value.tags.isEditing = false; + } + }; + + const cancelEditing = (field: string) => { + if (field === 'name') { + state.value.name.isEditing = false; + state.value.name.tempValue = ''; + } else { + state.value.tags.isEditing = false; + } + }; + + const handleKeydown = (event: KeyboardEvent, field: string) => { + 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), + loadTestData, + createTest, + updateTest, + startEditing, + saveChanges, + cancelEditing, + handleKeydown, + }; +} diff --git a/packages/editor-ui/src/components/TestDefinition/tests/MetricsInput.test.ts b/packages/editor-ui/src/components/TestDefinition/tests/MetricsInput.test.ts new file mode 100644 index 0000000000000..43b21645215e2 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/tests/MetricsInput.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createComponentRenderer } from '@/__tests__/render'; +import MetricsInput from '../EditDefinition/MetricsInput.vue'; +import userEvent from '@testing-library/user-event'; + +const renderComponent = createComponentRenderer(MetricsInput); + +describe('MetricsInput', () => { + let props: { modelValue: string[] }; + + beforeEach(() => { + props = { + modelValue: ['Metric 1', 'Metric 2'], + }; + }); + + it('should render correctly with initial metrics', () => { + const { getAllByPlaceholderText } = renderComponent({ props }); + const inputs = getAllByPlaceholderText('Enter metric name'); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toHaveValue('Metric 1'); + expect(inputs[1]).toHaveValue('Metric 2'); + }); + + it('should update a metric when typing in the input', async () => { + const { getAllByPlaceholderText, emitted } = renderComponent({ + props: { + modelValue: [''], + }, + }); + 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]])); + }); + + it('should render correctly with no initial metrics', () => { + props.modelValue = []; + const { queryAllByRole, getByText } = renderComponent({ props }); + const inputs = queryAllByRole('textbox'); + expect(inputs).toHaveLength(0); + expect(getByText('New metric')).toBeInTheDocument(); + }); + + it('should handle adding multiple metrics', async () => { + const { getByText, emitted } = renderComponent({ props }); + const addButton = getByText('New metric'); + + addButton.click(); + addButton.click(); + addButton.click(); + + expect(emitted('update:modelValue')).toHaveProperty('length', 3); + }); +}); diff --git a/packages/editor-ui/src/components/TestDefinition/tests/useTestDefinitionForm.test.ts b/packages/editor-ui/src/components/TestDefinition/tests/useTestDefinitionForm.test.ts new file mode 100644 index 0000000000000..952807d7cc3e7 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/tests/useTestDefinitionForm.test.ts @@ -0,0 +1,176 @@ +import { setActivePinia } from 'pinia'; +import { createTestingPinia } from '@pinia/testing'; +import { useTestDefinitionForm } from '../composables/useTestDefinitionForm'; +import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; +import { mockedStore } from '@/__tests__/utils'; +import type { TestDefinitionRecord } from '@/api/testDefinition.ee'; + +const TEST_DEF_A: TestDefinitionRecord = { + id: '1', + name: 'Test Definition A', + description: 'Description A', + evaluationWorkflowId: '456', + workflowId: '123', + annotationTagId: '789', +}; +const TEST_DEF_B: TestDefinitionRecord = { + id: '2', + name: 'Test Definition B', + workflowId: '123', + description: 'Description B', +}; +const TEST_DEF_NEW: TestDefinitionRecord = { + id: '3', + workflowId: '123', + name: 'New Test Definition', + description: 'New Description', +}; + +beforeEach(() => { + const pinia = createTestingPinia(); + setActivePinia(pinia); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('useTestDefinitionForm', async () => { + it('should initialize with default props', async () => { + const { state } = useTestDefinitionForm(); + + expect(state.value.description).toEqual(''); + 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(''); + }); + + it('should load test data', async () => { + const { loadTestData, state } = useTestDefinitionForm(); + const fetchSpy = vi.fn(); + 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(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.evaluationWorkflow.value).toEqual(TEST_DEF_A.evaluationWorkflowId); + }); + + 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; + + state.value.name.value = TEST_DEF_NEW.name; + state.value.description = TEST_DEF_NEW.description ?? ''; + + const newTest = await createTest('123'); + expect(createSpy).toBeCalledWith({ + name: TEST_DEF_NEW.name, + description: TEST_DEF_NEW.description, + workflowId: '123', + }); + expect(newTest).toEqual(TEST_DEF_NEW); + }); + + 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; + + state.value.name.value = TEST_DEF_B.name; + state.value.description = TEST_DEF_B.description ?? ''; + + const updatedTest = await updateTest(TEST_DEF_A.id); + expect(updateSpy).toBeCalledWith({ + id: TEST_DEF_A.id, + name: TEST_DEF_B.name, + description: TEST_DEF_B.description, + }); + expect(updatedTest).toEqual(TEST_DEF_B); + }); + + it('should start editing a field', async () => { + const { state, startEditing } = useTestDefinitionForm(); + + await startEditing('name'); + expect(state.value.name.isEditing).toBe(true); + expect(state.value.name.tempValue).toBe(state.value.name.value); + + await startEditing('tags'); + expect(state.value.tags.isEditing).toBe(true); + }); + + it('should save changes to a field', async () => { + const { state, startEditing, saveChanges } = useTestDefinitionForm(); + + await 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']; + saveChanges('tags'); + expect(state.value.tags.isEditing).toBe(false); + expect(state.value.tags.appliedTagIds).toEqual(['123']); + }); + + it('should cancel editing a field', async () => { + const { state, startEditing, cancelEditing } = useTestDefinitionForm(); + + await startEditing('name'); + state.value.name.tempValue = 'New Name'; + cancelEditing('name'); + expect(state.value.name.isEditing).toBe(false); + expect(state.value.name.tempValue).toBe(''); + + await startEditing('tags'); + state.value.tags.appliedTagIds = ['123']; + cancelEditing('tags'); + expect(state.value.tags.isEditing).toBe(false); + }); + + it('should handle keydown - Escape', async () => { + const { state, startEditing, handleKeydown } = useTestDefinitionForm(); + + await startEditing('name'); + handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'name'); + expect(state.value.name.isEditing).toBe(false); + + await startEditing('tags'); + handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'tags'); + expect(state.value.tags.isEditing).toBe(false); + }); + + it('should handle keydown - Enter', async () => { + const { state, startEditing, handleKeydown } = useTestDefinitionForm(); + + await 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']; + handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'tags'); + expect(state.value.tags.isEditing).toBe(false); + }); +}); diff --git a/packages/editor-ui/src/components/TestDefinition/types.ts b/packages/editor-ui/src/components/TestDefinition/types.ts new file mode 100644 index 0000000000000..68a9d246a5b5c --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/types.ts @@ -0,0 +1,13 @@ +export interface TestExecution { + lastRun: string | null; + errorRate: number | null; + metrics: Record; +} + +export interface TestListItem { + id: string; + name: string; + tagName: string; + testCases: number; + execution: TestExecution; +} diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue index 0a4dbecba3f16..8422699feed2f 100644 --- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue +++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue @@ -459,8 +459,8 @@ async function onAutoRefreshToggle(value: boolean) { position: relative; height: 100%; width: 100%; - max-width: 1280px; padding: var(--spacing-l) var(--spacing-2xl) 0; + max-width: var(--content-container-width); } .execList { diff --git a/packages/editor-ui/src/components/layouts/PageViewLayout.vue b/packages/editor-ui/src/components/layouts/PageViewLayout.vue index 3500700373541..419a41663ff3c 100644 --- a/packages/editor-ui/src/components/layouts/PageViewLayout.vue +++ b/packages/editor-ui/src/components/layouts/PageViewLayout.vue @@ -13,7 +13,7 @@ flex-direction: column; height: 100%; width: 100%; - max-width: 1280px; + max-width: var(--content-container-width); box-sizing: border-box; align-content: start; padding: var(--spacing-l) var(--spacing-2xl) 0; diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 90332668f3706..0ee70b9929ae6 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -495,6 +495,9 @@ export const enum VIEWS { COMMUNITY_NODES = 'CommunityNodes', WORKFLOWS = 'WorkflowsView', WORKFLOW_EXECUTIONS = 'WorkflowExecutions', + TEST_DEFINITION = 'TestDefinition', + TEST_DEFINITION_EDIT = 'TestDefinitionEdit', + NEW_TEST_DEFINITION = 'NewTestDefinition', USAGE = 'Usage', LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView', SSO_SETTINGS = 'SSoSettings', @@ -591,6 +594,7 @@ export const enum MAIN_HEADER_TABS { WORKFLOW = 'workflow', EXECUTIONS = 'executions', SETTINGS = 'settings', + TEST_DEFINITION = 'testDefinition', } export const CURL_IMPORT_NOT_SUPPORTED_PROTOCOLS = [ 'ftp', @@ -652,6 +656,7 @@ export const enum STORES { ASSISTANT = 'assistant', BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator', PROJECTS = 'projects', + TEST_DEFINITION = 'testDefinition', } export const enum SignInType { @@ -709,6 +714,8 @@ export const EXPERIMENTS_TO_TRACK = [ CREDENTIAL_DOCS_EXPERIMENT.name, ]; +export const WORKFLOW_EVALUATION_EXPERIMENT = '025_workflow_evaluation'; + export const MFA_FORM = { MFA_TOKEN: 'MFA_TOKEN', MFA_RECOVERY_CODE: 'MFA_RECOVERY_CODE', diff --git a/packages/editor-ui/src/n8n-theme.scss b/packages/editor-ui/src/n8n-theme.scss index 05064ca78ad62..33fa11cb5fdda 100644 --- a/packages/editor-ui/src/n8n-theme.scss +++ b/packages/editor-ui/src/n8n-theme.scss @@ -4,6 +4,7 @@ :root { // Using native css variable enables us to use this value in JS --header-height: 65; + --content-container-width: 1280px; } .clickable { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index e78fd1b1050c9..d0d959ce67d2c 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -47,6 +47,7 @@ "generic.delete": "Delete", "generic.dontShowAgain": "Don't show again", "generic.executions": "Executions", + "generic.tests": "Tests", "generic.or": "or", "generic.clickToCopy": "Click to copy", "generic.copiedToClipboard": "Copied to clipboard", @@ -2716,5 +2717,47 @@ "communityPlusModal.button.skip": "Skip", "communityPlusModal.button.confirm": "Send me a free license key", "executeWorkflowTrigger.createNewSubworkflow": "Create a sub-workflow in {projectName}", - "executeWorkflowTrigger.createNewSubworkflow.noProject": "Create a new sub-workflow" + "executeWorkflowTrigger.createNewSubworkflow.noProject": "Create a new sub-workflow", + "testDefinition.edit.descriptionPlaceholder": "", + "testDefinition.edit.backButtonTitle": "Back to Workflow Evaluation", + "testDefinition.edit.namePlaceholder": "Enter test name", + "testDefinition.edit.metricsTitle": "Metrics", + "testDefinition.edit.metricsHelpText": "The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.", + "testDefinition.edit.metricsFields": "Output field(s)", + "testDefinition.edit.metricsPlaceholder": "Enter metric name", + "testDefinition.edit.metricsNew": "New metric", + "testDefinition.edit.selectTag": "Select tag...", + "testDefinition.edit.tagsHelpText": "Executions with this tag will be added as test cases to this test.", + "testDefinition.edit.workflowSelectorLabel": "Workflow to make comparisons", + "testDefinition.edit.workflowSelectorDisplayName": "Workflow", + "testDefinition.edit.workflowSelectorTitle": "Workflow to make comparisons", + "testDefinition.edit.workflowSelectorHelpText": "This workflow will be called once for each test case.", + "testDefinition.edit.updateTest": "Update test", + "testDefinition.edit.saveTest": "Run test", + "testDefinition.edit.testSaved": "Test saved", + "testDefinition.edit.testSaveFailed": "Failed to save test", + "testDefinition.edit.description": "Description", + "testDefinition.edit.tagName": "Tag name", + "testDefinition.edit.step.intro": "When running a test", + "testDefinition.edit.step.executions": "Fetch 5 past executions", + "testDefinition.edit.step.nodes": "Mock nodes", + "testDefinition.edit.step.mockedNodes": "No nodes mocked | {count} node mocked | {count} nodes mocked", + "testDefinition.edit.step.reRunExecutions": "Re-run executions", + "testDefinition.edit.step.compareExecutions": "Compare each past and new execution", + "testDefinition.edit.step.metrics": "Summarise metrics", + "testDefinition.edit.step.collapse": "Collapse", + "testDefinition.edit.step.expand": "Expand", + "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.errorRate": "Error rate: {errorRate}", + "testDefinition.runTest": "Run Test", + "testDefinition.notImplemented": "This feature is not implemented yet!", + "testDefinition.viewDetails": "View Details", + "testDefinition.editTest": "Edit Test", + "testDefinition.deleteTest": "Delete Test" } diff --git a/packages/editor-ui/src/plugins/icons/index.ts b/packages/editor-ui/src/plugins/icons/index.ts index 0e9430d8ae570..561ad6afbf1c0 100644 --- a/packages/editor-ui/src/plugins/icons/index.ts +++ b/packages/editor-ui/src/plugins/icons/index.ts @@ -51,6 +51,7 @@ import { faEllipsisH, faEllipsisV, faEnvelope, + faEquals, faEye, faExclamationTriangle, faExpand, @@ -223,6 +224,7 @@ export const FontAwesomePlugin: Plugin = { addIcon(faEllipsisH); addIcon(faEllipsisV); addIcon(faEnvelope); + addIcon(faEquals); addIcon(faEye); addIcon(faExclamationTriangle); addIcon(faExpand); diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 1580a3bff4a83..f3bf9f656a766 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -57,6 +57,10 @@ const SettingsExternalSecrets = async () => await import('./views/SettingsExtern const WorkerView = async () => await import('./views/WorkerView.vue'); const WorkflowHistory = async () => await import('@/views/WorkflowHistory.vue'); const WorkflowOnboardingView = async () => await import('@/views/WorkflowOnboardingView.vue'); +const TestDefinitionListView = async () => + await import('./views/TestDefinition/TestDefinitionListView.vue'); +const TestDefinitionEditView = async () => + await import('./views/TestDefinition/TestDefinitionEditView.vue'); function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]): { name: string } | false { const settingsStore = useSettingsStore(); @@ -249,6 +253,55 @@ export const routes: RouteRecordRaw[] = [ }, ], }, + { + path: '/workflow/:name/evaluation', + name: VIEWS.TEST_DEFINITION, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + children: [ + { + path: '', + name: VIEWS.TEST_DEFINITION, + components: { + default: TestDefinitionListView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + }, + { + path: 'new', + name: VIEWS.NEW_TEST_DEFINITION, + components: { + default: TestDefinitionEditView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + }, + { + path: ':testId', + name: VIEWS.TEST_DEFINITION_EDIT, + components: { + default: TestDefinitionEditView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + }, + ], + }, { path: '/workflow/:workflowId/history/:versionId?', name: VIEWS.WORKFLOW_HISTORY, diff --git a/packages/editor-ui/src/stores/testDefinition.store.ee.test.ts b/packages/editor-ui/src/stores/testDefinition.store.ee.test.ts new file mode 100644 index 0000000000000..702701a495776 --- /dev/null +++ b/packages/editor-ui/src/stores/testDefinition.store.ee.test.ts @@ -0,0 +1,264 @@ +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'; + +const { createTestDefinition, deleteTestDefinition, getTestDefinitions, updateTestDefinition } = + vi.hoisted(() => ({ + getTestDefinitions: vi.fn(), + createTestDefinition: vi.fn(), + updateTestDefinition: vi.fn(), + deleteTestDefinition: vi.fn(), + })); + +vi.mock('@/api/testDefinition.ee', () => ({ + createTestDefinition, + deleteTestDefinition, + getTestDefinitions, + updateTestDefinition, +})); + +vi.mock('@/stores/root.store', () => ({ + useRootStore: vi.fn(() => ({ + restApiContext: { instanceId: 'test-instance-id' }, + })), +})); + +const TEST_DEF_A: TestDefinitionRecord = { + id: '1', + name: 'Test Definition A', + workflowId: '123', + description: 'Description A', +}; +const TEST_DEF_B: TestDefinitionRecord = { + id: '2', + name: 'Test Definition B', + workflowId: '123', + description: 'Description B', +}; +const TEST_DEF_NEW: TestDefinitionRecord = { + id: '3', + name: 'New Test Definition', + workflowId: '123', + description: 'New Description', +}; + +describe('testDefinition.store.ee', () => { + let store: ReturnType; + let rootStoreMock: ReturnType; + let posthogStoreMock: ReturnType; + + beforeEach(() => { + vi.restoreAllMocks(); + setActivePinia(createPinia()); + store = useTestDefinitionStore(); + rootStoreMock = useRootStore(); + posthogStoreMock = usePostHog(); + + getTestDefinitions.mockResolvedValue({ + count: 2, + testDefinitions: [TEST_DEF_A, TEST_DEF_B], + }); + + createTestDefinition.mockResolvedValue(TEST_DEF_NEW); + + deleteTestDefinition.mockResolvedValue({ success: true }); + }); + + test('Initialization', () => { + expect(store.testDefinitionsById).toEqual({}); + expect(store.isLoading).toBe(false); + expect(store.hasTestDefinitions).toBe(false); + }); + + test('Fetching Test Definitions', async () => { + expect(store.isLoading).toBe(false); + + const result = await store.fetchAll(); + + expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext); + 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 with force flag', async () => { + expect(store.isLoading).toBe(false); + + const result = await store.fetchAll({ force: true }); + + expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext); + 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('Deleting Test Definitions', () => { + store.testDefinitionsById = { + '1': TEST_DEF_A, + '2': TEST_DEF_B, + }; + + store.deleteTestDefinition('1'); + + expect(store.testDefinitionsById).toEqual({ + '2': TEST_DEF_B, + }); + }); + + 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 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); + }); +}); diff --git a/packages/editor-ui/src/stores/testDefinition.store.ee.ts b/packages/editor-ui/src/stores/testDefinition.store.ee.ts new file mode 100644 index 0000000000000..6a9e3fe363692 --- /dev/null +++ b/packages/editor-ui/src/stores/testDefinition.store.ee.ts @@ -0,0 +1,171 @@ +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 { usePostHog } from './posthog.store'; +import { STORES, WORKFLOW_EVALUATION_EXPERIMENT } from '@/constants'; + +export const useTestDefinitionStore = defineStore( + STORES.TEST_DEFINITION, + () => { + // State + const testDefinitionsById = ref>({}); + const loading = ref(false); + const fetchedAll = ref(false); + + // Store instances + const posthogStore = usePostHog(); + const rootStore = useRootStore(); + + // Computed + const allTestDefinitions = computed(() => { + return Object.values(testDefinitionsById.value).sort((a, b) => + (a.name ?? '').localeCompare(b.name ?? ''), + ); + }); + + // Enable with `window.featureFlags.override('025_workflow_evaluation', true)` + const isFeatureEnabled = computed(() => + posthogStore.isFeatureEnabled(WORKFLOW_EVALUATION_EXPERIMENT), + ); + + const isLoading = computed(() => loading.value); + + const hasTestDefinitions = computed(() => Object.keys(testDefinitionsById.value).length > 0); + + // Methods + const setAllTestDefinitions = (definitions: TestDefinitionRecord[]) => { + testDefinitionsById.value = definitions.reduce( + (acc: Record, def: TestDefinitionRecord) => { + acc[def.id] = def; + return acc; + }, + {}, + ); + }; + + /** + * Upserts test definitions in the store. + * @param toUpsertDefinitions - An array of test definitions to upsert. + */ + const upsertTestDefinitions = (toUpsertDefinitions: TestDefinitionRecord[]) => { + toUpsertDefinitions.forEach((toUpsertDef) => { + const defId = toUpsertDef.id; + if (!defId) throw Error('ID is required for upserting'); + const currentDef = testDefinitionsById.value[defId]; + testDefinitionsById.value = { + ...testDefinitionsById.value, + [defId]: { + ...currentDef, + ...toUpsertDef, + }, + }; + }); + }; + + const deleteTestDefinition = (id: string) => { + const { [id]: deleted, ...rest } = testDefinitionsById.value; + testDefinitionsById.value = rest; + }; + + /** + * 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 testDefinitions = Object.values(testDefinitionsById.value); + return { + count: testDefinitions.length, + testDefinitions, + }; + } + + loading.value = true; + try { + const retrievedDefinitions = await testDefinitionsApi.getTestDefinitions( + rootStore.restApiContext, + ); + + setAllTestDefinitions(retrievedDefinitions.testDefinitions); + fetchedAll.value = true; + return retrievedDefinitions; + } finally { + loading.value = false; + } + }; + + /** + * Creates a new test definition using the provided parameters. + * + * @param {Object} params - An object containing the necessary parameters to create a test definition. + * @param {string} params.name - The name of the new test definition. + * @param {string} params.workflowId - The ID of the workflow associated with the test definition. + * @returns {Promise} A promise that resolves to the newly created test definition. + * @throws {Error} Throws an error if there is a problem creating the test definition. + */ + const create = async (params: { + name: string; + workflowId: string; + }) => { + const createdDefinition = await testDefinitionsApi.createTestDefinition( + rootStore.restApiContext, + params, + ); + upsertTestDefinitions([createdDefinition]); + return createdDefinition; + }; + + const update = async (params: Partial) => { + if (!params.id) throw new Error('ID is required to update a test definition'); + + const { id, ...updateParams } = params; + const updatedDefinition = await testDefinitionsApi.updateTestDefinition( + rootStore.restApiContext, + id, + updateParams, + ); + upsertTestDefinitions([updatedDefinition]); + return updatedDefinition; + }; + + /** + * Deletes a test definition by its ID. + * + * @param {number} id - The ID of the test definition to delete. + * @returns {Promise} A promise that resolves to true if the test definition was successfully deleted, false otherwise. + */ + const deleteById = async (id: string) => { + const result = await testDefinitionsApi.deleteTestDefinition(rootStore.restApiContext, id); + + if (result.success) { + deleteTestDefinition(id); + } + + return result.success; + }; + + return { + // State + fetchedAll, + testDefinitionsById, + + // Computed + allTestDefinitions, + isLoading, + hasTestDefinitions, + isFeatureEnabled, + + // Methods + fetchAll, + create, + update, + deleteById, + upsertTestDefinitions, + deleteTestDefinition, + }; + }, + {}, +); diff --git a/packages/editor-ui/src/views/ProjectSettings.vue b/packages/editor-ui/src/views/ProjectSettings.vue index 971fac96a6e26..70d4232f32a42 100644 --- a/packages/editor-ui/src/views/ProjectSettings.vue +++ b/packages/editor-ui/src/views/ProjectSettings.vue @@ -399,7 +399,7 @@ onMounted(() => { form { width: 100%; - max-width: 1280px; + max-width: var(--content-container-width); padding: 0 var(--spacing-2xl); fieldset { @@ -416,7 +416,7 @@ onMounted(() => { .header { width: 100%; - max-width: 1280px; + max-width: var(--content-container-width); padding: var(--spacing-l) var(--spacing-2xl) 0; } diff --git a/packages/editor-ui/src/views/TemplatesView.vue b/packages/editor-ui/src/views/TemplatesView.vue index d38107f51f485..5f570eab92b78 100644 --- a/packages/editor-ui/src/views/TemplatesView.vue +++ b/packages/editor-ui/src/views/TemplatesView.vue @@ -30,7 +30,7 @@ withDefaults(defineProps(), { .template { display: flex; width: 100%; - max-width: 1280px; + max-width: var(--content-container-width); padding: var(--spacing-l) var(--spacing-l) 0; justify-content: center; @media (min-width: 1200px) { diff --git a/packages/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue b/packages/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue new file mode 100644 index 0000000000000..e17c16447203e --- /dev/null +++ b/packages/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/packages/editor-ui/src/views/TestDefinition/TestDefinitionListView.vue b/packages/editor-ui/src/views/TestDefinition/TestDefinitionListView.vue new file mode 100644 index 0000000000000..33a966b4b7c89 --- /dev/null +++ b/packages/editor-ui/src/views/TestDefinition/TestDefinitionListView.vue @@ -0,0 +1,153 @@ + + + + diff --git a/packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts b/packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts new file mode 100644 index 0000000000000..9aeb70679bdce --- /dev/null +++ b/packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts @@ -0,0 +1,208 @@ +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 TestDefinitionEditView from '@/views/TestDefinition/TestDefinitionEditView.vue'; +import { useRoute, useRouter } from 'vue-router'; +import { useToast } from '@/composables/useToast'; +import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm'; +import { useAnnotationTagsStore } from '@/stores/tags.store'; +import { ref, nextTick } from 'vue'; + +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); + + beforeEach(() => { + setActivePinia(createPinia()); + + vi.mocked(useRoute).mockReturnValue({ + params: {}, + path: '/test-path', + name: 'test-route', + } as ReturnType); + + vi.mocked(useRouter).mockReturnValue({ + push: vi.fn(), + resolve: vi.fn().mockReturnValue({ href: '/test-href' }), + } as unknown as ReturnType); + + vi.mocked(useToast).mockReturnValue({ + showMessage: vi.fn(), + showError: vi.fn(), + } as unknown as ReturnType); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + state: ref({ + name: { value: '', isEditing: false, tempValue: '' }, + description: '', + tags: { appliedTagIds: [], isEditing: false }, + evaluationWorkflow: { id: '1', name: 'Test Workflow' }, + metrics: [], + }), + fieldsIssues: ref([]), + isSaving: ref(false), + loadTestData: vi.fn(), + saveTest: vi.fn(), + startEditing: vi.fn(), + saveChanges: vi.fn(), + cancelEditing: vi.fn(), + handleKeydown: vi.fn(), + } as unknown as ReturnType); + vi.mocked(useAnnotationTagsStore).mockReturnValue({ + isLoading: ref(false), + allTags: ref([]), + tagsById: ref({}), + fetchAll: vi.fn(), + } as unknown as ReturnType); + + vi.mock('@/stores/projects.store', () => ({ + useProjectsStore: vi.fn().mockReturnValue({ + isTeamProjectFeatureEnabled: false, + currentProject: null, + currentProjectId: null, + }), + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should load test data when testId is provided', async () => { + vi.mocked(useRoute).mockReturnValue({ + params: { testId: '1' }, + path: '/test-path', + name: 'test-route', + } as unknown as ReturnType); + const loadTestDataMock = vi.fn(); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + loadTestData: loadTestDataMock, + } as unknown as ReturnType); + + renderComponent({ + pinia: createTestingPinia(), + }); + + 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); + + 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); + + vi.mocked(useRouter).mockReturnValue({ + push: routerPushMock, + resolve: routerResolveMock, + } as unknown as ReturnType); + + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const saveButton = getByTestId('run-test-button'); + saveButton.click(); + await nextTick(); + + expect(saveTestMock).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); + 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 () => { + vi.mocked(useRoute).mockReturnValue({ + params: { testId: '1' }, + path: '/test-path', + name: 'test-route', + } as unknown as ReturnType); + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const updateButton = getByTestId('run-test-button'); + expect(updateButton.textContent).toContain('Update test'); + }); + + it('should display "Run Test" button when creating new test', async () => { + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const saveButton = getByTestId('run-test-button'); + expect(saveButton).toBeTruthy(); + }); + + it('should apply "has-issues" class to inputs with issues', async () => { + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + fieldsIssues: ref([{ field: 'name' }, { field: 'tags' }]), + } as unknown as ReturnType); + + const { container } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + expect(container.querySelector('.has-issues')).toBeTruthy(); + }); + + 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); + + renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + expect(fetchAllMock).toHaveBeenCalled(); + }); +});