diff options
Diffstat (limited to 'spec/frontend/ml')
9 files changed, 435 insertions, 158 deletions
diff --git a/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js b/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js index 66a447e73d3..07d8b4b8b3d 100644 --- a/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js +++ b/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js @@ -1,4 +1,4 @@ -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlButton } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { IndexMlModels } from '~/ml/model_registry/apps'; import ModelRow from '~/ml/model_registry/components/model_row.vue'; @@ -8,13 +8,22 @@ import { BASE_SORT_FIELDS, MODEL_ENTITIES } from '~/ml/model_registry/constants' import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import EmptyState from '~/ml/model_registry/components/empty_state.vue'; +import ActionsDropdown from '~/ml/model_registry/components/actions_dropdown.vue'; import { mockModels, startCursor, defaultPageInfo } from '../mock_data'; let wrapper; -const createWrapper = ( - propsData = { models: mockModels, pageInfo: defaultPageInfo, modelCount: 2 }, -) => { - wrapper = shallowMountExtended(IndexMlModels, { propsData }); + +const createWrapper = (propsData = {}) => { + wrapper = shallowMountExtended(IndexMlModels, { + propsData: { + models: mockModels, + pageInfo: defaultPageInfo, + modelCount: 2, + createModelPath: 'path/to/create', + canWriteModelRegistry: false, + ...propsData, + }, + }); }; const findModelRow = (index) => wrapper.findAllComponents(ModelRow).at(index); @@ -24,8 +33,10 @@ const findSearchBar = () => wrapper.findComponent(SearchBar); const findTitleArea = () => wrapper.findComponent(TitleArea); const findModelCountMetadataItem = () => findTitleArea().findComponent(MetadataItem); const findBadge = () => wrapper.findComponent(GlBadge); +const findCreateButton = () => findTitleArea().findComponent(GlButton); +const findActionsDropdown = () => wrapper.findComponent(ActionsDropdown); -describe('MlModelsIndex', () => { +describe('ml/model_registry/apps/index_ml_models', () => { describe('empty state', () => { beforeEach(() => createWrapper({ models: [], pageInfo: defaultPageInfo })); @@ -40,6 +51,28 @@ describe('MlModelsIndex', () => { it('does not show search bar', () => { expect(findSearchBar().exists()).toBe(false); }); + + it('renders the extra actions button', () => { + expect(findActionsDropdown().exists()).toBe(true); + }); + }); + + describe('create button', () => { + describe('when user has no permission to write model registry', () => { + it('does not display create button', () => { + createWrapper(); + + expect(findCreateButton().exists()).toBe(false); + }); + }); + + describe('when user has permission to write model registry', () => { + it('displays create button', () => { + createWrapper({ canWriteModelRegistry: true }); + + expect(findCreateButton().attributes().href).toBe('path/to/create'); + }); + }); }); describe('with data', () => { diff --git a/spec/frontend/ml/model_registry/apps/new_ml_model_spec.js b/spec/frontend/ml/model_registry/apps/new_ml_model_spec.js new file mode 100644 index 00000000000..204c021c080 --- /dev/null +++ b/spec/frontend/ml/model_registry/apps/new_ml_model_spec.js @@ -0,0 +1,119 @@ +import { + GlAlert, + GlButton, + GlFormInput, + GlFormTextarea, + GlForm, + GlSprintf, + GlLink, +} from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import NewMlModel from '~/ml/model_registry/apps/new_ml_model.vue'; +import createModelMutation from '~/ml/model_registry/graphql/mutations/create_model.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { visitUrl } from '~/lib/utils/url_utility'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createModelResponses } from '../graphql_mock_data'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); + +describe('ml/model_registry/apps/new_ml_model.vue', () => { + let wrapper; + let apolloProvider; + + Vue.use(VueApollo); + + beforeEach(() => { + jest.spyOn(Sentry, 'captureException').mockImplementation(); + }); + + const mountComponent = (resolver = jest.fn().mockResolvedValue(createModelResponses.success)) => { + const requestHandlers = [[createModelMutation, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(NewMlModel, { + apolloProvider, + propsData: { projectPath: 'project/path' }, + stubs: { GlSprintf }, + }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + const findInput = () => wrapper.findComponent(GlFormInput); + const findTextarea = () => wrapper.findComponent(GlFormTextarea); + const findForm = () => wrapper.findComponent(GlForm); + const findDocAlert = () => wrapper.findComponent(GlAlert); + const findDocLink = () => findDocAlert().findComponent(GlLink); + const findErrorAlert = () => wrapper.findByTestId('new-model-errors'); + + const submitForm = async () => { + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await waitForPromises(); + }; + + it('renders the button', () => { + mountComponent(); + + expect(findButton().text()).toBe('Create model'); + }); + + it('shows link to docs', () => { + mountComponent(); + + expect(findDocAlert().text()).toBe( + 'Creating models is also possible through the MLflow client. Follow the documentation to learn more.', + ); + expect(findDocLink().attributes().href).toBe('/help/user/project/ml/model_registry/index.md'); + }); + + it('submits the query with correct parameters', async () => { + const resolver = jest.fn().mockResolvedValue(createModelResponses.success); + mountComponent(resolver); + + findInput().vm.$emit('input', 'model_name'); + findTextarea().vm.$emit('input', 'A description'); + + await submitForm(); + + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ + projectPath: 'project/path', + name: 'model_name', + description: 'A description', + }), + ); + }); + + it('navigates to the new page when result is successful', async () => { + mountComponent(); + + await submitForm(); + + expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1'); + }); + + it('shows errors when result is a top level error', async () => { + const error = new Error('Failure!'); + mountComponent(jest.fn().mockRejectedValue({ error })); + + await submitForm(); + + expect(findErrorAlert().text()).toBe('An error has occurred when saving the model.'); + expect(visitUrl).not.toHaveBeenCalled(); + }); + + it('shows errors when result is a validation error', async () => { + mountComponent(jest.fn().mockResolvedValue(createModelResponses.validationFailure)); + + await submitForm(); + + expect(findErrorAlert().text()).toBe("Name is invalid, Name can't be blank"); + expect(visitUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js b/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js index 1fe0f5f88b3..7e991687496 100644 --- a/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js +++ b/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js @@ -1,7 +1,7 @@ import { GlBadge, GlTab } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { ShowMlModel } from '~/ml/model_registry/apps'; import ModelVersionList from '~/ml/model_registry/components/model_version_list.vue'; import CandidateList from '~/ml/model_registry/components/candidate_list.vue'; @@ -19,7 +19,7 @@ let wrapper; Vue.use(VueApollo); const createWrapper = (model = MODEL) => { - wrapper = shallowMount(ShowMlModel, { + wrapper = shallowMountExtended(ShowMlModel, { apolloProvider, propsData: { model }, stubs: { GlTab }, @@ -37,6 +37,7 @@ const findCandidatesCountBadge = () => findCandidateTab().findComponent(GlBadge) const findTitleArea = () => wrapper.findComponent(TitleArea); const findEmptyState = () => wrapper.findComponent(EmptyState); const findVersionCountMetadataItem = () => findTitleArea().findComponent(MetadataItem); +const findVersionLink = () => wrapper.findByTestId('model-version-link'); describe('ShowMlModel', () => { describe('Title', () => { @@ -67,8 +68,10 @@ describe('ShowMlModel', () => { expect(findModelVersionDetail().props('modelVersion')).toBe(MODEL.latestVersion); }); - it('displays the title', () => { - expect(findDetailTab().text()).toContain('Latest version: 1.2.3'); + it('displays a link to latest version', () => { + expect(findDetailTab().text()).toContain('Latest version:'); + expect(findVersionLink().attributes('href')).toBe(MODEL.latestVersion.path); + expect(findVersionLink().text()).toBe('1.2.3'); }); }); diff --git a/spec/frontend/ml/model_registry/components/actions_dropdown_spec.js b/spec/frontend/ml/model_registry/components/actions_dropdown_spec.js new file mode 100644 index 00000000000..6285d7360c7 --- /dev/null +++ b/spec/frontend/ml/model_registry/components/actions_dropdown_spec.js @@ -0,0 +1,39 @@ +import { mount } from '@vue/test-utils'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; +import ActionsDropdown from '~/ml/model_registry/components/actions_dropdown.vue'; + +describe('ml/model_registry/components/actions_dropdown', () => { + let wrapper; + + const showToast = jest.fn(); + + const createWrapper = () => { + wrapper = mount(ActionsDropdown, { + mocks: { + $toast: { + show: showToast, + }, + }, + provide: { + mlflowTrackingUrl: 'path/to/mlflow', + }, + }); + }; + + const findCopyLinkDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); + + it('has data-clipboard-text set to the correct url', () => { + createWrapper(); + + expect(findCopyLinkDropdownItem().text()).toBe('Copy MLflow tracking URL'); + expect(findCopyLinkDropdownItem().attributes()['data-clipboard-text']).toBe('path/to/mlflow'); + }); + + it('shows a success toast after copying the url to the clipboard', () => { + createWrapper(); + + findCopyLinkDropdownItem().find('button').trigger('click'); + + expect(showToast).toHaveBeenCalledWith('Copied MLflow tracking URL to clipboard'); + }); +}); diff --git a/spec/frontend/ml/model_registry/components/candidate_list_spec.js b/spec/frontend/ml/model_registry/components/candidate_list_spec.js index c10222a99fd..8491c7be16f 100644 --- a/spec/frontend/ml/model_registry/components/candidate_list_spec.js +++ b/spec/frontend/ml/model_registry/components/candidate_list_spec.js @@ -1,13 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlAlert } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CandidateList from '~/ml/model_registry/components/candidate_list.vue'; -import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; -import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import SearchableList from '~/ml/model_registry/components/searchable_list.vue'; import CandidateListRow from '~/ml/model_registry/components/candidate_list_row.vue'; import getModelCandidatesQuery from '~/ml/model_registry/graphql/queries/get_model_candidates.query.graphql'; import { GRAPHQL_PAGE_SIZE } from '~/ml/model_registry/constants'; @@ -24,10 +22,7 @@ describe('ml/model_registry/components/candidate_list.vue', () => { let wrapper; let apolloProvider; - const findAlert = () => wrapper.findComponent(GlAlert); - const findLoader = () => wrapper.findComponent(PackagesListLoader); - const findRegistryList = () => wrapper.findComponent(RegistryList); - const findListRow = () => wrapper.findComponent(CandidateListRow); + const findSearchableList = () => wrapper.findComponent(SearchableList); const findAllRows = () => wrapper.findAllComponents(CandidateListRow); const mountComponent = ({ @@ -37,15 +32,12 @@ describe('ml/model_registry/components/candidate_list.vue', () => { const requestHandlers = [[getModelCandidatesQuery, resolver]]; apolloProvider = createMockApollo(requestHandlers); - wrapper = shallowMount(CandidateList, { + wrapper = mount(CandidateList, { apolloProvider, propsData: { modelId: 2, ...props, }, - stubs: { - RegistryList, - }, }); }; @@ -60,25 +52,9 @@ describe('ml/model_registry/components/candidate_list.vue', () => { await waitForPromises(); }); - it('displays empty slot message', () => { + it('shows empty state', () => { expect(wrapper.text()).toContain('This model has no candidates'); }); - - it('does not display loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('does not display rows', () => { - expect(findListRow().exists()).toBe(false); - }); - - it('does not display registry list', () => { - expect(findRegistryList().exists()).toBe(false); - }); - - it('does not display alert', () => { - expect(findAlert().exists()).toBe(false); - }); }); describe('if load fails, alert', () => { @@ -90,19 +66,9 @@ describe('ml/model_registry/components/candidate_list.vue', () => { }); it('is displayed', () => { - expect(findAlert().exists()).toBe(true); - }); - - it('shows error message', () => { - expect(findAlert().text()).toContain('Failed to load model candidates with error: Failure!'); - }); - - it('is not dismissible', () => { - expect(findAlert().props('dismissible')).toBe(false); - }); - - it('is of variant danger', () => { - expect(findAlert().attributes('variant')).toBe('danger'); + expect(findSearchableList().props('errorMessage')).toBe( + 'Failed to load model candidates with error: Failure!', + ); }); it('error is logged in sentry', () => { @@ -116,21 +82,11 @@ describe('ml/model_registry/components/candidate_list.vue', () => { await waitForPromises(); }); - it('displays package registry list', () => { - expect(findRegistryList().exists()).toEqual(true); + it('Passes items to list', () => { + expect(findSearchableList().props('items')).toEqual(graphqlCandidates); }); - it('binds the right props', () => { - expect(findRegistryList().props()).toMatchObject({ - items: graphqlCandidates, - pagination: {}, - isLoading: false, - hiddenDelete: true, - }); - }); - - it('displays candidate rows', () => { - expect(findAllRows().exists()).toEqual(true); + it('displays package version rows', () => { expect(findAllRows()).toHaveLength(graphqlCandidates.length); }); @@ -143,17 +99,9 @@ describe('ml/model_registry/components/candidate_list.vue', () => { candidate: expect.objectContaining(graphqlCandidates[1]), }); }); - - it('does not display loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('does not display empty message', () => { - expect(findAlert().exists()).toBe(false); - }); }); - describe('when user interacts with pagination', () => { + describe('when list requests update', () => { const resolver = jest.fn().mockResolvedValue(modelCandidatesQuery()); beforeEach(async () => { @@ -161,21 +109,17 @@ describe('ml/model_registry/components/candidate_list.vue', () => { await waitForPromises(); }); - it('when list emits next-page fetches the next set of records', async () => { - findRegistryList().vm.$emit('next-page'); - await waitForPromises(); - - expect(resolver).toHaveBeenLastCalledWith( - expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }), - ); - }); + it('when list emits fetch-page fetches the next set of records', async () => { + findSearchableList().vm.$emit('fetch-page', { + after: 'eyJpZCI6IjIifQ', + first: 30, + id: 'gid://gitlab/Ml::Model/2', + }); - it('when list emits prev-page fetches the prev set of records', async () => { - findRegistryList().vm.$emit('prev-page'); await waitForPromises(); expect(resolver).toHaveBeenLastCalledWith( - expect.objectContaining({ before: graphqlPageInfo.startCursor, last: GRAPHQL_PAGE_SIZE }), + expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }), ); }); }); diff --git a/spec/frontend/ml/model_registry/components/model_version_list_spec.js b/spec/frontend/ml/model_registry/components/model_version_list_spec.js index 41f7e71c543..f5d6acf3bae 100644 --- a/spec/frontend/ml/model_registry/components/model_version_list_spec.js +++ b/spec/frontend/ml/model_registry/components/model_version_list_spec.js @@ -1,13 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlAlert } from '@gitlab/ui'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import ModelVersionList from '~/ml/model_registry/components/model_version_list.vue'; -import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; -import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import SearchableList from '~/ml/model_registry/components/searchable_list.vue'; import ModelVersionRow from '~/ml/model_registry/components/model_version_row.vue'; import getModelVersionsQuery from '~/ml/model_registry/graphql/queries/get_model_versions.query.graphql'; import EmptyState from '~/ml/model_registry/components/empty_state.vue'; @@ -25,11 +23,8 @@ describe('ModelVersionList', () => { let wrapper; let apolloProvider; - const findAlert = () => wrapper.findComponent(GlAlert); - const findLoader = () => wrapper.findComponent(PackagesListLoader); - const findRegistryList = () => wrapper.findComponent(RegistryList); + const findSearchableList = () => wrapper.findComponent(SearchableList); const findEmptyState = () => wrapper.findComponent(EmptyState); - const findListRow = () => wrapper.findComponent(ModelVersionRow); const findAllRows = () => wrapper.findAllComponents(ModelVersionRow); const mountComponent = ({ @@ -39,15 +34,12 @@ describe('ModelVersionList', () => { const requestHandlers = [[getModelVersionsQuery, resolver]]; apolloProvider = createMockApollo(requestHandlers); - wrapper = shallowMountExtended(ModelVersionList, { + wrapper = mountExtended(ModelVersionList, { apolloProvider, propsData: { modelId: 2, ...props, }, - stubs: { - RegistryList, - }, }); }; @@ -65,22 +57,6 @@ describe('ModelVersionList', () => { it('shows empty state', () => { expect(findEmptyState().props('entityType')).toBe(MODEL_ENTITIES.modelVersion); }); - - it('does not display loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('does not display rows', () => { - expect(findListRow().exists()).toBe(false); - }); - - it('does not display registry list', () => { - expect(findRegistryList().exists()).toBe(false); - }); - - it('does not display alert', () => { - expect(findAlert().exists()).toBe(false); - }); }); describe('if load fails, alert', () => { @@ -92,19 +68,9 @@ describe('ModelVersionList', () => { }); it('is displayed', () => { - expect(findAlert().exists()).toBe(true); - }); - - it('shows error message', () => { - expect(findAlert().text()).toContain('Failed to load model versions with error: Failure!'); - }); - - it('is not dismissible', () => { - expect(findAlert().props('dismissible')).toBe(false); - }); - - it('is of variant danger', () => { - expect(findAlert().attributes('variant')).toBe('danger'); + expect(findSearchableList().props('errorMessage')).toBe( + 'Failed to load model versions with error: Failure!', + ); }); it('error is logged in sentry', () => { @@ -118,21 +84,11 @@ describe('ModelVersionList', () => { await waitForPromises(); }); - it('displays package registry list', () => { - expect(findRegistryList().exists()).toEqual(true); - }); - - it('binds the right props', () => { - expect(findRegistryList().props()).toMatchObject({ - items: graphqlModelVersions, - pagination: {}, - isLoading: false, - hiddenDelete: true, - }); + it('Passes items to list', () => { + expect(findSearchableList().props('items')).toEqual(graphqlModelVersions); }); it('displays package version rows', () => { - expect(findAllRows().exists()).toEqual(true); expect(findAllRows()).toHaveLength(graphqlModelVersions.length); }); @@ -145,17 +101,9 @@ describe('ModelVersionList', () => { modelVersion: expect.objectContaining(graphqlModelVersions[1]), }); }); - - it('does not display loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('does not display empty state', () => { - expect(findEmptyState().exists()).toBe(false); - }); }); - describe('when user interacts with pagination', () => { + describe('when list requests update', () => { const resolver = jest.fn().mockResolvedValue(modelVersionsQuery()); beforeEach(async () => { @@ -163,21 +111,17 @@ describe('ModelVersionList', () => { await waitForPromises(); }); - it('when list emits next-page fetches the next set of records', async () => { - findRegistryList().vm.$emit('next-page'); - await waitForPromises(); - - expect(resolver).toHaveBeenLastCalledWith( - expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }), - ); - }); + it('when list emits fetch-page fetches the next set of records', async () => { + findSearchableList().vm.$emit('fetch-page', { + after: 'eyJpZCI6IjIifQ', + first: 30, + id: 'gid://gitlab/Ml::Model/2', + }); - it('when list emits prev-page fetches the prev set of records', async () => { - findRegistryList().vm.$emit('prev-page'); await waitForPromises(); expect(resolver).toHaveBeenLastCalledWith( - expect.objectContaining({ before: graphqlPageInfo.startCursor, last: GRAPHQL_PAGE_SIZE }), + expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }), ); }); }); diff --git a/spec/frontend/ml/model_registry/components/searchable_list_spec.js b/spec/frontend/ml/model_registry/components/searchable_list_spec.js new file mode 100644 index 00000000000..ea58a9a830a --- /dev/null +++ b/spec/frontend/ml/model_registry/components/searchable_list_spec.js @@ -0,0 +1,170 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SearchableList from '~/ml/model_registry/components/searchable_list.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import { defaultPageInfo } from '../mock_data'; + +describe('ml/model_registry/components/searchable_list.vue', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoader = () => wrapper.findComponent(PackagesListLoader); + const findRegistryList = () => wrapper.findComponent(RegistryList); + const findEmptyState = () => wrapper.findByTestId('empty-state-slot'); + const findFirstRow = () => wrapper.findByTestId('element'); + const findRows = () => wrapper.findAllByTestId('element'); + + const defaultProps = { + items: ['a', 'b', 'c'], + pageInfo: defaultPageInfo, + isLoading: false, + errorMessage: '', + }; + + const mountComponent = (props = {}) => { + wrapper = shallowMountExtended(SearchableList, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + RegistryList, + }, + slots: { + 'empty-state': '<div data-testid="empty-state-slot">This is empty</div>', + item: '<div data-testid="element"></div>', + }, + }); + }; + + describe('when list is loaded and has no data', () => { + beforeEach(() => mountComponent({ items: [] })); + + it('shows empty state', () => { + expect(findEmptyState().text()).toBe('This is empty'); + }); + + it('does not display loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display rows', () => { + expect(findFirstRow().exists()).toBe(false); + }); + + it('does not display registry list', () => { + expect(findRegistryList().exists()).toBe(false); + }); + + it('does not display alert', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('if errorMessage', () => { + beforeEach(() => mountComponent({ errorMessage: 'Failure!' })); + + it('shows error message', () => { + expect(findAlert().text()).toContain('Failure!'); + }); + + it('is not dismissible', () => { + expect(findAlert().props('dismissible')).toBe(false); + }); + + it('is of variant danger', () => { + expect(findAlert().attributes('variant')).toBe('danger'); + }); + + it('hides loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('hides registry list', () => { + expect(findRegistryList().exists()).toBe(false); + }); + + it('hides empty state', () => { + expect(findEmptyState().exists()).toBe(false); + }); + }); + + describe('if loading', () => { + beforeEach(() => mountComponent({ isLoading: true })); + + it('shows loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('hides error message', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('hides registry list', () => { + expect(findRegistryList().exists()).toBe(false); + }); + + it('hides empty state', () => { + expect(findEmptyState().exists()).toBe(false); + }); + }); + + describe('when list is loaded with data', () => { + beforeEach(() => mountComponent()); + + it('displays package registry list', () => { + expect(findRegistryList().exists()).toEqual(true); + }); + + it('binds the right props', () => { + expect(findRegistryList().props()).toMatchObject({ + items: ['a', 'b', 'c'], + isLoading: false, + pagination: defaultPageInfo, + hiddenDelete: true, + }); + }); + + it('displays package version rows', () => { + expect(findRows().exists()).toEqual(true); + expect(findRows()).toHaveLength(3); + }); + + it('does not display loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display empty state', () => { + expect(findEmptyState().exists()).toBe(false); + }); + }); + + describe('when user interacts with pagination', () => { + beforeEach(() => mountComponent()); + + it('when list emits next-page emits fetchPage with correct pageInfo', () => { + findRegistryList().vm.$emit('next-page'); + + const expectedNewPageInfo = { + after: 'eyJpZCI6IjIifQ', + first: 30, + last: null, + }; + + expect(wrapper.emitted('fetch-page')).toEqual([[expectedNewPageInfo]]); + }); + + it('when list emits prev-page emits fetchPage with correct pageInfo', () => { + findRegistryList().vm.$emit('prev-page'); + + const expectedNewPageInfo = { + before: 'eyJpZCI6IjE2In0', + first: null, + last: 30, + }; + + expect(wrapper.emitted('fetch-page')).toEqual([[expectedNewPageInfo]]); + }); + }); +}); diff --git a/spec/frontend/ml/model_registry/graphql_mock_data.js b/spec/frontend/ml/model_registry/graphql_mock_data.js index 1c31ee4627f..27424fbf0df 100644 --- a/spec/frontend/ml/model_registry/graphql_mock_data.js +++ b/spec/frontend/ml/model_registry/graphql_mock_data.js @@ -114,3 +114,27 @@ export const emptyCandidateQuery = { }, }, }; + +export const createModelResponses = { + success: { + data: { + mlModelCreate: { + model: { + id: 'gid://gitlab/Ml::Model/1', + _links: { + showPath: '/some/project/-/ml/models/1', + }, + }, + errors: [], + }, + }, + }, + validationFailure: { + data: { + mlModelCreate: { + model: null, + errors: ['Name is invalid', "Name can't be blank"], + }, + }, + }, +}; diff --git a/spec/frontend/ml/model_registry/mock_data.js b/spec/frontend/ml/model_registry/mock_data.js index 4399df38990..d8bb6a8eedb 100644 --- a/spec/frontend/ml/model_registry/mock_data.js +++ b/spec/frontend/ml/model_registry/mock_data.js @@ -42,6 +42,7 @@ export const newCandidate = () => ({ const LATEST_VERSION = { version: '1.2.3', + path: 'path/to/modelversion', }; export const makeModel = ({ latestVersion } = { latestVersion: LATEST_VERSION }) => ({ |