From 87bf1240a645f62bc7eb3c572773a143890e4585 Mon Sep 17 00:00:00 2001 From: Jeff Puzzo Date: Tue, 9 Jul 2024 13:30:41 -0400 Subject: [PATCH] [RHOAIENG-9048] Cypress tests for MLMD artifacts --- .../src/__mocks__/mlmd/mockGetArtifacts.ts | 345 ++++++++++++++++++ .../cypress/pages/pipelines/artifacts.ts | 120 ++++++ .../cypress/cypress/support/commands/odh.ts | 10 + .../tests/mocked/pipelines/artifacts.cy.ts | 161 ++++++++ .../ArtifactOverviewDetails.tsx | 4 +- .../ArtifactPropertyDescriptionList.tsx | 2 +- .../experiments/artifacts/ArtifactsTable.tsx | 14 +- 7 files changed, 649 insertions(+), 7 deletions(-) create mode 100644 frontend/src/__mocks__/mlmd/mockGetArtifacts.ts create mode 100644 frontend/src/__tests__/cypress/cypress/pages/pipelines/artifacts.ts create mode 100644 frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/artifacts.cy.ts diff --git a/frontend/src/__mocks__/mlmd/mockGetArtifacts.ts b/frontend/src/__mocks__/mlmd/mockGetArtifacts.ts new file mode 100644 index 0000000000..0926c4a387 --- /dev/null +++ b/frontend/src/__mocks__/mlmd/mockGetArtifacts.ts @@ -0,0 +1,345 @@ +/* eslint-disable camelcase */ +import { GetArtifactsResponse, GetArtifactsByIDResponse } from '~/__mocks__/third_party/mlmd'; +import createGrpcResponse, { GrpcResponse } from './utils'; + +export const mockedArtifactsResponse: GetArtifactsResponse = { + artifacts: [ + { + id: 1, + typeId: 14, + type: 'system.Metrics', + uri: 's3://scalar-metrics-uri', + properties: {}, + customProperties: { + accuracy: { doubleValue: 92 }, + display_name: { stringValue: 'scalar metrics' }, + }, + state: 2, + createTimeSinceEpoch: 1611399342384, + lastUpdateTimeSinceEpoch: 1611399342384, + }, + { + id: 2, + typeId: 16, + type: 'system.Dataset', + uri: 's3://dataset-uri', + properties: {}, + customProperties: { display_name: { stringValue: 'dataset' } }, + state: 2, + createTimeSinceEpoch: 1611399342384, + lastUpdateTimeSinceEpoch: 1611399342384, + }, + { + id: 3, + typeId: 15, + type: 'system.ClassificationMetrics', + uri: 's3://confidence-metrics-uri', + properties: {}, + customProperties: { + confidenceMetrics: { + structValue: { + fields: { + list: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + listValue: { + valuesList: [ + { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + structValue: { + fields: { + confidenceThreshold: { + nullValue: 0, + numberValue: 2, + stringValue: '', + boolValue: false, + }, + + falsePositiveRate: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + }, + + recall: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + }, + }, + }, + }, + { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + structValue: { + fields: { + confidenceThreshold: { + nullValue: 0, + numberValue: 1, + stringValue: '', + boolValue: false, + }, + + falsePositiveRate: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + }, + + recall: { + nullValue: 0, + numberValue: 0.33962264150943394, + stringValue: '', + boolValue: false, + }, + }, + }, + }, + { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + structValue: { + fields: { + confidenceThreshold: { + nullValue: 0, + numberValue: 0.9, + stringValue: '', + boolValue: false, + }, + falsePositiveRate: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + }, + recall: { + nullValue: 0, + numberValue: 0.6037735849056604, + stringValue: '', + boolValue: false, + }, + }, + }, + }, + { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + structValue: { + fields: { + confidenceThreshold: { + nullValue: 0, + numberValue: 0.8, + stringValue: '', + boolValue: false, + }, + falsePositiveRate: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + }, + recall: { + nullValue: 0, + numberValue: 0.8490566037735849, + stringValue: '', + boolValue: false, + }, + }, + }, + }, + ], + }, + }, + }, + }, + }, + display_name: { stringValue: 'confidence metrics' }, + }, + state: 2, + createTimeSinceEpoch: 1611399342384, + lastUpdateTimeSinceEpoch: 1611399342384, + }, + { + id: 4, + typeId: 15, + type: 'system.ClassificationMetrics', + uri: 's3://confusion-matrix-uri', + properties: {}, + customProperties: { + confusionMatrix: { + structValue: { + fields: { + struct: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + structValue: { + fields: { + annotationSpecs: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + listValue: { + valuesList: [ + { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + structValue: { + fields: { + displayName: { + nullValue: 0, + numberValue: 0, + stringValue: 'Setosa', + boolValue: false, + }, + }, + }, + }, + { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + structValue: { + fields: { + displayName: { + nullValue: 0, + numberValue: 0, + stringValue: 'Versicolour', + boolValue: false, + }, + }, + }, + }, + ], + }, + }, + rows: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + listValue: { + valuesList: [ + { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + structValue: { + fields: { + row: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + listValue: { + valuesList: [ + { + nullValue: 0, + numberValue: 37, + stringValue: '', + boolValue: false, + }, + { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + }, + { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + }, + ], + }, + }, + }, + }, + }, + { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + structValue: { + fields: { + row: { + nullValue: 0, + numberValue: 0, + stringValue: '', + boolValue: false, + listValue: { + valuesList: [ + { + nullValue: 0, + numberValue: 15, + stringValue: '', + boolValue: false, + }, + { + nullValue: 0, + numberValue: 7, + stringValue: '', + boolValue: false, + }, + { + nullValue: 0, + numberValue: 11, + stringValue: '', + boolValue: false, + }, + ], + }, + }, + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + display_name: { stringValue: 'confusion matrix' }, + }, + state: 2, + createTimeSinceEpoch: 1611399342384, + lastUpdateTimeSinceEpoch: 1611399342384, + }, + ], +}; + +export const mockGetArtifactsById = (response: GetArtifactsByIDResponse): GrpcResponse => { + const binary = GetArtifactsByIDResponse.encode(response).finish(); + return createGrpcResponse(binary); +}; + +export const mockGetArtifactsResponse = (response: GetArtifactsResponse): GrpcResponse => { + const binary = GetArtifactsResponse.encode(response).finish(); + return createGrpcResponse(binary); +}; diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/artifacts.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/artifacts.ts new file mode 100644 index 0000000000..7af3819d38 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/artifacts.ts @@ -0,0 +1,120 @@ +import type { GrpcResponse } from '~/__mocks__/mlmd/utils'; +import { Contextual } from '~/__tests__/cypress/cypress/pages/components/Contextual'; + +class ArtifactsGlobal { + visit(projectName: string) { + cy.visitWithLogin(`/artifacts/${projectName}`); + this.wait(); + } + + private wait() { + cy.findByTestId('app-page-title').contains('Artifacts'); + cy.testA11y(); + } + + findEmptyState() { + return cy.findByTestId('empty-state-title'); + } + + findTableToolbar() { + return cy.findByTestId('artifacts-table-toolbar'); + } + + selectFilterByName(name: string) { + cy.findByTestId('filter-toolbar-dropdown').findDropdownItem(name).click(); + } + + findFilterField() { + return cy.findByTestId('filter-toolbar-text-field'); + } + + findFilterFieldInput() { + return this.findFilterField().find('input'); + } + + selectFilterType(type: string) { + cy.findByTestId('artifact-type-filter-select').findByTestId(`dropdown-item ${type}`).click(); + } +} + +class ArtifactsTable { + find() { + return cy.findByTestId('artifacts-list-table'); + } + + findRows() { + return this.find().find('tbody tr'); + } + + getRowByName(name: string) { + return new ArtifactsTableRow(() => + this.find().find(`[data-label="Artifact"]`).contains(name).parents('tr'), + ); + } + + findEmptyState() { + return cy.findByTestId('artifacts-list-empty-state'); + } + + mockGetArtifacts(projectName: string, response: GrpcResponse, times?: number) { + return cy.interceptOdh( + 'POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetArtifacts', + { path: { namespace: projectName, serviceName: 'dspa' }, times }, + response, + ); + } +} + +class ArtifactsTableRow extends Contextual { + findName() { + return this.find().find(`[data-label=Artifact]`); + } + + findType() { + return this.find().find(`[data-label=Type]`); + } + + findId() { + return this.find().find(`[data-label=ID]`); + } + + findUri() { + return this.find().find(`[data-label=URI]`); + } + + findCreated() { + return this.find().find(`[data-label=Created]`); + } +} + +class ArtifactDetails { + visit(projectName: string, artifactName: string, artifactId: string) { + cy.visitWithLogin(`/artifacts/${projectName}/${artifactId}`); + this.wait(artifactName); + } + + private wait(pageTitle: string) { + cy.findByTestId('app-page-title').contains(pageTitle); + cy.testA11y(); + } + + findDatasetItemByLabel(label: string) { + return cy.findByTestId(`dataset-description-list-${label}`); + } + + findCustomPropItemByLabel(label: string) { + return cy.findByTestId(`custom-props-description-list-${label}`); + } + + mockGetArtifactById(projectName: string, response: GrpcResponse) { + return cy.interceptOdh( + 'POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetArtifactsByID', + { path: { namespace: projectName, serviceName: 'dspa' } }, + response, + ); + } +} + +export const artifactsGlobal = new ArtifactsGlobal(); +export const artifactsTable = new ArtifactsTable(); +export const artifactDetails = new ArtifactDetails(); diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts index e1637fb50c..ab6b7cbef6 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts @@ -470,6 +470,16 @@ declare global { options: { path: { namespace: string; serviceName: string } }, response: OdhResponse, ) => Cypress.Chainable) & + (( + type: `POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetArtifacts`, + options: { path: { namespace: string; serviceName: string }; times?: number }, + response: OdhResponse, + ) => Cypress.Chainable) & + (( + type: `POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetArtifactsByID`, + options: { path: { namespace: string; serviceName: string } }, + response: OdhResponse, + ) => Cypress.Chainable) & (( type: `POST /api/service/mlmd/:namespace/:serviceName/ml_metadata.MetadataStoreService/GetArtifactTypes`, options: { path: { namespace: string; serviceName: string } }, diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/artifacts.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/artifacts.cy.ts new file mode 100644 index 0000000000..309f174375 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/pipelines/artifacts.cy.ts @@ -0,0 +1,161 @@ +import { + artifactDetails, + artifactsGlobal, + artifactsTable, +} from '~/__tests__/cypress/cypress/pages/pipelines/artifacts'; +import { + mockGetArtifactsById, + mockGetArtifactsResponse, + mockedArtifactsResponse, +} from '~/__mocks__/mlmd/mockGetArtifacts'; +import { configIntercept, dspaIntercepts, projectsIntercept } from './intercepts'; + +const projectName = 'test-project-name'; + +describe('Artifacts', () => { + beforeEach(() => { + initIntercepts(); + }); + + describe('table', () => { + it('shows empty state', () => { + artifactsTable.mockGetArtifacts(projectName, mockGetArtifactsResponse({ artifacts: [] })); + artifactsGlobal.visit(projectName); + artifactsTable.findEmptyState().should('be.visible'); + }); + + it('renders row data', () => { + artifactsTable.mockGetArtifacts( + projectName, + mockGetArtifactsResponse(mockedArtifactsResponse), + ); + artifactsGlobal.visit(projectName); + + const scalarMetricsRow = artifactsTable.getRowByName('scalar metrics'); + scalarMetricsRow.findId().should('have.text', '1'); + scalarMetricsRow.findType().should('have.text', 'system.Metrics'); + scalarMetricsRow.findUri().should('have.text', 's3://scalar-metrics-uri'); + scalarMetricsRow.findCreated().should('have.text', '23 Jan 2021'); + + const datasetRow = artifactsTable.getRowByName('dataset'); + datasetRow.findId().should('have.text', '2'); + datasetRow.findType().should('have.text', 'system.Dataset'); + datasetRow.findUri().should('have.text', 's3://dataset-uri'); + datasetRow.findCreated().should('have.text', '23 Jan 2021'); + + const confidenceMetricsRow = artifactsTable.getRowByName('confidence metrics'); + confidenceMetricsRow.findId().should('have.text', '3'); + confidenceMetricsRow.findType().should('have.text', 'system.ClassificationMetrics'); + confidenceMetricsRow.findUri().should('have.text', 's3://confidence-metrics-uri'); + confidenceMetricsRow.findCreated().should('have.text', '23 Jan 2021'); + + const confusionMatrixRow = artifactsTable.getRowByName('confusion matrix'); + confusionMatrixRow.findId().should('have.text', '4'); + confusionMatrixRow.findType().should('have.text', 'system.ClassificationMetrics'); + confusionMatrixRow.findUri().should('have.text', 's3://confusion-matrix-uri'); + confusionMatrixRow.findCreated().should('have.text', '23 Jan 2021'); + }); + + it('navigates to details page on Artifact name click', () => { + artifactsGlobal.visit(projectName); + artifactsTable.mockGetArtifacts( + projectName, + mockGetArtifactsResponse(mockedArtifactsResponse), + ); + artifactsGlobal.visit(projectName); + artifactsTable.getRowByName('scalar metrics').findName().find('a').click(); + + cy.url().should('include', `/artifacts/${projectName}/1`); + }); + + describe('filters data by', () => { + beforeEach(() => { + artifactsTable.mockGetArtifacts( + projectName, + mockGetArtifactsResponse(mockedArtifactsResponse), + 3, + ); + artifactsGlobal.visit(projectName); + artifactsTable.findRows().should('have.length', 4); + }); + + it('name', () => { + artifactsGlobal.selectFilterByName('Artifact'); + artifactsTable.mockGetArtifacts( + projectName, + mockGetArtifactsResponse({ + artifacts: mockedArtifactsResponse.artifacts.filter((mockArtifact) => + mockArtifact.customProperties.display_name.stringValue?.includes('metrics'), + ), + }), + 1, + ); + artifactsGlobal.findFilterFieldInput().type('metrics'); + artifactsTable.findRows().should('have.length', 2); + artifactsTable.getRowByName('scalar metrics').find().should('be.visible'); + artifactsTable.getRowByName('confidence metrics').find().should('be.visible'); + }); + + it('ID', () => { + artifactsGlobal.selectFilterByName('ID'); + artifactsTable.mockGetArtifacts( + projectName, + mockGetArtifactsResponse({ + artifacts: mockedArtifactsResponse.artifacts.filter( + (mockArtifact) => mockArtifact.id === 4, + ), + }), + 1, + ); + artifactsGlobal.findFilterFieldInput().type('4'); + artifactsTable.findRows().should('have.length', 1); + artifactsTable.getRowByName('confusion matrix').find().should('be.visible'); + }); + + it('Type', () => { + artifactsGlobal.selectFilterByName('Type'); + artifactsTable.mockGetArtifacts( + projectName, + mockGetArtifactsResponse({ + artifacts: mockedArtifactsResponse.artifacts.filter( + (mockArtifact) => mockArtifact.type === 'system.Metrics', + ), + }), + 1, + ); + artifactsGlobal.findFilterField().click(); + artifactsGlobal.selectFilterType('system.Metrics'); + artifactsTable.findRows().should('have.length', 1); + artifactsTable.getRowByName('scalar metrics').find().should('be.visible'); + }); + }); + }); + + describe('details', () => { + it('shows Overview tab content', () => { + artifactDetails.mockGetArtifactById( + projectName, + mockGetArtifactsById({ + artifacts: [mockedArtifactsResponse.artifacts[0]], + artifactTypes: [], + }), + ); + artifactDetails.visit(projectName, 'metrics', '1'); + artifactDetails + .findDatasetItemByLabel('URI') + .next() + .should('include.text', 's3://scalar-metrics-uri'); + artifactDetails.findCustomPropItemByLabel('accuracy').next().should('have.text', '92'); + artifactDetails + .findCustomPropItemByLabel('display_name') + .next() + .should('have.text', 'scalar metrics'); + }); + }); +}); + +export const initIntercepts = (): void => { + configIntercept(); + dspaIntercepts(projectName); + projectsIntercept([{ k8sName: projectName, displayName: 'Test project' }]); +}; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactOverviewDetails.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactOverviewDetails.tsx index aa6f49ac22..fd0dddf6e5 100644 --- a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactOverviewDetails.tsx +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactOverviewDetails.tsx @@ -32,7 +32,9 @@ export const ArtifactOverviewDetails: React.FC = ( {artifact?.uri && ( <> - URI + + URI + diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactPropertyDescriptionList.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactPropertyDescriptionList.tsx index 04636a259e..305935cb88 100644 --- a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactPropertyDescriptionList.tsx +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactPropertyDescriptionList.tsx @@ -23,7 +23,7 @@ export const ArtifactPropertyDescriptionList: React.FC {propertiesMap.map(([propKey, propValues]) => ( - {propKey} + {propKey} diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx index 49ca53034f..11702d7893 100644 --- a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx @@ -107,6 +107,7 @@ export const ArtifactsTable: React.FC = ({ aria-label="Search artifact name" placeholder="Search..." onChange={(_event, value) => onChange(value)} + data-testid="artifact-name-filter-input" /> ), [FilterOptions.Id]: ({ onChange, ...props }) => ( @@ -117,6 +118,7 @@ export const ArtifactsTable: React.FC = ({ type="number" min={1} onChange={(_event, value) => onChange(value)} + data-testid="artifact-id-filter-input" /> ), [FilterOptions.Type]: ({ value, onChange, ...props }) => ( @@ -129,12 +131,14 @@ export const ArtifactsTable: React.FC = ({ label: v, }))} onChange={(v) => onChange(v)} + data-testid="artifact-type-filter-select" /> ), }} filterData={filterData} onClearFilters={onClearFilters} onFilterUpdate={onFilterUpdate} + data-testid="artifacts-table-toolbar" /> ), [filterData, onClearFilters, onFilterUpdate], @@ -143,17 +147,17 @@ export const ArtifactsTable: React.FC = ({ const rowRenderer = React.useCallback( (artifact: Artifact.AsObject) => ( - + {getArtifactName(artifact)} - {artifact.id} - {artifact.type} - + {artifact.id} + {artifact.type} + - +