diff --git a/frontend/src/__mocks__/mlmd/mockGetExecutions.ts b/frontend/src/__mocks__/mlmd/mockGetExecutions.ts new file mode 100644 index 0000000000..c16157749e --- /dev/null +++ b/frontend/src/__mocks__/mlmd/mockGetExecutions.ts @@ -0,0 +1,346 @@ +/* eslint-disable camelcase */ +import { GetExecutionsResponse } from '~/__mocks__/third_party/mlmd'; +import createGrpcResponse, { GrpcResponse } from './utils'; + +const mockedGetNoExecutions: GetExecutionsResponse = { + executions: [], +}; + +const mockedGetExecutions: GetExecutionsResponse = { + executions: [ + { + id: 287, + name: 'run/c7ea4e4f-f2f3-4ecf-9379-a9441a243887', + typeId: 12, + type: 'system.DAGExecution', + lastKnownState: 2, + createTimeSinceEpoch: 1712899519170, + lastUpdateTimeSinceEpoch: 1712899519170, + properties: {}, + customProperties: { + display_name: { + stringValue: '', + }, + task_name: { + stringValue: '', + }, + }, + }, + { + id: 288, + typeId: 13, + type: 'system.ContainerExecution', + lastKnownState: 5, + createTimeSinceEpoch: 1712899528735, + lastUpdateTimeSinceEpoch: 1712899529361, + properties: {}, + customProperties: { + cache_fingerprint: { + stringValue: '7c3190be2e584610488d53da68e945b703d90753bf0cfade5b3cfe395d7f5c20', + }, + cached_execution_id: { + stringValue: '211', + }, + display_name: { + stringValue: 'digit-classification', + }, + image: { + stringValue: '', + }, + inputs: { + structValue: { + fields: {}, + }, + }, + namespace: { + stringValue: '', + }, + outputs: { + structValue: { + fields: {}, + }, + }, + parent_dag_id: { + intValue: 287, + }, + pod_name: { + stringValue: '', + }, + pod_uid: { + stringValue: '', + }, + task_name: { + stringValue: 'digit-classification', + }, + }, + }, + { + id: 289, + typeId: 13, + type: 'system.ContainerExecution', + lastKnownState: 5, + createTimeSinceEpoch: 1712899529328, + lastUpdateTimeSinceEpoch: 1712899529738, + properties: {}, + customProperties: { + cache_fingerprint: { + stringValue: '1122f9fda5483cc0c3dd6950514104b878f2f4f7a0cef4945ea195c0b90e0fb9', + }, + cached_execution_id: { + stringValue: '201', + }, + display_name: { + stringValue: 'html-visualization', + }, + image: { + stringValue: '', + }, + inputs: { + structValue: { + fields: {}, + }, + }, + namespace: { + stringValue: '', + }, + outputs: { + structValue: { + fields: {}, + }, + }, + parent_dag_id: { + intValue: 287, + }, + pod_name: { + stringValue: '', + }, + pod_uid: { + stringValue: '', + }, + task_name: { + stringValue: 'html-visualization', + }, + }, + }, + { + id: 290, + typeId: 13, + type: 'system.ContainerExecution', + lastKnownState: 5, + createTimeSinceEpoch: 1712899529332, + lastUpdateTimeSinceEpoch: 1712899529840, + properties: {}, + customProperties: { + cache_fingerprint: { + stringValue: '1816513632893d5cc27a2e7cc97f77d11c479a577f5be832aa46c467d87f7b33', + }, + cached_execution_id: { + stringValue: '222', + }, + display_name: { + stringValue: 'wine-classification', + }, + image: { + stringValue: '', + }, + inputs: { + structValue: { + fields: {}, + }, + }, + namespace: { + stringValue: '', + }, + outputs: { + structValue: { + fields: {}, + }, + }, + parent_dag_id: { + intValue: 287, + }, + pod_name: { + stringValue: '', + }, + pod_uid: { + stringValue: '', + }, + task_name: { + stringValue: 'wine-classification', + }, + }, + }, + { + id: 291, + typeId: 13, + type: 'system.ContainerExecution', + lastKnownState: 5, + createTimeSinceEpoch: 1712899529940, + lastUpdateTimeSinceEpoch: 1712899530046, + properties: {}, + customProperties: { + cache_fingerprint: { + stringValue: 'b66ac635abe84bb9b6018e9e27a1b43ea1d214439931ec3197049ede0e07177e', + }, + cached_execution_id: { + stringValue: '215', + }, + display_name: { + stringValue: 'iris-sgdclassifier', + }, + image: { + stringValue: '', + }, + inputs: { + structValue: { + fields: { + test_samples_fraction: { + nullValue: 0, + numberValue: 0.3, + stringValue: '', + boolValue: false, + }, + }, + }, + }, + namespace: { + stringValue: '', + }, + outputs: { + structValue: { + fields: {}, + }, + }, + parent_dag_id: { + intValue: 287, + }, + pod_name: { + stringValue: '', + }, + pod_uid: { + stringValue: '', + }, + task_name: { + stringValue: 'iris-sgdclassifier', + }, + }, + }, + { + id: 292, + typeId: 13, + type: 'system.ContainerExecution', + lastKnownState: 5, + createTimeSinceEpoch: 1712899531539, + lastUpdateTimeSinceEpoch: 1712899531647, + properties: {}, + customProperties: { + cache_fingerprint: { + stringValue: '6842ef332bd56a8e7cf55bcb002b70464102018e6d2c2360c5cc1f1587d8893f', + }, + cached_execution_id: { + stringValue: '198', + }, + display_name: { + stringValue: 'markdown-visualization', + }, + image: { + stringValue: '', + }, + inputs: { + structValue: { + fields: {}, + }, + }, + namespace: { + stringValue: '', + }, + outputs: { + structValue: { + fields: {}, + }, + }, + parent_dag_id: { + intValue: 287, + }, + pod_name: { + stringValue: '', + }, + pod_uid: { + stringValue: '', + }, + task_name: { + stringValue: 'markdown-visualization', + }, + }, + }, + ], + nextPageToken: 'page token', +}; + +const mockedGetNextPageExecutions: GetExecutionsResponse = { + executions: [ + { + id: 288, + typeId: 13, + type: 'system.ContainerExecution', + lastKnownState: 5, + createTimeSinceEpoch: 1712899528735, + lastUpdateTimeSinceEpoch: 1712899529361, + properties: {}, + customProperties: { + cache_fingerprint: { + stringValue: '7c3190be2e584610488d53da68e945b703d90753bf0cfade5b3cfe395d7f5c20', + }, + cached_execution_id: { + stringValue: '211', + }, + display_name: { + stringValue: 'digit-classification', + }, + image: { + stringValue: '', + }, + inputs: { + structValue: { + fields: {}, + }, + }, + namespace: { + stringValue: '', + }, + outputs: { + structValue: { + fields: {}, + }, + }, + parent_dag_id: { + intValue: 287, + }, + pod_name: { + stringValue: '', + }, + pod_uid: { + stringValue: '', + }, + task_name: { + stringValue: 'digit-classification', + }, + }, + }, + ], +}; + +export const mockGetNoExecutions = (): GrpcResponse => { + const binary = GetExecutionsResponse.encode(mockedGetNoExecutions).finish(); + return createGrpcResponse(binary); +}; + +export const mockGetExecutions = (): GrpcResponse => { + const binary = GetExecutionsResponse.encode(mockedGetExecutions).finish(); + return createGrpcResponse(binary); +}; + +export const mockGetNextPageExecutions = (): GrpcResponse => { + const binary = GetExecutionsResponse.encode(mockedGetNextPageExecutions).finish(); + return createGrpcResponse(binary); +}; diff --git a/frontend/src/__mocks__/mlmd/mockGetExecutionsByID.ts b/frontend/src/__mocks__/mlmd/mockGetExecutionsByID.ts new file mode 100644 index 0000000000..25a1f2104c --- /dev/null +++ b/frontend/src/__mocks__/mlmd/mockGetExecutionsByID.ts @@ -0,0 +1,61 @@ +/* eslint-disable camelcase */ +import { GetExecutionsResponse } from '~/__mocks__/third_party/mlmd'; +import createGrpcResponse, { GrpcResponse } from './utils'; + +const mockedGetExecutionsByID: GetExecutionsResponse = { + executions: [ + { + id: 288, + typeId: 13, + type: 'system.ContainerExecution', + lastKnownState: 5, + createTimeSinceEpoch: 1712899528735, + lastUpdateTimeSinceEpoch: 1712899529361, + properties: {}, + customProperties: { + cache_fingerprint: { + stringValue: '7c3190be2e584610488d53da68e945b703d90753bf0cfade5b3cfe395d7f5c20', + }, + cached_execution_id: { + stringValue: '211', + }, + display_name: { + stringValue: 'digit-classification', + }, + image: { + stringValue: '', + }, + inputs: { + structValue: { + fields: {}, + }, + }, + namespace: { + stringValue: '', + }, + outputs: { + structValue: { + fields: {}, + }, + }, + parent_dag_id: { + intValue: 287, + }, + pod_name: { + stringValue: '', + }, + pod_uid: { + stringValue: '', + }, + task_name: { + stringValue: 'digit-classification', + }, + }, + }, + ], +}; + +export const mockGetExecutionsByID = (): GrpcResponse => { + const binary = GetExecutionsResponse.encode(mockedGetExecutionsByID).finish(); + return createGrpcResponse(binary); +}; diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/executions.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/executions.ts new file mode 100644 index 0000000000..69c486076a --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/executions.ts @@ -0,0 +1,50 @@ +import { FilterArgs } from '~/__tests__/cypress/cypress/tests/mocked/pipelines/executions.cy'; + +class ExecutionPage { + visit(namespace?: string) { + cy.visitWithLogin(`/executions${namespace ? `/${namespace}` : ''}`); + } +} + +class ExecutionFilter { + private testId = 'filter-toolbar'; + + find() { + return cy.findByTestId(this.testId); + } + + findEntriesPerPage() { + return cy.get('#table-pagination-top-toggle'); + } + + findNextPage() { + return cy.get('[aria-label="Go to next page"]').first(); + } + + findPreviousPage() { + return cy.get('[aria-label="Go to previous page"]').first(); + } + + findFilterDropdown() { + return this.find().findByTestId('filter-toolbar-dropdown'); + } + + typeSearchFilter(query: string) { + return this.find().findByTestId('filter-toolbar-text-field').type(query); + } + + clearSearchFilter() { + return this.find().findByTestId('filter-toolbar-text-field').type('{selectAll}{backspace}'); + } + + findSearchFilterItem(item: FilterArgs) { + return this.findFilterDropdown().findDropdownItem(item).click(); + } + + findTypeSearchFilterItem(item: string) { + return this.find().findByTestId('filter-toolbar-text-field').findDropdownItem(item).click(); + } +} + +export const executionPage = new ExecutionPage(); +export const executionFilter = new ExecutionFilter(); diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts index 371b1513fe..53047f652a 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts @@ -485,6 +485,16 @@ declare global { options: { path: { namespace: string; serviceName: string } }, response: OdhResponse, ) => Cypress.Chainable) & + (( + type: `POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetExecutions`, + options: { path: { namespace: string; serviceName: string } }, + response: OdhResponse, + ) => Cypress.Chainable) & + (( + type: `POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetExecutionsByID`, + options: { path: { namespace: string; serviceName: string } }, + response: OdhResponse, + ) => Cypress.Chainable) & (( type: `POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetEventsByExecutionIDs`, options: { path: { namespace: string; serviceName: string } }, diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/executions.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/executions.cy.ts new file mode 100644 index 0000000000..c09425f4f6 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/executions.cy.ts @@ -0,0 +1,184 @@ +/* eslint-disable camelcase */ +import { + buildMockPipelineV2, + buildMockPipelines, + mockDashboardConfig, + mockDataSciencePipelineApplicationK8sResource, + mockK8sResourceList, + mockProjectK8sResource, + mockRouteK8sResource, +} from '~/__mocks__'; +import { + DataSciencePipelineApplicationModel, + RouteModel, + ProjectModel, +} from '~/__tests__/cypress/cypress/utils/models'; +import { + executionPage, + executionFilter, +} from '~/__tests__/cypress/cypress/pages/pipelines/executions'; +import { mockGetExecutions, mockGetNextPageExecutions } from '~/__mocks__/mlmd/mockGetExecutions'; +import { initMlmdIntercepts } from './mlmdUtils'; + +const projectName = 'test-project-name'; +const initialMockPipeline = buildMockPipelineV2({ display_name: 'Test pipeline' }); + +describe('ExecutionsError', () => { + it('Fails to load executions list', () => { + initIntercepts(false); + executionPage.visit(projectName); + cy.contains('There was an issue loading executions'); + }); +}); + +describe('No Executions', () => { + it('Has no executions', () => { + initIntercepts(true, true); + executionPage.visit(projectName); + cy.contains('No executions'); + }); +}); + +describe('Executions', () => { + beforeEach(() => { + initIntercepts(true); + executionPage.visit(projectName); + }); + + it('Makes filter request', () => { + shouldFilterItems(FilterArgs.Execution, 'h'); + shouldFilterItems(FilterArgs.ID, '2'); + shouldFilterItems(FilterArgs.Status); + shouldFilterItems(FilterArgs.Type); + }); + + it('Makes requests to include more entries', () => { + executionFilter.findEntriesPerPage().click(); + setUpIntercept(); + cy.findByText('20 per page').click(); + cy.wait('@request'); + executionFilter.findEntriesPerPage().click(); + setUpIntercept(); + cy.findByText('30 per page').click(); + cy.wait('@request'); + }); + + it('Visits execution details page', () => { + cy.get('a[href="/executions/test-project-name/288"]').click(); + cy.contains('288'); + cy.contains('system.ContainerExecution'); + }); + + it('Navigates to next page and previous page', () => { + executionFilter.findNextPage().as('nextpage'); + setUpInterceptForNextPage(); + cy.get('@nextpage').click(); + cy.wait('@request'); + executionFilter.findPreviousPage().as('prevpage'); + setUpIntercept(); + cy.get('@prevpage').first().click(); + cy.wait('@request'); + }); +}); + +export enum FilterArgs { + Execution = 'Execution', + ID = 'ID', + Type = 'Type', + Status = 'Status', +} + +const setUpIntercept = () => { + cy.interceptOdh( + 'POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetExecutions', + { path: { namespace: projectName, serviceName: 'dspa' } }, + mockGetExecutions(), + ).as('request'); +}; + +const setUpInterceptForNextPage = () => { + cy.interceptOdh( + 'POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetExecutions', + { path: { namespace: projectName, serviceName: 'dspa' } }, + mockGetNextPageExecutions(), + ).as('request'); +}; + +const shouldFilterItems = (filter: FilterArgs, query?: string) => { + switch (filter) { + case FilterArgs.Execution: + executionFilter.findSearchFilterItem(FilterArgs.Execution); + if (typeof query === 'undefined') { + throw new Error('Incorrect usage of shouldFilterItems'); + } + setUpIntercept(); + executionFilter.typeSearchFilter(query); + cy.wait('@request'); + executionFilter.clearSearchFilter(); + break; + case FilterArgs.ID: + executionFilter.findSearchFilterItem(FilterArgs.ID); + if (typeof query === 'undefined') { + throw new Error('Incorrect usage of shouldFilterItems'); + } + setUpIntercept(); + executionFilter.typeSearchFilter(query); + cy.wait('@request'); + executionFilter.clearSearchFilter(); + break; + case FilterArgs.Type: + executionFilter.findSearchFilterItem(FilterArgs.Type); + setUpIntercept(); + executionFilter.findTypeSearchFilterItem('system.ContainerExecution'); + cy.wait('@request'); + break; + case FilterArgs.Status: + executionFilter.findSearchFilterItem(FilterArgs.Status); + setUpIntercept(); + executionFilter.findTypeSearchFilterItem('Cached'); + cy.wait('@request'); + break; + } +}; + +const initIntercepts = (interceptMlmd: boolean, isExecutionsEmpty?: boolean) => { + cy.interceptOdh('GET /api/config', mockDashboardConfig({ disablePipelineExperiments: false })); + cy.interceptK8sList( + DataSciencePipelineApplicationModel, + mockK8sResourceList([ + mockDataSciencePipelineApplicationK8sResource({ namespace: projectName }), + ]), + ); + cy.interceptK8s( + DataSciencePipelineApplicationModel, + mockDataSciencePipelineApplicationK8sResource({ namespace: projectName }), + ); + cy.interceptK8s( + RouteModel, + mockRouteK8sResource({ + notebookName: 'ds-pipeline-dspa', + namespace: projectName, + }), + ); + cy.interceptK8sList( + ProjectModel, + mockK8sResourceList([ + mockProjectK8sResource({ k8sName: projectName, displayName: projectName }), + ]), + ); + cy.interceptOdh( + 'GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines', + { + path: { namespace: projectName, serviceName: 'dspa' }, + }, + buildMockPipelines([initialMockPipeline]), + ); + + if (interceptMlmd) { + if (isExecutionsEmpty) { + initMlmdIntercepts(projectName, true); + } else { + initMlmdIntercepts(projectName, false); + } + } +}; diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/mlmdUtils.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/mlmdUtils.ts index d11522dee4..1958646cd1 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/mlmdUtils.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/mlmdUtils.ts @@ -2,9 +2,11 @@ import { mockGetArtifactTypes } from '~/__mocks__/mlmd/mockGetArtifactTypes'; import { mockGetArtifactsByContext } from '~/__mocks__/mlmd/mockGetArtifactsByContext'; import { mockGetContextByTypeAndName } from '~/__mocks__/mlmd/mockGetContextByTypeAndName'; import { mockGetEventsByExecutionIDs } from '~/__mocks__/mlmd/mockGetEventsByExecutionIDs'; +import { mockGetExecutions, mockGetNoExecutions } from '~/__mocks__/mlmd/mockGetExecutions'; import { mockGetExecutionsByContext } from '~/__mocks__/mlmd/mockGetExecutionsByContext'; +import { mockGetExecutionsByID } from '~/__mocks__/mlmd/mockGetExecutionsByID'; -export const initMlmdIntercepts = (projectName: string): void => { +export const initMlmdIntercepts = (projectName: string, isExecutionsEmpty?: boolean): void => { cy.interceptOdh( 'POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetArtifactTypes', { path: { namespace: projectName, serviceName: 'dspa' } }, @@ -25,6 +27,16 @@ export const initMlmdIntercepts = (projectName: string): void => { { path: { namespace: projectName, serviceName: 'dspa' } }, mockGetExecutionsByContext(), ); + cy.interceptOdh( + 'POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetExecutions', + { path: { namespace: projectName, serviceName: 'dspa' } }, + isExecutionsEmpty ? mockGetNoExecutions() : mockGetExecutions(), + ); + cy.interceptOdh( + 'POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetExecutionsByID', + { path: { namespace: projectName, serviceName: 'dspa' } }, + mockGetExecutionsByID(), + ); cy.interceptOdh( 'POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetEventsByExecutionIDs', { path: { namespace: projectName, serviceName: 'dspa' } },