gitlab-ce/spec/frontend/ml/model_registry/components/model_create_spec.js

545 lines
19 KiB
JavaScript

import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlModal } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { visitUrl } from '~/lib/utils/url_utility';
import ModelCreate from '~/ml/model_registry/components/model_create.vue';
import ImportArtifactZone from '~/ml/model_registry/components/import_artifact_zone.vue';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import { uploadModel } from '~/ml/model_registry/services/upload_model';
import createModelMutation from '~/ml/model_registry/graphql/mutations/create_model.mutation.graphql';
import createModelVersionMutation from '~/ml/model_registry/graphql/mutations/create_model_version.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { MODEL_CREATION_MODAL_ID } from '~/ml/model_registry/constants';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { createModelResponses, createModelVersionResponses } from '../graphql_mock_data';
Vue.use(VueApollo);
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
visitUrl: jest.fn(),
}));
jest.mock('~/ml/model_registry/services/upload_model', () => ({
uploadModel: jest.fn(),
}));
describe('ModelCreate', () => {
let wrapper;
let apolloProvider;
const file = { name: 'file.txt', size: 1024 };
beforeEach(() => {
jest.spyOn(Sentry, 'captureException').mockImplementation();
});
afterEach(() => {
apolloProvider = null;
});
const createWrapper = (
createModelResolver = jest.fn().mockResolvedValue(createModelResponses.success),
createModelVersionResolver = jest.fn().mockResolvedValue(createModelVersionResponses.success),
createModelVisible = false,
) => {
const requestHandlers = [
[createModelMutation, createModelResolver],
[createModelVersionMutation, createModelVersionResolver],
];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMountExtended(ModelCreate, {
propsData: {
createModelVisible,
},
provide: {
projectPath: 'some/project',
maxAllowedFileSize: 99999,
markdownPreviewPath: '/markdown-preview',
},
directives: {
GlModal: createMockDirective('gl-modal'),
},
apolloProvider,
stubs: {
ImportArtifactZone,
},
});
};
const findModalButton = () => wrapper.findByText('Create model');
const findNameInput = () => wrapper.findByTestId('nameId');
const findVersionInput = () => wrapper.findByTestId('versionId');
const findVersionGroup = () => wrapper.findByTestId('versionGroupId');
const findVersionDescriptionGroup = () => wrapper.findByTestId('versionDescriptionGroupId');
const findDescriptionGroup = () => wrapper.findByTestId('descriptionGroupId');
const findDescriptionInput = () => wrapper.findByTestId('descriptionId');
const findVersionDescriptionInput = () => wrapper.findByTestId('versionDescriptionId');
const findImportArtifactZone = () => wrapper.findComponent(ImportArtifactZone);
const zone = () => wrapper.findComponent(UploadDropzone);
const findGlModal = () => wrapper.findComponent(GlModal);
const findGlAlert = () => wrapper.findByTestId('modalCreateAlert');
const submitForm = async () => {
findGlModal().vm.$emit('primary', new Event('primary'));
await waitForPromises();
};
const findArtifactZoneLabel = () => wrapper.findByTestId('importArtifactZoneLabel');
const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
const findModelNameGroup = () => wrapper.findByTestId('nameGroupId');
describe('Initial state', () => {
describe('Modal closed', () => {
beforeEach(() => {
createWrapper();
});
it('does not show modal', () => {
expect(findGlModal().props('visible')).toBe(false);
});
it('renders the modal button', () => {
expect(findModalButton().text()).toBe('Create model');
expect(getBinding(findModalButton().element, 'gl-modal').value).toBe(
MODEL_CREATION_MODAL_ID,
);
});
});
describe('Markdown editor', () => {
it('should show markdown editor', () => {
createWrapper();
expect(findMarkdownEditor().props()).toMatchObject({
enableContentEditor: true,
formFieldProps: {
id: 'model-description',
name: 'model-description',
placeholder: 'Enter a model description',
},
markdownDocsPath: '/help/user/markdown',
renderMarkdownPath: '/markdown-preview',
uploadsPath: '',
restrictedToolBarItems: ['full-screen'],
});
});
});
describe('Modal open', () => {
beforeEach(() => {
createWrapper(
jest.fn().mockResolvedValue(createModelResponses.success),
jest.fn().mockResolvedValue(createModelVersionResponses.success),
true,
);
});
it('renders the name input', () => {
expect(findNameInput().exists()).toBe(true);
});
it('renders the model name group description', () => {
expect(findModelNameGroup().attributes('description')).toBe(
ModelCreate.modal.nameDescription,
);
});
it('renders the name label', () => {
expect(findModelNameGroup().attributes('label')).toBe(ModelCreate.modal.modelName);
});
it('renders the version input', () => {
expect(findVersionInput().exists()).toBe(true);
});
it('renders the version label', () => {
expect(findVersionGroup().attributes('label')).toBe('Version');
});
it('renders the version placeholder', () => {
expect(findVersionInput().attributes('placeholder')).toBe(
ModelCreate.modal.versionPlaceholder,
);
});
it('renders the version group', () => {
expect(findVersionGroup().attributes()).toMatchObject({
description: 'Example: 1.0.0',
optional: 'true',
optionaltext: '(Optional)',
label: 'Version',
});
});
it('renders the version description group', () => {
expect(findVersionDescriptionGroup().attributes()).toMatchObject({
optional: 'true',
optionaltext: '(Optional)',
label: 'Version description',
});
});
it('renders the description group', () => {
expect(findDescriptionGroup().attributes()).toMatchObject({
optionaltext: '(Optional)',
optional: 'true',
label: 'Model description',
});
});
it('renders the description input', () => {
expect(findDescriptionInput().exists()).toBe(true);
});
it('renders the description input text', () => {
expect(findVersionGroup().attributes('valid-feedback')).toBe(
ModelCreate.modal.validVersion,
);
});
it('renders the version description input', () => {
expect(findVersionDescriptionInput().exists()).toBe(true);
});
it('renders the import artifact zone input', () => {
expect(findImportArtifactZone().exists()).toBe(false);
});
it('does not displays the title of the artifacts uploader', () => {
expect(findArtifactZoneLabel().exists()).toBe(false);
});
it('displays the title of the artifacts uploader when a version is entered', async () => {
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', '1.0.0');
findVersionDescriptionInput().vm.$emit('input', 'My version description');
await Vue.nextTick();
expect(findArtifactZoneLabel().attributes('label')).toBe('Upload artifacts');
});
it('renders the import artifact zone input with version entered', async () => {
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', '1.0.0');
await waitForPromises();
expect(findImportArtifactZone().props()).toEqual({
path: null,
submitOnSelect: false,
value: { file: null, subfolder: '' },
});
});
it('renders the import modal', () => {
expect(findGlModal().props()).toMatchObject({
modalId: 'create-model-modal',
title: 'Create model, version & import artifacts',
size: 'lg',
});
});
it('renders the create button in the modal', () => {
expect(findGlModal().props('actionPrimary')).toEqual({
attributes: { variant: 'confirm', disabled: true },
text: 'Create',
});
});
it('renders the cancel button in the modal', () => {
expect(findGlModal().props('actionSecondary')).toEqual({
text: 'Cancel',
attributes: { variant: 'default' },
});
});
it('does not render the alert by default', () => {
expect(findGlAlert().exists()).toBe(false);
});
});
describe('It reacts to semantic version input', () => {
beforeEach(() => {
createWrapper();
});
it('renders the version input label for initial state', () => {
expect(findVersionGroup().attributes('state')).toBe('true');
expect(findGlModal().props('actionPrimary')).toEqual({
attributes: { variant: 'confirm', disabled: true },
text: 'Create',
});
});
it.each(['1.0', '1', 'abc', '1.abc', '1.0.0.0'])(
'renders the version input label for invalid state',
async (version) => {
findVersionInput().vm.$emit('input', version);
await nextTick();
expect(findVersionGroup().attributes()).not.toContain('state');
expect(findVersionGroup().attributes('invalid-feedback')).toBe(
ModelCreate.modal.versionInvalid,
);
expect(findVersionGroup().attributes('description')).toBe('');
expect(findGlModal().props('actionPrimary')).toEqual({
attributes: { variant: 'confirm', disabled: true },
text: 'Create',
});
},
);
it.each(['1.0.0', '0.0.0-b', '24.99.99-b99'])(
'renders the version input label for valid state',
async (version) => {
findVersionInput().vm.$emit('input', version);
await nextTick();
expect(findVersionGroup().attributes('state')).toBe('true');
expect(findVersionGroup().attributes('valid-feedback')).toBe(
ModelCreate.modal.versionValid,
);
expect(findVersionGroup().attributes('description')).toBe('');
expect(findGlModal().props('actionPrimary')).toEqual({
attributes: { variant: 'confirm', disabled: true },
text: 'Create',
});
},
);
it.each(['1.0.0', '0.0.0-b', '24.99.99-b99'])(
'renders the version input label for valid state',
async (version) => {
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', version);
await nextTick();
expect(findVersionGroup().attributes('state')).toBe('true');
expect(findGlModal().props('actionPrimary')).toEqual({
attributes: { variant: 'confirm', disabled: false },
text: 'Create',
});
},
);
it.each(['model name', ' modelname', 'modelname ', ' ', ''])(
'renders the modelnames as invalid',
async (name) => {
findNameInput().vm.$emit('input', name);
await nextTick();
expect(findModelNameGroup().attributes()).not.toContain('state');
},
);
it.each(['modelname', 'model-name', 'MODELname', 'model_name'])(
'renders the modelnames as invalid',
async (name) => {
findNameInput().vm.$emit('input', name);
await nextTick();
expect(findModelNameGroup().attributes('state')).toBe('true');
},
);
});
it('clicking on secondary button clears the form', async () => {
createWrapper();
await findNameInput().vm.$emit('input', 'my_model');
await findGlModal().vm.$emit('secondary');
expect(findVersionInput().attributes('value')).toBe(undefined);
});
});
describe('Successful flow with version', () => {
beforeEach(async () => {
createWrapper();
findNameInput().vm.$emit('input', 'gpt-alice-1');
findMarkdownEditor().vm.$emit('input', 'My model description');
findVersionInput().vm.$emit('input', '1.0.0');
findVersionDescriptionInput().vm.$emit('input', 'My version description');
await Vue.nextTick();
zone().vm.$emit('change', file);
jest.spyOn(apolloProvider.defaultClient, 'mutate');
await submitForm();
});
it('Makes a create model mutation upon confirm', () => {
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: createModelMutation,
variables: {
projectPath: 'some/project',
name: 'gpt-alice-1',
description: 'My model description',
},
}),
);
});
it('Makes a create model version mutation upon confirm', () => {
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: createModelVersionMutation,
variables: {
modelId: 'gid://gitlab/Ml::Model/1',
projectPath: 'some/project',
version: '1.0.0',
description: 'My version description',
},
}),
);
});
it('Uploads a file mutation upon confirm', () => {
expect(uploadModel).toHaveBeenCalledWith({
file,
importPath: '/api/v4/projects/1/packages/ml_models/1/files/',
subfolder: '',
maxAllowedFileSize: 99999,
onUploadProgress: expect.any(Function),
});
});
it('Visits the model versions page upon successful create mutation', async () => {
createWrapper();
await submitForm();
expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1/versions/1');
});
});
describe('Successful flow without version', () => {
beforeEach(async () => {
createWrapper();
findNameInput().vm.$emit('input', 'gpt-alice-1');
findDescriptionInput().vm.$emit('input', 'My model description');
jest.spyOn(apolloProvider.defaultClient, 'mutate');
await submitForm();
});
it('Visits the model page upon successful create mutation without a version', async () => {
createWrapper();
await submitForm();
expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1');
});
});
describe('Failed flow with version', () => {
beforeEach(async () => {
const failedCreateModelVersionResolver = jest
.fn()
.mockResolvedValue(createModelVersionResponses.failure);
createWrapper(undefined, failedCreateModelVersionResolver);
jest.spyOn(apolloProvider.defaultClient, 'mutate');
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', '1.0.0');
findVersionDescriptionInput().vm.$emit('input', 'My version description');
await Vue.nextTick();
zone().vm.$emit('change', file);
await submitForm();
});
it('Displays an alert upon failed model create mutation', () => {
expect(findGlAlert().text()).toBe('Version is invalid');
});
});
describe('Failed flow with version retried', () => {
beforeEach(async () => {
const failedCreateModelVersionResolver = jest
.fn()
.mockResolvedValueOnce(createModelVersionResponses.failure);
createWrapper(undefined, failedCreateModelVersionResolver);
jest.spyOn(apolloProvider.defaultClient, 'mutate');
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', '1.0.0');
findVersionDescriptionInput().vm.$emit('input', 'My retried version description');
await submitForm();
});
it('Displays an alert upon failed model create mutation', async () => {
expect(findGlAlert().text()).toBe('Version is invalid');
await submitForm();
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: createModelVersionMutation,
variables: {
modelId: 'gid://gitlab/Ml::Model/1',
projectPath: 'some/project',
version: '1.0.0',
description: 'My retried version description',
},
}),
);
});
});
describe('Failed flow with file upload retried', () => {
beforeEach(async () => {
createWrapper();
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', '1.0.0');
findDescriptionInput().vm.$emit('input', 'My model description');
findVersionDescriptionInput().vm.$emit('input', 'My version description');
await Vue.nextTick();
zone().vm.$emit('change', file);
uploadModel.mockRejectedValueOnce('Artifact import error.');
await submitForm();
});
it('Visits the model versions page upon successful create mutation', async () => {
expect(findGlAlert().text()).toBe('Artifact import error.');
await submitForm(); // retry submit
expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1/versions/1');
});
it('Uploads a file mutation upon confirm', async () => {
await submitForm(); // retry submit
expect(uploadModel).toHaveBeenCalledWith({
file,
importPath: '/api/v4/projects/1/packages/ml_models/1/files/',
subfolder: '',
maxAllowedFileSize: 99999,
onUploadProgress: expect.any(Function),
});
});
});
describe('Failed flow without version', () => {
describe('Mutation errors', () => {
beforeEach(async () => {
const failedCreateModelResolver = jest
.fn()
.mockResolvedValue(createModelResponses.validationFailure);
createWrapper(failedCreateModelResolver);
jest.spyOn(apolloProvider.defaultClient, 'mutate');
findNameInput().vm.$emit('input', 'gpt-alice-1');
await submitForm();
});
it('Displays an alert upon failed model create mutation', () => {
expect(findGlAlert().text()).toBe("Name is invalid, Name can't be blank");
});
it('Displays an alert upon an exception', () => {
expect(findGlAlert().text()).toBe("Name is invalid, Name can't be blank");
});
});
it('Logs to sentry upon an exception', async () => {
const error = new Error('Runtime error');
createWrapper();
jest.spyOn(apolloProvider.defaultClient, 'mutate').mockImplementation(() => {
throw error;
});
findNameInput().vm.$emit('input', 'gpt-alice-1');
await submitForm();
expect(Sentry.captureException).toHaveBeenCalledWith(error);
});
});
});