From b2fcfe9d69c2c8c038bbfefe3b225963a71c7c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Wed, 12 Mar 2025 12:07:57 +0100 Subject: [PATCH] fix(editor): Ai 695 update executions view ux (no-changelog) (#13531) --- .../WorkflowExecutionsPreview.test.ts | 143 +++++++- .../workflow/WorkflowExecutionsPreview.vue | 339 ++++++++++++------ .../src/plugins/i18n/locales/en.json | 11 +- .../TestDefinition/TestDefinitionNewView.vue | 23 +- .../tests/TestDefinitionNewView.test.ts | 88 +++++ 5 files changed, 493 insertions(+), 111 deletions(-) create mode 100644 packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionNewView.test.ts diff --git a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.test.ts b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.test.ts index c6deacb4f4f..7bb6b9138e0 100644 --- a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.test.ts +++ b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.test.ts @@ -11,6 +11,17 @@ import type { ExecutionSummaryWithScopes, IWorkflowDb } from '@/Interface'; import { createComponentRenderer } from '@/__tests__/render'; import { createTestingPinia } from '@pinia/testing'; import { mockedStore } from '@/__tests__/utils'; +import type { FrontendSettings } from '@n8n/api-types'; +import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; +import { useExecutionsStore } from '@/stores/executions.store'; +import type { TestDefinitionRecord } from '@/api/testDefinition.ee'; + +const showMessage = vi.fn(); +const showError = vi.fn(); +const showToast = vi.fn(); +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ showMessage, showError, showToast }), +})); const routes = [ { path: '/', name: 'home', component: { template: '
' } }, @@ -41,7 +52,9 @@ const generateUndefinedNullOrString = () => { } }; -const executionDataFactory = (): ExecutionSummaryWithScopes => ({ +const executionDataFactory = ( + tags: Array<{ id: string; name: string }> = [], +): ExecutionSummaryWithScopes => ({ id: faker.string.uuid(), finished: faker.datatype.boolean(), mode: faker.helpers.arrayElement(['manual', 'trigger']), @@ -55,6 +68,17 @@ const executionDataFactory = (): ExecutionSummaryWithScopes => ({ retryOf: generateUndefinedNullOrString(), retrySuccessId: generateUndefinedNullOrString(), scopes: ['workflow:update'], + annotation: { tags, vote: 'up' }, +}); + +const testCaseFactory = (workflowId: string, annotationTagId?: string): TestDefinitionRecord => ({ + id: faker.string.uuid(), + createdAt: faker.date.past().toString(), + updatedAt: faker.date.past().toString(), + evaluationWorkflowId: null, + annotationTagId, + workflowId, + name: `My test ${faker.number.int()}`, }); const renderComponent = createComponentRenderer(WorkflowExecutionsPreview, { @@ -89,7 +113,7 @@ describe('WorkflowExecutionsPreview.vue', () => { settingsStore.settings.enterprise = { ...(settingsStore.settings.enterprise ?? {}), [EnterpriseEditionFeature.DebugInEditor]: availability, - }; + } as FrontendSettings['enterprise']; workflowsStore.workflowsById[executionData.workflowId] = { scopes } as IWorkflowDb; @@ -110,4 +134,119 @@ describe('WorkflowExecutionsPreview.vue', () => { expect(getByTestId('stop-execution')).toBeDisabled(); }); + + describe('test execution crud', () => { + it('should add an execution to a testcase', async () => { + const tag = { id: 'tag_id', name: 'tag_name' }; + const execution = executionDataFactory([]); + const testCase = testCaseFactory(execution.workflowId, tag.id); + + const testDefinitionStore = mockedStore(useTestDefinitionStore); + const executionsStore = mockedStore(useExecutionsStore); + const settingsStore = mockedStore(useSettingsStore); + + testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [testCase]; + + settingsStore.isEnterpriseFeatureEnabled = { + advancedExecutionFilters: true, + } as FrontendSettings['enterprise']; + + const { getByTestId } = renderComponent({ + props: { execution: { ...execution, status: 'success' } }, + }); + + await router.push({ params: { name: execution.workflowId }, query: { testId: testCase.id } }); + + expect(getByTestId('test-execution-crud')).toBeInTheDocument(); + expect(getByTestId('test-execution-add')).toBeVisible(); + + await userEvent.click(getByTestId('test-execution-add')); + + expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, { + tags: [testCase.annotationTagId], + }); + + expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); + }); + + it('should remove an execution from a testcase', async () => { + const tag = { id: 'tag_id', name: 'tag_name' }; + const execution = executionDataFactory([tag]); + const testCase = testCaseFactory(execution.workflowId, tag.id); + + const testDefinitionStore = mockedStore(useTestDefinitionStore); + const executionsStore = mockedStore(useExecutionsStore); + const settingsStore = mockedStore(useSettingsStore); + + testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [testCase]; + + settingsStore.isEnterpriseFeatureEnabled = { + advancedExecutionFilters: true, + } as FrontendSettings['enterprise']; + + const { getByTestId } = renderComponent({ + props: { execution: { ...execution, status: 'success' } }, + }); + + await router.push({ params: { name: execution.workflowId }, query: { testId: testCase.id } }); + + expect(getByTestId('test-execution-crud')).toBeInTheDocument(); + expect(getByTestId('test-execution-remove')).toBeVisible(); + + await userEvent.click(getByTestId('test-execution-remove')); + + expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, { + tags: [], + }); + + expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); + }); + + it('should toggle an execution', async () => { + const tag1 = { id: 'tag_id', name: 'tag_name' }; + const tag2 = { id: 'tag_id_2', name: 'tag_name_2' }; + const execution = executionDataFactory([tag1]); + const testCase1 = testCaseFactory(execution.workflowId, tag1.id); + const testCase2 = testCaseFactory(execution.workflowId, tag2.id); + + const testDefinitionStore = mockedStore(useTestDefinitionStore); + const executionsStore = mockedStore(useExecutionsStore); + const settingsStore = mockedStore(useSettingsStore); + + testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [ + testCase1, + testCase2, + ]; + + settingsStore.isEnterpriseFeatureEnabled = { + advancedExecutionFilters: true, + } as FrontendSettings['enterprise']; + + const { getByTestId, queryAllByTestId, rerender } = renderComponent({ + props: { execution: { ...execution, status: 'success' } }, + }); + + await router.push({ params: { name: execution.workflowId } }); + + expect(getByTestId('test-execution-crud')).toBeInTheDocument(); + expect(getByTestId('test-execution-toggle')).toBeVisible(); + + // add + await userEvent.click(getByTestId('test-execution-toggle')); + await userEvent.click(queryAllByTestId('test-execution-add-to')[1]); + expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, { + tags: [tag1.id, tag2.id], + }); + + const executionWithBothTags = executionDataFactory([tag1, tag2]); + await rerender({ execution: { ...executionWithBothTags, status: 'success' } }); + + // remove + await userEvent.click(getByTestId('test-execution-toggle')); + await userEvent.click(queryAllByTestId('test-execution-add-to')[1]); + expect(executionsStore.annotateExecution).toHaveBeenLastCalledWith(executionWithBothTags.id, { + tags: [tag1.id], + }); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue index 63d9e9dda08..f7e83c715bd 100644 --- a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue +++ b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue @@ -1,24 +1,23 @@ diff --git a/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionNewView.test.ts b/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionNewView.test.ts new file mode 100644 index 00000000000..ada4e7a6775 --- /dev/null +++ b/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionNewView.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; +import { createComponentRenderer } from '@/__tests__/render'; +import TestDefinitionNewView from '@/views/TestDefinition/TestDefinitionNewView.vue'; +import { ref } from 'vue'; +import { mockedStore } from '@/__tests__/utils'; +import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; +import { useAnnotationTagsStore } from '@/stores/tags.store'; +import { useRoute } from 'vue-router'; +import { useExecutionsStore } from '@/stores/executions.store'; +import { waitFor } from '@testing-library/vue'; + +const workflowId = 'workflow_id'; +const testId = 'test_id'; + +const mockedForm = { + state: ref({ tags: { value: [] }, name }), + createTest: vi.fn().mockResolvedValue({ + id: testId, + name: 'test_name', + workflowId, + createdAt: '', + }), + updateTest: vi.fn().mockResolvedValue({}), +}; +vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm', () => ({ + useTestDefinitionForm: vi.fn().mockImplementation(() => mockedForm), +})); + +const mockReplace = vi.fn(); +vi.mock('vue-router', async (importOriginal) => ({ + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + ...(await importOriginal()), + useRoute: vi.fn().mockReturnValue({}), + useRouter: vi.fn(() => ({ + replace: mockReplace, + })), +})); + +describe('TestDefinitionRootView', () => { + const renderComponent = createComponentRenderer(TestDefinitionNewView); + + beforeEach(() => { + createTestingPinia(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should create a test adn redirect', async () => { + const testDefinitionStore = mockedStore(useTestDefinitionStore); + const annotationTagsStore = mockedStore(useAnnotationTagsStore); + + annotationTagsStore.create.mockResolvedValueOnce({ id: 'tag_id', name: 'tag_name' }); + renderComponent({ props: { name: workflowId } }); + + expect(mockedForm.createTest).toHaveBeenCalledWith(workflowId); + await waitFor(() => + expect(testDefinitionStore.updateRunFieldIssues).toHaveBeenCalledWith(testId), + ); + + expect(mockReplace).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + testId, + }, + }), + ); + }); + + it('should assign an execution to a test', async () => { + (useRoute as Mock).mockReturnValue({ + query: { executionId: 'execution_id', annotationTags: ['2', '3'] }, + }); + const annotationTagsStore = mockedStore(useAnnotationTagsStore); + const executionsStore = mockedStore(useExecutionsStore); + + annotationTagsStore.create.mockResolvedValueOnce({ id: 'tag_id', name: 'tag_name' }); + renderComponent({ props: { name: workflowId } }); + + await waitFor(() => + expect(executionsStore.annotateExecution).toHaveBeenCalledWith('execution_id', { + tags: ['2', '3', 'tag_id'], + }), + ); + }); +});