diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 16:16:36 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 16:16:36 +0300 |
commit | 311b0269b4eb9839fa63f80c8d7a58f32b8138a0 (patch) | |
tree | 07e7870bca8aed6d61fdcc810731c50d2c40af47 /spec/frontend/packages_and_registries | |
parent | 27909cef6c4170ed9205afa7426b8d3de47cbb0c (diff) |
Add latest changes from gitlab-org/gitlab@14-5-stable-eev14.5.0-rc42
Diffstat (limited to 'spec/frontend/packages_and_registries')
51 files changed, 5024 insertions, 237 deletions
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap new file mode 100644 index 00000000000..7044c1285d8 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` +<div + class="gl-breadcrumbs" +> + + <ol + class="breadcrumb gl-breadcrumb-list" + > + <li + class="breadcrumb-item gl-breadcrumb-item" + > + <a + class="" + href="/" + target="_self" + > + <span> + + </span> + + <span + class="gl-breadcrumb-separator" + data-testid="separator" + > + <svg + aria-hidden="true" + class="gl-icon s8" + data-testid="angle-right-icon" + role="img" + > + <use + href="#angle-right" + /> + </svg> + </span> + </a> + </li> + <li + class="breadcrumb-item gl-breadcrumb-item" + > + <a + class="" + href="#" + target="_self" + > + <span> + + </span> + + <!----> + </a> + </li> + </ol> +</div> +`; + +exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` +<div + class="gl-breadcrumbs" +> + + <ol + class="breadcrumb gl-breadcrumb-list" + > + <li + class="breadcrumb-item gl-breadcrumb-item" + > + <a + class="" + href="/" + target="_self" + > + <span> + + </span> + + <!----> + </a> + </li> + </ol> +</div> +`; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js new file mode 100644 index 00000000000..6d7bf528495 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js @@ -0,0 +1,73 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import component from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue'; + +describe('delete_button', () => { + let wrapper; + + const defaultProps = { + title: 'Foo title', + tooltipTitle: 'Bar tooltipTitle', + }; + + const findButton = () => wrapper.find(GlButton); + + const mountComponent = (props) => { + wrapper = shallowMount(component, { + propsData: { + ...defaultProps, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('tooltip', () => { + it('the title is controlled by tooltipTitle prop', () => { + mountComponent(); + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip.value.title).toBe(defaultProps.tooltipTitle); + }); + + it('is disabled when tooltipTitle is disabled', () => { + mountComponent({ tooltipDisabled: true }); + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip.value.disabled).toBe(true); + }); + + describe('button', () => { + it('exists', () => { + mountComponent(); + expect(findButton().exists()).toBe(true); + }); + + it('has the correct props/attributes bound', () => { + mountComponent({ disabled: true }); + expect(findButton().attributes()).toMatchObject({ + 'aria-label': 'Foo title', + icon: 'remove', + title: 'Foo title', + variant: 'danger', + disabled: 'true', + category: 'secondary', + }); + }); + + it('emits a delete event', () => { + mountComponent(); + expect(wrapper.emitted('delete')).toEqual(undefined); + findButton().vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js new file mode 100644 index 00000000000..620c96e8c9e --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js @@ -0,0 +1,152 @@ +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import component from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; +import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/container_registry/explorer/constants/index'; +import deleteContainerRepositoryMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql'; +import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; + +describe('Delete Image', () => { + let wrapper; + const id = '1'; + const storeMock = { + readQuery: jest.fn().mockReturnValue({ + containerRepository: { + status: 'foo', + }, + }), + writeQuery: jest.fn(), + }; + + const updatePayload = { + data: { + destroyContainerRepository: { + containerRepository: { + status: 'baz', + }, + }, + }, + }; + + const findButton = () => wrapper.find('button'); + + const mountComponent = ({ + propsData = { id }, + mutate = jest.fn().mockResolvedValue({}), + } = {}) => { + wrapper = shallowMount(component, { + propsData, + mocks: { + $apollo: { + mutate, + }, + }, + scopedSlots: { + default: '<button @click="props.doDelete">test</button>', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('executes apollo mutate on doDelete', () => { + const mutate = jest.fn().mockResolvedValue({}); + mountComponent({ mutate }); + + wrapper.vm.doDelete(); + + expect(mutate).toHaveBeenCalledWith({ + mutation: deleteContainerRepositoryMutation, + variables: { + id, + }, + update: undefined, + }); + }); + + it('on success emits the correct events', async () => { + const mutate = jest.fn().mockResolvedValue({}); + mountComponent({ mutate }); + + wrapper.vm.doDelete(); + + await waitForPromises(); + + expect(wrapper.emitted('start')).toEqual([[]]); + expect(wrapper.emitted('success')).toEqual([[]]); + expect(wrapper.emitted('end')).toEqual([[]]); + }); + + it('when a payload contains an error emits an error event', async () => { + const mutate = jest + .fn() + .mockResolvedValue({ data: { destroyContainerRepository: { errors: ['foo'] } } }); + + mountComponent({ mutate }); + wrapper.vm.doDelete(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[['foo']]]); + }); + + it('when the api call errors emits an error event', async () => { + const mutate = jest.fn().mockRejectedValue('error'); + + mountComponent({ mutate }); + wrapper.vm.doDelete(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[['error']]]); + }); + + it('uses the update function, when the prop is set to true', () => { + const mutate = jest.fn().mockResolvedValue({}); + + mountComponent({ mutate, propsData: { id, useUpdateFn: true } }); + wrapper.vm.doDelete(); + + expect(mutate).toHaveBeenCalledWith({ + mutation: deleteContainerRepositoryMutation, + variables: { + id, + }, + update: wrapper.vm.updateImageStatus, + }); + }); + + it('updateImage status reads and write to the cache', () => { + mountComponent(); + + const variables = { + id, + first: GRAPHQL_PAGE_SIZE, + }; + + wrapper.vm.updateImageStatus(storeMock, updatePayload); + + expect(storeMock.readQuery).toHaveBeenCalledWith({ + query: getContainerRepositoryDetailsQuery, + variables, + }); + expect(storeMock.writeQuery).toHaveBeenCalledWith({ + query: getContainerRepositoryDetailsQuery, + variables, + data: { + containerRepository: { + status: updatePayload.data.destroyContainerRepository.containerRepository.status, + }, + }, + }); + }); + + it('binds the doDelete function to the default scoped slot', () => { + const mutate = jest.fn().mockResolvedValue({}); + mountComponent({ mutate }); + findButton().trigger('click'); + expect(mutate).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap new file mode 100644 index 00000000000..5f191ef5561 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TagsLoader component has the correct markup 1`] = ` +<div> + <div> + <rect + height="15" + rx="4" + width="15" + x="0" + y="12.5" + /> + + <rect + height="20" + rx="4" + width="250" + x="25" + y="10" + /> + + <circle + cx="290" + cy="20" + r="10" + /> + + <rect + height="20" + rx="4" + width="100" + x="315" + y="10" + /> + + <rect + height="20" + rx="4" + width="100" + x="500" + y="10" + /> + + <rect + height="20" + rx="4" + width="100" + x="630" + y="10" + /> + + <rect + height="40" + rx="4" + width="40" + x="960" + y="0" + /> + </div> +</div> +`; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js new file mode 100644 index 00000000000..e25162f4da5 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js @@ -0,0 +1,116 @@ +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue'; +import { + DELETE_TAG_SUCCESS_MESSAGE, + DELETE_TAG_ERROR_MESSAGE, + DELETE_TAGS_SUCCESS_MESSAGE, + DELETE_TAGS_ERROR_MESSAGE, + ADMIN_GARBAGE_COLLECTION_TIP, +} from '~/packages_and_registries/container_registry/explorer/constants'; + +describe('Delete alert', () => { + let wrapper; + + const findAlert = () => wrapper.find(GlAlert); + const findLink = () => wrapper.find(GlLink); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { stubs: { GlSprintf }, propsData }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when deleteAlertType is null', () => { + it('does not show the alert', () => { + mountComponent(); + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('when deleteAlertType is not null', () => { + describe('success states', () => { + describe.each` + deleteAlertType | message + ${'success_tag'} | ${DELETE_TAG_SUCCESS_MESSAGE} + ${'success_tags'} | ${DELETE_TAGS_SUCCESS_MESSAGE} + `('when deleteAlertType is $deleteAlertType', ({ deleteAlertType, message }) => { + it('alert exists', () => { + mountComponent({ deleteAlertType }); + expect(findAlert().exists()).toBe(true); + }); + + describe('when the user is an admin', () => { + beforeEach(() => { + mountComponent({ + deleteAlertType, + isAdmin: true, + garbageCollectionHelpPagePath: 'foo', + }); + }); + + it(`alert title is ${message}`, () => { + expect(findAlert().attributes('title')).toBe(message); + }); + + it('alert body contains admin tip', () => { + expect(findAlert().text()).toMatchInterpolatedText(ADMIN_GARBAGE_COLLECTION_TIP); + }); + + it('alert body contains link', () => { + const alertLink = findLink(); + expect(alertLink.exists()).toBe(true); + expect(alertLink.attributes('href')).toBe('foo'); + }); + }); + + describe('when the user is not an admin', () => { + it('alert exist and text is appropriate', () => { + mountComponent({ deleteAlertType }); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(message); + }); + }); + }); + }); + describe('error states', () => { + describe.each` + deleteAlertType | message + ${'danger_tag'} | ${DELETE_TAG_ERROR_MESSAGE} + ${'danger_tags'} | ${DELETE_TAGS_ERROR_MESSAGE} + `('when deleteAlertType is $deleteAlertType', ({ deleteAlertType, message }) => { + it('alert exists', () => { + mountComponent({ deleteAlertType }); + expect(findAlert().exists()).toBe(true); + }); + + describe('when the user is an admin', () => { + it('alert exist and text is appropriate', () => { + mountComponent({ deleteAlertType }); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(message); + }); + }); + + describe('when the user is not an admin', () => { + it('alert exist and text is appropriate', () => { + mountComponent({ deleteAlertType }); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(message); + }); + }); + }); + }); + + describe('dismissing alert', () => { + it('GlAlert dismiss event triggers a change event', () => { + mountComponent({ deleteAlertType: 'success_tags' }); + findAlert().vm.$emit('dismiss'); + expect(wrapper.emitted('change')).toEqual([[null]]); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js new file mode 100644 index 00000000000..16c9485e69e --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js @@ -0,0 +1,152 @@ +import { GlSprintf, GlFormInput } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue'; +import { + REMOVE_TAG_CONFIRMATION_TEXT, + REMOVE_TAGS_CONFIRMATION_TEXT, + DELETE_IMAGE_CONFIRMATION_TITLE, + DELETE_IMAGE_CONFIRMATION_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import { GlModal } from '../../stubs'; + +describe('Delete Modal', () => { + let wrapper; + + const findModal = () => wrapper.findComponent(GlModal); + const findDescription = () => wrapper.find('[data-testid="description"]'); + const findInputComponent = () => wrapper.findComponent(GlFormInput); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { + propsData, + stubs: { + GlSprintf, + GlModal, + }, + }); + }; + + const expectPrimaryActionStatus = (disabled = true) => + expect(findModal().props('actionPrimary')).toMatchObject( + expect.objectContaining({ + attributes: [{ variant: 'danger' }, { disabled }], + }), + ); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('contains a GlModal', () => { + mountComponent(); + expect(findModal().exists()).toBe(true); + }); + + describe('events', () => { + it.each` + glEvent | localEvent + ${'primary'} | ${'confirmDelete'} + ${'cancel'} | ${'cancelDelete'} + `('GlModal $glEvent emits $localEvent', ({ glEvent, localEvent }) => { + mountComponent(); + findModal().vm.$emit(glEvent); + expect(wrapper.emitted(localEvent)).toEqual([[]]); + }); + }); + + describe('methods', () => { + it('show calls gl-modal show', () => { + mountComponent(); + wrapper.vm.show(); + expect(GlModal.methods.show).toHaveBeenCalled(); + }); + }); + + describe('when we are deleting images', () => { + it('has the correct title', () => { + mountComponent({ deleteImage: true }); + + expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TITLE); + }); + + it('has the correct description', () => { + mountComponent({ deleteImage: true }); + + expect(wrapper.text()).toContain( + DELETE_IMAGE_CONFIRMATION_TEXT.replace('%{code}', '').trim(), + ); + }); + + describe('delete button', () => { + const itemsToBeDeleted = [{ project: { path: 'foo' } }]; + + it('is disabled by default', () => { + mountComponent({ deleteImage: true }); + + expectPrimaryActionStatus(); + }); + + it('if the user types something different from the project path is disabled', async () => { + mountComponent({ deleteImage: true, itemsToBeDeleted }); + + findInputComponent().vm.$emit('input', 'bar'); + + await nextTick(); + + expectPrimaryActionStatus(); + }); + + it('if the user types the project path it is enabled', async () => { + mountComponent({ deleteImage: true, itemsToBeDeleted }); + + findInputComponent().vm.$emit('input', 'foo'); + + await nextTick(); + + expectPrimaryActionStatus(false); + }); + }); + }); + + describe('when we are deleting tags', () => { + it('delete button is enabled', () => { + mountComponent(); + + expectPrimaryActionStatus(false); + }); + + describe('itemsToBeDeleted contains one element', () => { + beforeEach(() => { + mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] }); + }); + + it(`has the correct description`, () => { + expect(findDescription().text()).toBe( + REMOVE_TAG_CONFIRMATION_TEXT.replace('%{item}', 'foo'), + ); + }); + + it('has the correct title', () => { + expect(wrapper.text()).toContain('Remove tag'); + }); + }); + + describe('itemsToBeDeleted contains more than element', () => { + beforeEach(() => { + mountComponent({ itemsToBeDeleted: [{ path: 'foo' }, { path: 'bar' }] }); + }); + + it(`has the correct description`, () => { + expect(findDescription().text()).toBe( + REMOVE_TAGS_CONFIRMATION_TEXT.replace('%{item}', '2'), + ); + }); + + it('has the correct title', () => { + expect(wrapper.text()).toContain('Remove tags'); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js new file mode 100644 index 00000000000..f06300efa29 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js @@ -0,0 +1,304 @@ +import { GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { GlDropdown } from 'jest/packages_and_registries/container_registry/explorer/stubs'; +import { useFakeDate } from 'helpers/fake_date'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import waitForPromises from 'helpers/wait_for_promises'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue'; +import { + UNSCHEDULED_STATUS, + SCHEDULED_STATUS, + ONGOING_STATUS, + UNFINISHED_STATUS, + CLEANUP_DISABLED_TEXT, + CLEANUP_DISABLED_TOOLTIP, + CLEANUP_SCHEDULED_TOOLTIP, + CLEANUP_ONGOING_TOOLTIP, + CLEANUP_UNFINISHED_TOOLTIP, + ROOT_IMAGE_TEXT, + ROOT_IMAGE_TOOLTIP, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import getContainerRepositoryTagCountQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { imageTagsCountMock } from '../../mock_data'; + +describe('Details Header', () => { + let wrapper; + let apolloProvider; + let localVue; + + const defaultImage = { + name: 'foo', + updatedAt: '2020-11-03T13:29:21Z', + canDelete: true, + project: { + visibility: 'public', + containerExpirationPolicy: { + enabled: false, + }, + }, + }; + + // set the date to Dec 4, 2020 + useFakeDate(2020, 11, 4); + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + + const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility'); + const findTitle = () => findByTestId('title'); + const findTagsCount = () => findByTestId('tags-count'); + const findCleanup = () => findByTestId('cleanup'); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + const findInfoIcon = () => wrapper.findComponent(GlIcon); + + const waitForMetadataItems = async () => { + // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + }; + + const mountComponent = ({ + propsData = { image: defaultImage }, + resolver = jest.fn().mockResolvedValue(imageTagsCountMock()), + $apollo = undefined, + } = {}) => { + const mocks = {}; + + if ($apollo) { + mocks.$apollo = $apollo; + } else { + localVue = createLocalVue(); + localVue.use(VueApollo); + + const requestHandlers = [[getContainerRepositoryTagCountQuery, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + } + + wrapper = shallowMount(component, { + localVue, + apolloProvider, + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + mocks, + stubs: { + TitleArea, + GlDropdown, + GlDropdownItem, + }, + }); + }; + + afterEach(() => { + // if we want to mix createMockApollo and manual mocks we need to reset everything + wrapper.destroy(); + apolloProvider = undefined; + localVue = undefined; + wrapper = null; + }); + + describe('image name', () => { + describe('missing image name', () => { + beforeEach(() => { + mountComponent({ propsData: { image: { ...defaultImage, name: '' } } }); + + return waitForPromises(); + }); + + it('root image ', () => { + expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); + }); + + it('has an icon', () => { + expect(findInfoIcon().exists()).toBe(true); + expect(findInfoIcon().props('name')).toBe('information-o'); + }); + + it('has a tooltip', () => { + const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip'); + expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP); + }); + }); + + describe('with image name present', () => { + beforeEach(() => { + mountComponent(); + + return waitForPromises(); + }); + + it('shows image.name ', () => { + expect(findTitle().text()).toContain('foo'); + }); + + it('has no icon', () => { + expect(findInfoIcon().exists()).toBe(false); + }); + }); + }); + + describe('delete button', () => { + it('exists', () => { + mountComponent(); + + expect(findDeleteButton().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findDeleteButton().text()).toBe('Delete image repository'); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findDeleteButton().attributes()).toMatchObject( + expect.objectContaining({ + variant: 'danger', + }), + ); + }); + + it('emits the correct event', () => { + mountComponent(); + + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + + it.each` + canDelete | disabled | isDisabled + ${true} | ${false} | ${undefined} + ${true} | ${true} | ${'true'} + ${false} | ${false} | ${'true'} + ${false} | ${true} | ${'true'} + `( + 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', + ({ canDelete, disabled, isDisabled }) => { + mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); + + expect(findDeleteButton().attributes('disabled')).toBe(isDisabled); + }, + ); + }); + + describe('metadata items', () => { + describe('tags count', () => { + it('displays "-- tags" while loading', async () => { + // here we are forced to mock apollo because `waitForMetadataItems` waits + // for two ticks, de facto allowing the promise to resolve, so there is + // no way to catch the component as both rendered and in loading state + mountComponent({ $apollo: { queries: { containerRepository: { loading: true } } } }); + + await waitForMetadataItems(); + + expect(findTagsCount().props('text')).toBe('-- tags'); + }); + + it('when there is more than one tag has the correct text', async () => { + mountComponent(); + + await waitForPromises(); + await waitForMetadataItems(); + + expect(findTagsCount().props('text')).toBe('13 tags'); + }); + + it('when there is one tag has the correct text', async () => { + mountComponent({ + resolver: jest.fn().mockResolvedValue(imageTagsCountMock({ tagsCount: 1 })), + }); + + await waitForPromises(); + await waitForMetadataItems(); + + expect(findTagsCount().props('text')).toBe('1 tag'); + }); + + it('has the correct icon', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findTagsCount().props('icon')).toBe('tag'); + }); + }); + + describe('cleanup metadata item', () => { + it('has the correct icon', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findCleanup().props('icon')).toBe('expire'); + }); + + it('when the expiration policy is disabled', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findCleanup().props()).toMatchObject({ + text: CLEANUP_DISABLED_TEXT, + textTooltip: CLEANUP_DISABLED_TOOLTIP, + }); + }); + + it.each` + status | text | tooltip + ${UNSCHEDULED_STATUS} | ${'Cleanup will run in 1 month'} | ${''} + ${SCHEDULED_STATUS} | ${'Cleanup pending'} | ${CLEANUP_SCHEDULED_TOOLTIP} + ${ONGOING_STATUS} | ${'Cleanup in progress'} | ${CLEANUP_ONGOING_TOOLTIP} + ${UNFINISHED_STATUS} | ${'Cleanup incomplete'} | ${CLEANUP_UNFINISHED_TOOLTIP} + `( + 'when the status is $status the text is $text and the tooltip is $tooltip', + async ({ status, text, tooltip }) => { + mountComponent({ + propsData: { + image: { + ...defaultImage, + expirationPolicyCleanupStatus: status, + project: { + containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, + }, + }, + }, + }); + await waitForMetadataItems(); + + expect(findCleanup().props()).toMatchObject({ + text, + textTooltip: tooltip, + }); + }, + ); + }); + + describe('visibility and updated at ', () => { + it('has last updated text', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago'); + }); + + describe('visibility icon', () => { + it('shows an eye when the project is public', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); + }); + it('shows an eye slashed when the project is not public', async () => { + mountComponent({ + propsData: { image: { ...defaultImage, project: { visibility: 'private' } } }, + }); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js new file mode 100644 index 00000000000..f14284e9efe --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js @@ -0,0 +1,54 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; +import { + NO_TAGS_TITLE, + NO_TAGS_MESSAGE, + MISSING_OR_DELETED_IMAGE_TITLE, + MISSING_OR_DELETED_IMAGE_MESSAGE, +} from '~/packages_and_registries/container_registry/explorer/constants'; + +describe('EmptyTagsState component', () => { + let wrapper; + + const findEmptyState = () => wrapper.find(GlEmptyState); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { + stubs: { + GlEmptyState, + }, + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('contains gl-empty-state', () => { + mountComponent(); + expect(findEmptyState().exists()).toBe(true); + }); + + it.each` + isEmptyImage | title | description + ${false} | ${NO_TAGS_TITLE} | ${NO_TAGS_MESSAGE} + ${true} | ${MISSING_OR_DELETED_IMAGE_TITLE} | ${MISSING_OR_DELETED_IMAGE_MESSAGE} + `( + 'when isEmptyImage is $isEmptyImage has the correct props', + ({ isEmptyImage, title, description }) => { + mountComponent({ + noContainersImage: 'foo', + isEmptyImage, + }); + + expect(findEmptyState().props()).toMatchObject({ + title, + description, + svgPath: 'foo', + }); + }, + ); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js new file mode 100644 index 00000000000..1a27481a828 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js @@ -0,0 +1,74 @@ +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue'; +import { + DELETE_ALERT_TITLE, + DELETE_ALERT_LINK_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; + +describe('Partial Cleanup alert', () => { + let wrapper; + + const findAlert = () => wrapper.find(GlAlert); + const findRunLink = () => wrapper.find('[data-testid="run-link"'); + const findHelpLink = () => wrapper.find('[data-testid="help-link"'); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { GlSprintf }, + propsData: { + runCleanupPoliciesHelpPagePath: 'foo', + cleanupPoliciesHelpPagePath: 'bar', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it(`gl-alert has the correct properties`, () => { + mountComponent(); + + expect(findAlert().props()).toMatchObject({ + title: DELETE_ALERT_TITLE, + variant: 'warning', + }); + }); + + it('has the right text', () => { + mountComponent(); + + expect(wrapper.text()).toMatchInterpolatedText(DELETE_ALERT_LINK_TEXT); + }); + + it('contains run link', () => { + mountComponent(); + + const link = findRunLink(); + expect(link.exists()).toBe(true); + expect(link.attributes()).toMatchObject({ + href: 'foo', + target: '_blank', + }); + }); + + it('contains help link', () => { + mountComponent(); + + const link = findHelpLink(); + expect(link.exists()).toBe(true); + expect(link.attributes()).toMatchObject({ + href: 'bar', + target: '_blank', + }); + }); + + it('GlAlert dismiss event triggers a dismiss event', () => { + mountComponent(); + + findAlert().vm.$emit('dismiss'); + expect(wrapper.emitted('dismiss')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js new file mode 100644 index 00000000000..a11b102d9a6 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js @@ -0,0 +1,57 @@ +import { GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue'; +import { + DELETE_SCHEDULED, + DELETE_FAILED, + PACKAGE_DELETE_HELP_PAGE_PATH, + SCHEDULED_FOR_DELETION_STATUS_TITLE, + SCHEDULED_FOR_DELETION_STATUS_MESSAGE, + FAILED_DELETION_STATUS_TITLE, + FAILED_DELETION_STATUS_MESSAGE, +} from '~/packages_and_registries/container_registry/explorer/constants'; + +describe('Status Alert', () => { + let wrapper; + + const findLink = () => wrapper.find(GlLink); + const findAlert = () => wrapper.find(GlAlert); + const findMessage = () => wrapper.find('[data-testid="message"]'); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { + propsData, + stubs: { + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each` + status | title | variant | message | link + ${DELETE_SCHEDULED} | ${SCHEDULED_FOR_DELETION_STATUS_TITLE} | ${'info'} | ${SCHEDULED_FOR_DELETION_STATUS_MESSAGE} | ${PACKAGE_DELETE_HELP_PAGE_PATH} + ${DELETE_FAILED} | ${FAILED_DELETION_STATUS_TITLE} | ${'warning'} | ${FAILED_DELETION_STATUS_MESSAGE} | ${''} + `( + `when the status is $status, title is $title, variant is $variant, message is $message and the link is $link`, + ({ status, title, variant, message, link }) => { + mountComponent({ status }); + + expect(findMessage().text()).toMatchInterpolatedText(message); + expect(findAlert().props()).toMatchObject({ + title, + variant, + }); + if (link) { + expect(findLink().attributes()).toMatchObject({ + target: '_blank', + href: link, + }); + } + }, + ); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js new file mode 100644 index 00000000000..00b1d03b7c2 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js @@ -0,0 +1,382 @@ +import { GlFormCheckbox, GlSprintf, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; + +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue'; +import { + REMOVE_TAG_BUTTON_TITLE, + MISSING_MANIFEST_WARNING_TOOLTIP, + NOT_AVAILABLE_TEXT, + NOT_AVAILABLE_SIZE, +} from '~/packages_and_registries/container_registry/explorer/constants/index'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import { tagsMock } from '../../mock_data'; +import { ListItem } from '../../stubs'; + +describe('tags list row', () => { + let wrapper; + const [tag] = [...tagsMock]; + + const defaultProps = { tag, isMobile: false, index: 0 }; + + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findName = () => wrapper.find('[data-testid="name"]'); + const findSize = () => wrapper.find('[data-testid="size"]'); + const findTime = () => wrapper.find('[data-testid="time"]'); + const findShortRevision = () => wrapper.find('[data-testid="digest"]'); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); + const findDetailsRows = () => wrapper.findAll(DetailsRow); + const findPublishedDateDetail = () => wrapper.find('[data-testid="published-date-detail"]'); + const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]'); + const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]'); + const findWarningIcon = () => wrapper.findComponent(GlIcon); + const findAdditionalActionsMenu = () => wrapper.findComponent(GlDropdown); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMount(component, { + stubs: { + GlSprintf, + ListItem, + DetailsRow, + GlDropdown, + }, + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('checkbox', () => { + it('exists', () => { + mountComponent(); + + expect(findCheckbox().exists()).toBe(true); + }); + + it("does not exist when the row can't be deleted", () => { + const customTag = { ...tag, canDelete: false }; + + mountComponent({ ...defaultProps, tag: customTag }); + + expect(findCheckbox().exists()).toBe(false); + }); + + it.each` + digest | disabled + ${'foo'} | ${true} + ${null} | ${false} + ${null} | ${true} + ${'foo'} | ${true} + `('is disabled when the digest $digest and disabled is $disabled', ({ digest, disabled }) => { + mountComponent({ tag: { ...tag, digest }, disabled }); + + expect(findCheckbox().attributes('disabled')).toBe('true'); + }); + + it('is wired to the selected prop', () => { + mountComponent({ ...defaultProps, selected: true }); + + expect(findCheckbox().attributes('checked')).toBe('true'); + }); + + it('when changed emit a select event', () => { + mountComponent(); + + findCheckbox().vm.$emit('change'); + + expect(wrapper.emitted('select')).toEqual([[]]); + }); + }); + + describe('tag name', () => { + it('exists', () => { + mountComponent(); + + expect(findName().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findName().text()).toBe(tag.name); + }); + + it('has a tooltip', () => { + mountComponent(); + + const tooltip = getBinding(findName().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe(tag.name); + }); + + it('on mobile has mw-s class', () => { + mountComponent({ ...defaultProps, isMobile: true }); + + expect(findName().classes('mw-s')).toBe(true); + }); + }); + + describe('clipboard button', () => { + it('exist if tag.location exist', () => { + mountComponent(); + + expect(findClipboardButton().exists()).toBe(true); + }); + + it('is hidden if tag does not have a location', () => { + mountComponent({ ...defaultProps, tag: { ...tag, location: null } }); + + expect(findClipboardButton().exists()).toBe(false); + }); + + it('has the correct props/attributes', () => { + mountComponent(); + + expect(findClipboardButton().attributes()).toMatchObject({ + text: tag.location, + title: tag.location, + }); + }); + + it('is disabled when the component is disabled', () => { + mountComponent({ ...defaultProps, disabled: true }); + + expect(findClipboardButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('warning icon', () => { + it('is normally hidden', () => { + mountComponent(); + + expect(findWarningIcon().exists()).toBe(false); + }); + + it('is shown when the tag is broken', () => { + mountComponent({ tag: { ...tag, digest: null } }); + + expect(findWarningIcon().exists()).toBe(true); + }); + + it('has an appropriate tooltip', () => { + mountComponent({ tag: { ...tag, digest: null } }); + + const tooltip = getBinding(findWarningIcon().element, 'gl-tooltip'); + expect(tooltip.value.title).toBe(MISSING_MANIFEST_WARNING_TOOLTIP); + }); + }); + + describe('size', () => { + it('exists', () => { + mountComponent(); + + expect(findSize().exists()).toBe(true); + }); + + it('contains the totalSize and layers', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1024', layers: 10 } }); + + expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers'); + }); + + it('when totalSize is giantic', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1099511627776', layers: 2 } }); + + expect(findSize().text()).toMatchInterpolatedText('1024.00 GiB · 2 layers'); + }); + + it('when totalSize is missing', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '0', layers: 10 } }); + + expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 10 layers`); + }); + + it('when layers are missing', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1024' } }); + + expect(findSize().text()).toMatchInterpolatedText('1.00 KiB'); + }); + + it('when there is 1 layer', () => { + mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '0', layers: 1 } }); + + expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 1 layer`); + }); + }); + + describe('time', () => { + it('exists', () => { + mountComponent(); + + expect(findTime().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findTime().text()).toBe('Published'); + }); + + it('contains time_ago_tooltip component', () => { + mountComponent(); + + expect(findTimeAgoTooltip().exists()).toBe(true); + }); + + it('pass the correct props to time ago tooltip', () => { + mountComponent(); + + expect(findTimeAgoTooltip().attributes()).toMatchObject({ time: tag.createdAt }); + }); + }); + + describe('digest', () => { + it('exists', () => { + mountComponent(); + + expect(findShortRevision().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 2cf3d2f'); + }); + + it(`displays ${NOT_AVAILABLE_TEXT} when digest is missing`, () => { + mountComponent({ tag: { ...tag, digest: null } }); + + expect(findShortRevision().text()).toMatchInterpolatedText(`Digest: ${NOT_AVAILABLE_TEXT}`); + }); + }); + + describe('additional actions menu', () => { + it('exists', () => { + mountComponent(); + + expect(findAdditionalActionsMenu().exists()).toBe(true); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findAdditionalActionsMenu().props()).toMatchObject({ + icon: 'ellipsis_v', + text: 'More actions', + textSrOnly: true, + category: 'tertiary', + right: true, + }); + }); + + it.each` + canDelete | digest | disabled | buttonDisabled + ${true} | ${null} | ${true} | ${true} + ${false} | ${'foo'} | ${true} | ${true} + ${false} | ${null} | ${true} | ${true} + ${true} | ${'foo'} | ${true} | ${true} + ${true} | ${'foo'} | ${false} | ${false} + `( + 'is $visible that is visible when canDelete is $canDelete and digest is $digest and disabled is $disabled', + ({ canDelete, digest, disabled, buttonDisabled }) => { + mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled }); + + expect(findAdditionalActionsMenu().props('disabled')).toBe(buttonDisabled); + expect(findAdditionalActionsMenu().classes('gl-opacity-0')).toBe(buttonDisabled); + expect(findAdditionalActionsMenu().classes('gl-pointer-events-none')).toBe(buttonDisabled); + }, + ); + + describe('delete button', () => { + it('exists and has the correct attrs', () => { + mountComponent(); + + expect(findDeleteButton().exists()).toBe(true); + expect(findDeleteButton().attributes()).toMatchObject({ + variant: 'danger', + }); + expect(findDeleteButton().text()).toBe(REMOVE_TAG_BUTTON_TITLE); + }); + + it('delete event emits delete', () => { + mountComponent(); + + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + }); + + describe('details rows', () => { + describe('when the tag has a digest', () => { + it('has 3 details rows', async () => { + mountComponent(); + await nextTick(); + + expect(findDetailsRows().length).toBe(3); + }); + + describe.each` + name | finderFunction | text | icon | clipboard + ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 UTC on 2020-11-03'} | ${'clock'} | ${false} + ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} + ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} + `('$name details row', ({ finderFunction, text, icon, clipboard }) => { + it(`has ${text} as text`, async () => { + mountComponent(); + await nextTick(); + + expect(finderFunction().text()).toMatchInterpolatedText(text); + }); + + it(`has the ${icon} icon`, async () => { + mountComponent(); + await nextTick(); + + expect(finderFunction().props('icon')).toBe(icon); + }); + + if (clipboard) { + it(`clipboard button exist`, async () => { + mountComponent(); + await nextTick(); + + expect(finderFunction().find(ClipboardButton).exists()).toBe(clipboard); + }); + + it('is disabled when the component is disabled', async () => { + mountComponent({ ...defaultProps, disabled: true }); + await nextTick(); + + expect(finderFunction().findComponent(ClipboardButton).attributes('disabled')).toBe( + 'true', + ); + }); + } + }); + }); + + describe('when the tag does not have a digest', () => { + it('hides the details rows', async () => { + mountComponent({ tag: { ...tag, digest: null } }); + + await nextTick(); + expect(findDetailsRows().length).toBe(0); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js new file mode 100644 index 00000000000..9a42c82d7e0 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js @@ -0,0 +1,314 @@ +import { GlButton, GlKeysetPagination } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; +import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue'; +import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue'; +import { + TAGS_LIST_TITLE, + REMOVE_TAGS_BUTTON_TITLE, +} from '~/packages_and_registries/container_registry/explorer/constants/index'; +import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; +import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data'; + +const localVue = createLocalVue(); + +describe('Tags List', () => { + let wrapper; + let apolloProvider; + const tags = [...tagsMock]; + const readOnlyTags = tags.map((t) => ({ ...t, canDelete: false })); + + const findTagsListRow = () => wrapper.findAll(TagsListRow); + const findDeleteButton = () => wrapper.find(GlButton); + const findListTitle = () => wrapper.find('[data-testid="list-title"]'); + const findPagination = () => wrapper.find(GlKeysetPagination); + const findEmptyState = () => wrapper.find(EmptyTagsState); + const findTagsLoader = () => wrapper.find(TagsLoader); + + const waitForApolloRequestRender = async () => { + await waitForPromises(); + await nextTick(); + }; + + const mountComponent = ({ + propsData = { isMobile: false, id: 1 }, + resolver = jest.fn().mockResolvedValue(imageTagsMock()), + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[getContainerRepositoryTagsQuery, resolver]]; + + apolloProvider = createMockApollo(requestHandlers); + wrapper = shallowMount(component, { + localVue, + apolloProvider, + propsData, + provide() { + return { + config: {}, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('List title', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findListTitle().exists()).toBe(true); + }); + + it('has the correct text', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findListTitle().text()).toBe(TAGS_LIST_TITLE); + }); + }); + + describe('delete button', () => { + it.each` + inputTags | isMobile | isVisible + ${tags} | ${false} | ${true} + ${tags} | ${true} | ${false} + ${readOnlyTags} | ${false} | ${false} + ${readOnlyTags} | ${true} | ${false} + `( + 'is $isVisible that delete button exists when tags is $inputTags and isMobile is $isMobile', + async ({ inputTags, isMobile, isVisible }) => { + mountComponent({ + propsData: { tags: inputTags, isMobile, id: 1 }, + resolver: jest.fn().mockResolvedValue(imageTagsMock(inputTags)), + }); + + await waitForApolloRequestRender(); + + expect(findDeleteButton().exists()).toBe(isVisible); + }, + ); + + it('has the correct text', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE); + }); + + it('has the correct props', async () => { + mountComponent(); + await waitForApolloRequestRender(); + + expect(findDeleteButton().attributes()).toMatchObject({ + category: 'secondary', + variant: 'danger', + }); + }); + + it.each` + disabled | doSelect | buttonDisabled + ${true} | ${false} | ${'true'} + ${true} | ${true} | ${'true'} + ${false} | ${false} | ${'true'} + ${false} | ${true} | ${undefined} + `( + 'is $buttonDisabled that the button is disabled when the component disabled state is $disabled and is $doSelect that the user selected a tag', + async ({ disabled, buttonDisabled, doSelect }) => { + mountComponent({ propsData: { tags, disabled, isMobile: false, id: 1 } }); + + await waitForApolloRequestRender(); + + if (doSelect) { + findTagsListRow().at(0).vm.$emit('select'); + await nextTick(); + } + + expect(findDeleteButton().attributes('disabled')).toBe(buttonDisabled); + }, + ); + + it('click event emits a deleted event with selected items', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findTagsListRow().at(0).vm.$emit('select'); + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name); + }); + }); + + describe('list rows', () => { + it('one row exist for each tag', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findTagsListRow()).toHaveLength(tags.length); + }); + + it('the correct props are bound to it', async () => { + mountComponent({ propsData: { disabled: true, id: 1 } }); + + await waitForApolloRequestRender(); + + const rows = findTagsListRow(); + + expect(rows.at(0).attributes()).toMatchObject({ + first: 'true', + disabled: 'true', + }); + }); + + describe('events', () => { + it('select event update the selected items', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findTagsListRow().at(0).vm.$emit('select'); + + await nextTick(); + + expect(findTagsListRow().at(0).attributes('selected')).toBe('true'); + }); + + it('delete event emit a delete event', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findTagsListRow().at(0).vm.$emit('delete'); + expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name); + }); + }); + }); + + describe('when the list of tags is empty', () => { + const resolver = jest.fn().mockResolvedValue(imageTagsMock([])); + + it('has the empty state', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findEmptyState().exists()).toBe(true); + }); + + it('does not show the loader', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findTagsLoader().exists()).toBe(false); + }); + + it('does not show the list', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findTagsListRow().exists()).toBe(false); + expect(findListTitle().exists()).toBe(false); + }); + }); + + describe('pagination', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findPagination().exists()).toBe(true); + }); + + it('is hidden when loading', () => { + mountComponent(); + + expect(findPagination().exists()).toBe(false); + }); + + it('is hidden when there are no more pages', async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(imageTagsMock([])) }); + + await waitForApolloRequestRender(); + + expect(findPagination().exists()).toBe(false); + }); + + it('is wired to the correct pagination props', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findPagination().props()).toMatchObject({ + hasNextPage: tagsPageInfo.hasNextPage, + hasPreviousPage: tagsPageInfo.hasPreviousPage, + }); + }); + + it('fetch next page when user clicks next', async () => { + const resolver = jest.fn().mockResolvedValue(imageTagsMock()); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findPagination().vm.$emit('next'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ after: tagsPageInfo.endCursor }), + ); + }); + + it('fetch previous page when user clicks prev', async () => { + const resolver = jest.fn().mockResolvedValue(imageTagsMock()); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findPagination().vm.$emit('prev'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }), + ); + }); + }); + + describe('loading state', () => { + it.each` + isImageLoading | queryExecuting | loadingVisible + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when the isImageLoading is $isImageLoading, and is $queryExecuting that the query is still executing is $loadingVisible that the loader is shown', + async ({ isImageLoading, queryExecuting, loadingVisible }) => { + mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } }); + + if (!queryExecuting) { + await waitForApolloRequestRender(); + } + + expect(findTagsLoader().exists()).toBe(loadingVisible); + expect(findTagsListRow().exists()).toBe(!loadingVisible); + expect(findListTitle().exists()).toBe(!loadingVisible); + expect(findPagination().exists()).toBe(!loadingVisible); + }, + ); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js new file mode 100644 index 00000000000..060dc9dc5f3 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js @@ -0,0 +1,45 @@ +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue'; +import { GlSkeletonLoader } from '../../stubs'; + +describe('TagsLoader component', () => { + let wrapper; + + const findGlSkeletonLoaders = () => wrapper.findAll(GlSkeletonLoader); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { + GlSkeletonLoader, + }, + // set the repeat to 1 to avoid a long and verbose snapshot + loader: { + ...component.loader, + repeat: 1, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('produces the correct amount of loaders ', () => { + mountComponent(); + expect(findGlSkeletonLoaders().length).toBe(1); + }); + + it('has the correct props', () => { + mountComponent(); + expect(findGlSkeletonLoaders().at(0).props()).toMatchObject({ + width: component.loader.width, + height: component.loader.height, + }); + }); + + it('has the correct markup', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap new file mode 100644 index 00000000000..56579847468 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Registry Group Empty state to match the default snapshot 1`] = ` +<div> + <p> + With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. + <gl-link-stub + href="baz" + target="_blank" + > + More Information + </gl-link-stub> + </p> +</div> +`; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap new file mode 100644 index 00000000000..46b07b4c2d6 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Registry Project Empty state to match the default snapshot 1`] = ` +<div> + <p> + With the Container Registry, every project can have its own space to store its Docker images. + <gl-link-stub + href="baz" + target="_blank" + > + More Information + </gl-link-stub> + </p> + + <h5> + CLI Commands + </h5> + + <p> + If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have + <gl-link-stub + href="barBaz" + target="_blank" + > + Two-Factor Authentication + </gl-link-stub> + enabled, use a + <gl-link-stub + href="fooBaz" + target="_blank" + > + Personal Access Token + </gl-link-stub> + instead of a password. + </p> + + <gl-form-input-group-stub + class="gl-mb-4" + predefinedoptions="[object Object]" + value="" + > + <gl-form-input-stub + class="gl-font-monospace!" + readonly="" + type="text" + value="bazbaz" + /> + </gl-form-input-group-stub> + + <p + class="gl-mb-4" + > + + You can add an image to this registry with the following commands: + + </p> + + <gl-form-input-group-stub + class="gl-mb-4" + predefinedoptions="[object Object]" + value="" + > + <gl-form-input-stub + class="gl-font-monospace!" + readonly="" + type="text" + value="foofoo" + /> + </gl-form-input-group-stub> + + <gl-form-input-group-stub + predefinedoptions="[object Object]" + value="" + > + <gl-form-input-stub + class="gl-font-monospace!" + readonly="" + type="text" + value="barbar" + /> + </gl-form-input-group-stub> +</div> +`; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js new file mode 100644 index 00000000000..e8ddad2d8ca --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js @@ -0,0 +1,87 @@ +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue'; +import { + CLEANUP_TIMED_OUT_ERROR_MESSAGE, + CLEANUP_STATUS_SCHEDULED, + CLEANUP_STATUS_ONGOING, + CLEANUP_STATUS_UNFINISHED, + UNFINISHED_STATUS, + UNSCHEDULED_STATUS, + SCHEDULED_STATUS, + ONGOING_STATUS, +} from '~/packages_and_registries/container_registry/explorer/constants'; + +describe('cleanup_status', () => { + let wrapper; + + const findMainIcon = () => wrapper.findByTestId('main-icon'); + const findExtraInfoIcon = () => wrapper.findByTestId('extra-info'); + + const mountComponent = (propsData = { status: SCHEDULED_STATUS }) => { + wrapper = shallowMountExtended(CleanupStatus, { + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + status | visible | text + ${UNFINISHED_STATUS} | ${true} | ${CLEANUP_STATUS_UNFINISHED} + ${SCHEDULED_STATUS} | ${true} | ${CLEANUP_STATUS_SCHEDULED} + ${ONGOING_STATUS} | ${true} | ${CLEANUP_STATUS_ONGOING} + ${UNSCHEDULED_STATUS} | ${false} | ${''} + `( + 'when the status is $status is $visible that the component is mounted and has the correct text', + ({ status, visible, text }) => { + mountComponent({ status }); + + expect(findMainIcon().exists()).toBe(visible); + expect(wrapper.text()).toBe(text); + }, + ); + + describe('main icon', () => { + it('exists', () => { + mountComponent(); + + expect(findMainIcon().exists()).toBe(true); + }); + + it(`has the orange class when the status is ${UNFINISHED_STATUS}`, () => { + mountComponent({ status: UNFINISHED_STATUS }); + + expect(findMainIcon().classes('gl-text-orange-500')).toBe(true); + }); + }); + + describe('extra info icon', () => { + it.each` + status | visible + ${UNFINISHED_STATUS} | ${true} + ${SCHEDULED_STATUS} | ${false} + ${ONGOING_STATUS} | ${false} + `( + 'when the status is $status is $visible that the extra icon is visible', + ({ status, visible }) => { + mountComponent({ status }); + + expect(findExtraInfoIcon().exists()).toBe(visible); + }, + ); + + it(`has a tooltip`, () => { + mountComponent({ status: UNFINISHED_STATUS }); + + const tooltip = getBinding(findExtraInfoIcon().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe(CLEANUP_TIMED_OUT_ERROR_MESSAGE); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js new file mode 100644 index 00000000000..4039fba869b --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js @@ -0,0 +1,94 @@ +import { GlDropdown } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import QuickstartDropdown from '~/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue'; +import { + QUICK_START, + LOGIN_COMMAND_LABEL, + COPY_LOGIN_TITLE, + BUILD_COMMAND_LABEL, + COPY_BUILD_TITLE, + PUSH_COMMAND_LABEL, + COPY_PUSH_TITLE, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import Tracking from '~/tracking'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; + +import { dockerCommands } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('cli_commands', () => { + let wrapper; + + const config = { + repositoryUrl: 'foo', + registryHostUrlWithPort: 'bar', + }; + + const findDropdownButton = () => wrapper.find(GlDropdown); + const findCodeInstruction = () => wrapper.findAll(CodeInstruction); + + const mountComponent = () => { + wrapper = mount(QuickstartDropdown, { + localVue, + provide() { + return { + config, + ...dockerCommands, + }; + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('shows the correct text on the button', () => { + expect(findDropdownButton().text()).toContain(QUICK_START); + }); + + it('clicking on the dropdown emit a tracking event', () => { + findDropdownButton().vm.$emit('shown'); + expect(Tracking.event).toHaveBeenCalledWith( + undefined, + 'click_dropdown', + expect.objectContaining({ label: 'quickstart_dropdown' }), + ); + }); + + describe.each` + index | labelText | titleText | command | trackedEvent + ${0} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${dockerCommands.dockerLoginCommand} | ${'click_copy_login'} + ${1} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${dockerCommands.dockerBuildCommand} | ${'click_copy_build'} + ${2} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${dockerCommands.dockerPushCommand} | ${'click_copy_push'} + `('code instructions at $index', ({ index, labelText, titleText, command, trackedEvent }) => { + let codeInstruction; + + beforeEach(() => { + codeInstruction = findCodeInstruction().at(index); + }); + + it('exists', () => { + expect(codeInstruction.exists()).toBe(true); + }); + + it(`has the correct props`, () => { + expect(codeInstruction.props()).toMatchObject({ + label: labelText, + instruction: command, + copyText: titleText, + trackingAction: trackedEvent, + trackingLabel: 'quickstart_dropdown', + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js new file mode 100644 index 00000000000..027cdf732bc --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js @@ -0,0 +1,37 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import groupEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue'; +import { GlEmptyState } from '../../stubs'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Registry Group Empty state', () => { + let wrapper; + const config = { + noContainersImage: 'foo', + helpPagePath: 'baz', + }; + + beforeEach(() => { + wrapper = shallowMount(groupEmptyState, { + localVue, + stubs: { + GlEmptyState, + GlSprintf, + }, + provide() { + return { config }; + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('to match the default snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js new file mode 100644 index 00000000000..411bef54e40 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js @@ -0,0 +1,223 @@ +import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import DeleteButton from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue'; +import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue'; +import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue'; +import { + ROW_SCHEDULED_FOR_DELETION, + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + IMAGE_DELETE_SCHEDULED_STATUS, + SCHEDULED_STATUS, + ROOT_IMAGE_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { imagesListResponse } from '../../mock_data'; +import { RouterLink } from '../../stubs'; + +describe('Image List Row', () => { + let wrapper; + const [item] = imagesListResponse; + + const findDetailsLink = () => wrapper.find('[data-testid="details-link"]'); + const findTagsCount = () => wrapper.find('[data-testid="tags-count"]'); + const findDeleteBtn = () => wrapper.findComponent(DeleteButton); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findCleanupStatus = () => wrapper.findComponent(CleanupStatus); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findListItemComponent = () => wrapper.findComponent(ListItem); + + const mountComponent = (props) => { + wrapper = shallowMount(Component, { + stubs: { + RouterLink, + GlSprintf, + ListItem, + }, + propsData: { + item, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('list item component', () => { + describe('tooltip', () => { + it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => { + mountComponent(); + + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION); + }); + + it('is disabled when item is being deleted', () => { + mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); + + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip.value.disabled).toBe(false); + }); + }); + + it('is disabled when the item is in deleting status', () => { + mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); + + expect(findListItemComponent().props('disabled')).toBe(true); + }); + }); + + describe('image title and path', () => { + it('contains a link to the details page', () => { + mountComponent(); + + const link = findDetailsLink(); + expect(link.text()).toBe(item.path); + expect(findDetailsLink().props('to')).toMatchObject({ + name: 'details', + params: { + id: getIdFromGraphQLId(item.id), + }, + }); + }); + + it(`when the image has no name appends ${ROOT_IMAGE_TEXT} to the path`, () => { + mountComponent({ item: { ...item, name: '' } }); + + expect(findDetailsLink().text()).toBe(`${item.path}/ ${ROOT_IMAGE_TEXT}`); + }); + + it('contains a clipboard button', () => { + mountComponent(); + const button = findClipboardButton(); + expect(button.exists()).toBe(true); + expect(button.props('text')).toBe(item.location); + expect(button.props('title')).toBe(item.location); + }); + + describe('cleanup status component', () => { + it.each` + expirationPolicyCleanupStatus | shown + ${null} | ${false} + ${SCHEDULED_STATUS} | ${true} + `( + 'when expirationPolicyCleanupStatus is $expirationPolicyCleanupStatus it is $shown that the component exists', + ({ expirationPolicyCleanupStatus, shown }) => { + mountComponent({ item: { ...item, expirationPolicyCleanupStatus } }); + + expect(findCleanupStatus().exists()).toBe(shown); + + if (shown) { + expect(findCleanupStatus().props()).toMatchObject({ + status: expirationPolicyCleanupStatus, + }); + } + }, + ); + }); + + describe('when the item is deleting', () => { + beforeEach(() => { + mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); + }); + + it('the router link is disabled', () => { + // we check the event prop as is the only workaround to disable a router link + expect(findDetailsLink().props('event')).toBe(''); + }); + it('the clipboard button is disabled', () => { + expect(findClipboardButton().attributes('disabled')).toBe('true'); + }); + }); + }); + + describe('delete button', () => { + it('exists', () => { + mountComponent(); + expect(findDeleteBtn().exists()).toBe(true); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findDeleteBtn().props()).toMatchObject({ + title: REMOVE_REPOSITORY_LABEL, + tooltipDisabled: item.canDelete, + tooltipTitle: LIST_DELETE_BUTTON_DISABLED, + }); + }); + + it('emits a delete event', () => { + mountComponent(); + + findDeleteBtn().vm.$emit('delete'); + expect(wrapper.emitted('delete')).toEqual([[item]]); + }); + + it.each` + canDelete | status | state + ${false} | ${''} | ${true} + ${false} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true} + ${true} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true} + ${true} | ${''} | ${false} + `( + 'disabled is $state when canDelete is $canDelete and status is $status', + ({ canDelete, status, state }) => { + mountComponent({ item: { ...item, canDelete, status } }); + + expect(findDeleteBtn().props('disabled')).toBe(state); + }, + ); + }); + + describe('tags count', () => { + it('exists', () => { + mountComponent(); + expect(findTagsCount().exists()).toBe(true); + }); + + it('contains a tag icon', () => { + mountComponent(); + const icon = findTagsCount().find(GlIcon); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('tag'); + }); + + describe('loading state', () => { + it('shows a loader when metadataLoading is true', () => { + mountComponent({ metadataLoading: true }); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('hides the tags count while loading', () => { + mountComponent({ metadataLoading: true }); + + expect(findTagsCount().exists()).toBe(false); + }); + }); + + describe('tags count text', () => { + it('with one tag in the image', () => { + mountComponent({ item: { ...item, tagsCount: 1 } }); + + expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag'); + }); + it('with more than one tag in the image', () => { + mountComponent({ item: { ...item, tagsCount: 3 } }); + + expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags'); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js new file mode 100644 index 00000000000..e0119954ed4 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js @@ -0,0 +1,88 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue'; +import ImageListRow from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue'; + +import { imagesListResponse, pageInfo as defaultPageInfo } from '../../mock_data'; + +describe('Image List', () => { + let wrapper; + + const findRow = () => wrapper.findAll(ImageListRow); + const findPagination = () => wrapper.find(GlKeysetPagination); + + const mountComponent = (props) => { + wrapper = shallowMount(Component, { + propsData: { + images: imagesListResponse, + pageInfo: defaultPageInfo, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('list', () => { + it('contains one list element for each image', () => { + mountComponent(); + + expect(findRow().length).toBe(imagesListResponse.length); + }); + + it('when delete event is emitted on the row it emits up a delete event', () => { + mountComponent(); + + findRow().at(0).vm.$emit('delete', 'foo'); + expect(wrapper.emitted('delete')).toEqual([['foo']]); + }); + + it('passes down the metadataLoading prop', () => { + mountComponent({ metadataLoading: true }); + expect(findRow().at(0).props('metadataLoading')).toBe(true); + }); + }); + + describe('pagination', () => { + it('exists', () => { + mountComponent(); + + expect(findPagination().exists()).toBe(true); + }); + + it.each` + hasNextPage | hasPreviousPage | isVisible + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + `( + 'when hasNextPage is $hasNextPage and hasPreviousPage is $hasPreviousPage: is $isVisible that the component is visible', + ({ hasNextPage, hasPreviousPage, isVisible }) => { + mountComponent({ pageInfo: { ...defaultPageInfo, hasNextPage, hasPreviousPage } }); + + expect(findPagination().exists()).toBe(isVisible); + expect(findPagination().props('hasPreviousPage')).toBe(hasPreviousPage); + expect(findPagination().props('hasNextPage')).toBe(hasNextPage); + }, + ); + + it('emits "prev-page" when the user clicks the back page button', () => { + mountComponent(); + + findPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev-page')).toEqual([[]]); + }); + + it('emits "next-page" when the user clicks the forward page button', () => { + mountComponent(); + + findPagination().vm.$emit('next'); + + expect(wrapper.emitted('next-page')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js new file mode 100644 index 00000000000..21748ae2813 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js @@ -0,0 +1,45 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import projectEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue'; +import { dockerCommands } from '../../mock_data'; +import { GlEmptyState } from '../../stubs'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Registry Project Empty state', () => { + let wrapper; + const config = { + repositoryUrl: 'foo', + registryHostUrlWithPort: 'bar', + helpPagePath: 'baz', + twoFactorAuthHelpLink: 'barBaz', + personalAccessTokensHelpLink: 'fooBaz', + noContainersImage: 'bazFoo', + }; + + beforeEach(() => { + wrapper = shallowMount(projectEmptyState, { + localVue, + stubs: { + GlEmptyState, + GlSprintf, + }, + provide() { + return { + config, + ...dockerCommands, + }; + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('to match the default snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js new file mode 100644 index 00000000000..92cfeb7633e --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -0,0 +1,135 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue'; +import { + CONTAINER_REGISTRY_TITLE, + LIST_INTRO_TEXT, + EXPIRATION_POLICY_DISABLED_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +jest.mock('~/lib/utils/datetime_utility', () => ({ + approximateDuration: jest.fn(), + calculateRemainingMilliseconds: jest.fn(), +})); + +describe('registry_header', () => { + let wrapper; + + const findTitleArea = () => wrapper.find(TitleArea); + const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]'); + const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]'); + const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]'); + + const mountComponent = (propsData, slots) => { + wrapper = shallowMount(Component, { + stubs: { + GlSprintf, + TitleArea, + }, + propsData, + slots, + }); + return wrapper.vm.$nextTick(); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('header', () => { + it('has a title', () => { + mountComponent({ metadataLoading: true }); + + expect(findTitleArea().props()).toMatchObject({ + title: CONTAINER_REGISTRY_TITLE, + metadataLoading: true, + }); + }); + + it('has a commands slot', () => { + mountComponent(null, { commands: '<div data-testid="commands-slot">baz</div>' }); + + expect(findCommandsSlot().text()).toBe('baz'); + }); + + describe('sub header parts', () => { + describe('images count', () => { + it('exists', async () => { + await mountComponent({ imagesCount: 1 }); + + expect(findImagesCountSubHeader().exists()).toBe(true); + }); + + it('when there is one image', async () => { + await mountComponent({ imagesCount: 1 }); + + expect(findImagesCountSubHeader().props()).toMatchObject({ + text: '1 Image repository', + icon: 'container-image', + }); + }); + + it('when there is more than one image', async () => { + await mountComponent({ imagesCount: 3 }); + + expect(findImagesCountSubHeader().props('text')).toBe('3 Image repositories'); + }); + }); + + describe('expiration policy', () => { + it('when is disabled', async () => { + await mountComponent({ + expirationPolicy: { enabled: false }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + }); + + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(true); + expect(text.props()).toMatchObject({ + text: EXPIRATION_POLICY_DISABLED_TEXT, + icon: 'expire', + size: 'xl', + }); + }); + + it('when is enabled', async () => { + await mountComponent({ + expirationPolicy: { enabled: true }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + }); + + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(true); + expect(text.props('text')).toBe('Expiration policy will run in '); + }); + it('when the expiration policy is completely disabled', async () => { + await mountComponent({ + expirationPolicy: { enabled: true }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + hideExpirationPolicyData: true, + }); + + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(false); + }); + }); + }); + }); + + describe('info messages', () => { + describe('default message', () => { + it('is correctly bound to title_area props', () => { + mountComponent({ helpPagePath: 'foo' }); + + expect(findTitleArea().props('infoMessages')).toEqual([ + { text: LIST_INTRO_TEXT, link: 'foo' }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js new file mode 100644 index 00000000000..e5a8438f23f --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js @@ -0,0 +1,78 @@ +import { mount } from '@vue/test-utils'; + +import component from '~/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue'; + +describe('Registry Breadcrumb', () => { + let wrapper; + const nameGenerator = jest.fn(); + + const routes = [ + { name: 'list', path: '/', meta: { nameGenerator, root: true } }, + { name: 'details', path: '/:id', meta: { nameGenerator } }, + ]; + + const mountComponent = ($route) => { + wrapper = mount(component, { + mocks: { + $route, + $router: { + options: { + routes, + }, + }, + }, + }); + }; + + beforeEach(() => { + nameGenerator.mockClear(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when is rootRoute', () => { + beforeEach(() => { + mountComponent(routes[0]); + }); + + it('renders', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('contains only a single router-link to list', () => { + const links = wrapper.findAll('a'); + + expect(links).toHaveLength(1); + expect(links.at(0).attributes('href')).toBe('/'); + }); + + it('the link text is calculated by nameGenerator', () => { + expect(nameGenerator).toHaveBeenCalledTimes(1); + }); + }); + + describe('when is not rootRoute', () => { + beforeEach(() => { + mountComponent(routes[1]); + }); + + it('renders', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('contains two router-links to list and details', () => { + const links = wrapper.findAll('a'); + + expect(links).toHaveLength(2); + expect(links.at(0).attributes('href')).toBe('/'); + expect(links.at(1).attributes('href')).toBe('#'); + }); + + it('the link text is calculated by nameGenerator', () => { + expect(nameGenerator).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js new file mode 100644 index 00000000000..6a835a28807 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js @@ -0,0 +1,269 @@ +export const imagesListResponse = [ + { + __typename: 'ContainerRepository', + id: 'gid://gitlab/ContainerRepository/26', + name: 'rails-12009', + path: 'gitlab-org/gitlab-test/rails-12009', + status: null, + location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009', + canDelete: true, + createdAt: '2020-11-03T13:29:21Z', + expirationPolicyStartedAt: null, + expirationPolicyCleanupStatus: 'UNSCHEDULED', + }, + { + __typename: 'ContainerRepository', + id: 'gid://gitlab/ContainerRepository/11', + name: 'rails-20572', + path: 'gitlab-org/gitlab-test/rails-20572', + status: null, + location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572', + canDelete: true, + createdAt: '2020-09-21T06:57:43Z', + expirationPolicyStartedAt: null, + expirationPolicyCleanupStatus: 'UNSCHEDULED', + }, +]; + +export const pageInfo = { + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'eyJpZCI6IjI2In0', + endCursor: 'eyJpZCI6IjgifQ', + __typename: 'ContainerRepositoryConnection', +}; + +export const graphQLImageListMock = { + data: { + project: { + __typename: 'Project', + containerRepositoriesCount: 2, + containerRepositories: { + __typename: 'ContainerRepositoryConnection', + nodes: imagesListResponse, + pageInfo, + }, + }, + }, +}; + +export const graphQLEmptyImageListMock = { + data: { + project: { + __typename: 'Project', + containerRepositoriesCount: 2, + containerRepositories: { + __typename: 'ContainerRepositoryConnection', + nodes: [], + pageInfo, + }, + }, + }, +}; + +export const graphQLEmptyGroupImageListMock = { + data: { + group: { + __typename: 'Group', + containerRepositoriesCount: 2, + containerRepositories: { + __typename: 'ContainerRepositoryConnection', + nodes: [], + pageInfo, + }, + }, + }, +}; + +export const deletedContainerRepository = { + id: 'gid://gitlab/ContainerRepository/11', + status: 'DELETE_SCHEDULED', + path: 'gitlab-org/gitlab-test/rails-12009', + __typename: 'ContainerRepository', +}; + +export const graphQLImageDeleteMock = { + data: { + destroyContainerRepository: { + containerRepository: { + ...deletedContainerRepository, + }, + errors: [], + __typename: 'DestroyContainerRepositoryPayload', + }, + }, +}; + +export const graphQLImageDeleteMockError = { + data: { + destroyContainerRepository: { + containerRepository: { + ...deletedContainerRepository, + }, + errors: ['foo'], + __typename: 'DestroyContainerRepositoryPayload', + }, + }, +}; + +export const containerRepositoryMock = { + id: 'gid://gitlab/ContainerRepository/26', + name: 'rails-12009', + path: 'gitlab-org/gitlab-test/rails-12009', + status: null, + location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009', + canDelete: true, + createdAt: '2020-11-03T13:29:21Z', + updatedAt: '2020-11-03T13:29:21Z', + expirationPolicyStartedAt: null, + expirationPolicyCleanupStatus: 'UNSCHEDULED', + project: { + visibility: 'public', + path: 'gitlab-test', + containerExpirationPolicy: { + enabled: false, + nextRunAt: '2020-11-27T08:59:27Z', + }, + __typename: 'Project', + }, +}; + +export const tagsPageInfo = { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'MQ', + endCursor: 'MTA', +}; + +export const tagsMock = [ + { + digest: 'sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062', + location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-24753', + path: 'gitlab-org/gitlab-test/rails-12009:beta-24753', + name: 'beta-24753', + revision: 'c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b', + shortRevision: 'c2613843a', + createdAt: '2020-11-03T13:29:38+00:00', + totalSize: '1099511627776', + canDelete: true, + __typename: 'ContainerRepositoryTag', + }, + { + digest: 'sha256:7f94f97dff89ffd122cafe50cd32329adf682356a7a96f69cbfe313ee589791c', + location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-31075', + path: 'gitlab-org/gitlab-test/rails-12009:beta-31075', + name: 'beta-31075', + revision: 'df44e7228f0f255c73e35b6f0699624a615f42746e3e8e2e4b3804a6d6fc3292', + shortRevision: 'df44e7228', + createdAt: '2020-11-03T13:29:32+00:00', + totalSize: '536870912000', + canDelete: true, + __typename: 'ContainerRepositoryTag', + }, +]; + +export const imageTagsMock = (nodes = tagsMock) => ({ + data: { + containerRepository: { + id: containerRepositoryMock.id, + tags: { + nodes, + pageInfo: { ...tagsPageInfo }, + __typename: 'ContainerRepositoryTagConnection', + }, + __typename: 'ContainerRepositoryDetails', + }, + }, +}); + +export const imageTagsCountMock = (override) => ({ + data: { + containerRepository: { + id: containerRepositoryMock.id, + tagsCount: 13, + ...override, + }, + }, +}); + +export const graphQLImageDetailsMock = (override) => ({ + data: { + containerRepository: { + ...containerRepositoryMock, + + tags: { + nodes: tagsMock, + pageInfo: { ...tagsPageInfo }, + __typename: 'ContainerRepositoryTagConnection', + }, + __typename: 'ContainerRepositoryDetails', + ...override, + }, + }, +}); + +export const graphQLImageDetailsEmptyTagsMock = { + data: { + containerRepository: { + ...containerRepositoryMock, + tags: { + nodes: [], + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + __typename: 'ContainerRepositoryTagConnection', + }, + __typename: 'ContainerRepositoryDetails', + }, + }, +}; + +export const graphQLDeleteImageRepositoryTagsMock = { + data: { + destroyContainerRepositoryTags: { + deletedTagNames: [], + errors: [], + __typename: 'DestroyContainerRepositoryTagsPayload', + }, + }, +}; + +export const dockerCommands = { + dockerBuildCommand: 'foofoo', + dockerPushCommand: 'barbar', + dockerLoginCommand: 'bazbaz', +}; + +export const graphQLProjectImageRepositoriesDetailsMock = { + data: { + project: { + containerRepositories: { + nodes: [ + { + id: 'gid://gitlab/ContainerRepository/26', + tagsCount: 4, + __typename: 'ContainerRepository', + }, + { + id: 'gid://gitlab/ContainerRepository/11', + tagsCount: 1, + __typename: 'ContainerRepository', + }, + ], + __typename: 'ContainerRepositoryConnection', + }, + __typename: 'Project', + }, + }, +}; + +export const graphQLEmptyImageDetailsMock = { + data: { + containerRepository: null, + }, +}; diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js new file mode 100644 index 00000000000..adc9a64e5c9 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js @@ -0,0 +1,521 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; +import DeleteAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue'; +import DetailsHeader from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue'; +import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; +import PartialCleanupAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue'; +import StatusAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue'; +import TagsList from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; +import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue'; + +import { + UNFINISHED_STATUS, + DELETE_SCHEDULED, + ALERT_DANGER_IMAGE, + MISSING_OR_DELETED_IMAGE_BREADCRUMB, + ROOT_IMAGE_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; +import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; + +import component from '~/packages_and_registries/container_registry/explorer/pages/details.vue'; +import Tracking from '~/tracking'; + +import { + graphQLImageDetailsMock, + graphQLDeleteImageRepositoryTagsMock, + containerRepositoryMock, + graphQLEmptyImageDetailsMock, + tagsMock, +} from '../mock_data'; +import { DeleteModal } from '../stubs'; + +const localVue = createLocalVue(); + +describe('Details Page', () => { + let wrapper; + let apolloProvider; + + const findDeleteModal = () => wrapper.find(DeleteModal); + const findPagination = () => wrapper.find(GlKeysetPagination); + const findTagsLoader = () => wrapper.find(TagsLoader); + const findTagsList = () => wrapper.find(TagsList); + const findDeleteAlert = () => wrapper.find(DeleteAlert); + const findDetailsHeader = () => wrapper.find(DetailsHeader); + const findEmptyState = () => wrapper.find(EmptyTagsState); + const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); + const findStatusAlert = () => wrapper.find(StatusAlert); + const findDeleteImage = () => wrapper.find(DeleteImage); + + const routeId = 1; + + const breadCrumbState = { + updateName: jest.fn(), + }; + + const cleanTags = tagsMock.map((t) => { + const result = { ...t }; + // eslint-disable-next-line no-underscore-dangle + delete result.__typename; + return result; + }); + + const waitForApolloRequestRender = async () => { + await waitForPromises(); + await wrapper.vm.$nextTick(); + }; + + const mountComponent = ({ + resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()), + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), + options, + config = {}, + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [getContainerRepositoryDetailsQuery, resolver], + [deleteContainerRepositoryTagsMutation, mutationResolver], + ]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMount(component, { + localVue, + apolloProvider, + stubs: { + DeleteModal, + DeleteImage, + }, + mocks: { + $route: { + params: { + id: routeId, + }, + }, + }, + provide() { + return { + breadCrumbState, + config, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when isLoading is true', () => { + it('shows the loader', () => { + mountComponent(); + + expect(findTagsLoader().exists()).toBe(true); + }); + + it('does not show the list', () => { + mountComponent(); + + expect(findTagsList().exists()).toBe(false); + }); + }); + + describe('when the image does not exist', () => { + it('does not show the default ui', async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(findTagsLoader().exists()).toBe(false); + expect(findDetailsHeader().exists()).toBe(false); + expect(findTagsList().exists()).toBe(false); + expect(findPagination().exists()).toBe(false); + }); + + it('shows an empty state message', async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(findEmptyState().exists()).toBe(true); + }); + }); + + describe('list', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findTagsList().exists()).toBe(true); + }); + + it('has the correct props bound', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findTagsList().props()).toMatchObject({ + isMobile: false, + }); + }); + + describe('deleteEvent', () => { + describe('single item', () => { + let tagToBeDeleted; + beforeEach(async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + [tagToBeDeleted] = cleanTags; + findTagsList().vm.$emit('delete', [tagToBeDeleted]); + }); + + it('open the modal', async () => { + expect(DeleteModal.methods.show).toHaveBeenCalled(); + }); + + it('tracks a single delete event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'registry_tag_delete', + }); + }); + }); + + describe('multiple items', () => { + beforeEach(async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findTagsList().vm.$emit('delete', cleanTags); + }); + + it('open the modal', () => { + expect(DeleteModal.methods.show).toHaveBeenCalled(); + }); + + it('tracks a single delete event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'bulk_registry_tag_delete', + }); + }); + }); + }); + }); + + describe('modal', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findDeleteModal().exists()).toBe(true); + }); + + describe('cancel event', () => { + it('tracks cancel_delete', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findDeleteModal().vm.$emit('cancel'); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', { + label: 'registry_tag_delete', + }); + }); + }); + + describe('confirmDelete event', () => { + let mutationResolver; + + beforeEach(() => { + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); + mountComponent({ mutationResolver }); + + return waitForApolloRequestRender(); + }); + describe('when one item is selected to be deleted', () => { + it('calls apollo mutation with the right parameters', async () => { + findTagsList().vm.$emit('delete', [cleanTags[0]]); + + await wrapper.vm.$nextTick(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: [cleanTags[0].name] }), + ); + }); + }); + + describe('when more than one item is selected to be deleted', () => { + it('calls apollo mutation with the right parameters', async () => { + findTagsList().vm.$emit('delete', tagsMock); + + await wrapper.vm.$nextTick(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }), + ); + }); + }); + }); + }); + + describe('Header', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findDetailsHeader().exists()).toBe(true); + }); + + it('has the correct props', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findDetailsHeader().props()).toMatchObject({ + image: { + name: containerRepositoryMock.name, + project: { + visibility: containerRepositoryMock.project.visibility, + }, + }, + }); + }); + }); + + describe('Delete Alert', () => { + const config = { + isAdmin: true, + garbageCollectionHelpPagePath: 'baz', + }; + const deleteAlertType = 'success_tag'; + + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findDeleteAlert().exists()).toBe(true); + }); + + it('has the correct props', async () => { + mountComponent({ + options: { + data: () => ({ + deleteAlertType, + }), + }, + config, + }); + + await waitForApolloRequestRender(); + + expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType }); + }); + }); + + describe('Partial Cleanup Alert', () => { + const config = { + runCleanupPoliciesHelpPagePath: 'foo', + expirationPolicyHelpPagePath: 'bar', + userCalloutsPath: 'call_out_path', + userCalloutId: 'call_out_id', + showUnfinishedTagCleanupCallout: true, + }; + + describe(`when expirationPolicyCleanupStatus is ${UNFINISHED_STATUS}`, () => { + let resolver; + + beforeEach(() => { + resolver = jest.fn().mockResolvedValue( + graphQLImageDetailsMock({ + expirationPolicyCleanupStatus: UNFINISHED_STATUS, + }), + ); + }); + + it('exists', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(true); + }); + + it('has the correct props', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().props()).toEqual({ + runCleanupPoliciesHelpPagePath: config.runCleanupPoliciesHelpPagePath, + cleanupPoliciesHelpPagePath: config.expirationPolicyHelpPagePath, + }); + }); + + it('dismiss hides the component', async () => { + jest.spyOn(axios, 'post').mockReturnValue(); + + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(true); + + findPartialCleanupAlert().vm.$emit('dismiss'); + + await wrapper.vm.$nextTick(); + + expect(axios.post).toHaveBeenCalledWith(config.userCalloutsPath, { + feature_name: config.userCalloutId, + }); + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + + it('is hidden if the callout is dismissed', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + }); + + describe(`when expirationPolicyCleanupStatus is not ${UNFINISHED_STATUS}`, () => { + it('the component is hidden', async () => { + mountComponent({ config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + }); + }); + + describe('Breadcrumb connection', () => { + it('when the details are fetched updates the name', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); + }); + + it(`when the image is missing set the breadcrumb to ${MISSING_OR_DELETED_IMAGE_BREADCRUMB}`, async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB); + }); + + it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => { + mountComponent({ + resolver: jest + .fn() + .mockResolvedValue(graphQLImageDetailsMock({ ...containerRepositoryMock, name: null })), + }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT); + }); + }); + + describe('when the image has a status different from null', () => { + const resolver = jest + .fn() + .mockResolvedValue(graphQLImageDetailsMock({ status: DELETE_SCHEDULED })); + it('disables all the actions', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findDetailsHeader().props('disabled')).toBe(true); + expect(findTagsList().props('disabled')).toBe(true); + }); + + it('shows a status alert', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findStatusAlert().exists()).toBe(true); + expect(findStatusAlert().props()).toMatchObject({ + status: DELETE_SCHEDULED, + }); + }); + }); + + describe('delete the image', () => { + const mountComponentAndDeleteImage = async () => { + mountComponent(); + + await waitForApolloRequestRender(); + findDetailsHeader().vm.$emit('delete'); + + await wrapper.vm.$nextTick(); + }; + + it('on delete event it deletes the image', async () => { + await mountComponentAndDeleteImage(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(findDeleteImage().emitted('start')).toEqual([[]]); + }); + + it('binds the correct props to the modal', async () => { + await mountComponentAndDeleteImage(); + + expect(findDeleteModal().props()).toMatchObject({ + itemsToBeDeleted: [{ path: 'gitlab-org/gitlab-test/rails-12009' }], + deleteImage: true, + }); + }); + + it('binds correctly to delete-image start and end events', async () => { + mountComponent(); + + findDeleteImage().vm.$emit('start'); + + await wrapper.vm.$nextTick(); + + expect(findTagsLoader().exists()).toBe(true); + + findDeleteImage().vm.$emit('end'); + + await wrapper.vm.$nextTick(); + + expect(findTagsLoader().exists()).toBe(false); + }); + + it('binds correctly to delete-image error event', async () => { + mountComponent(); + + findDeleteImage().vm.$emit('error'); + + await wrapper.vm.$nextTick(); + + expect(findDeleteAlert().props('deleteAlertType')).toBe(ALERT_DANGER_IMAGE); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js new file mode 100644 index 00000000000..5f4cb8969bc --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js @@ -0,0 +1,24 @@ +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/pages/index.vue'; + +describe('List Page', () => { + let wrapper; + + const findRouterView = () => wrapper.find({ ref: 'router-view' }); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { + RouterView: true, + }, + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + it('has a router view', () => { + expect(findRouterView().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js new file mode 100644 index 00000000000..051d1e2a169 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js @@ -0,0 +1,597 @@ +import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; +import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; +import CliCommands from '~/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue'; +import GroupEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue'; +import ImageList from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue'; +import ProjectEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue'; +import RegistryHeader from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue'; +import { + DELETE_IMAGE_SUCCESS_MESSAGE, + DELETE_IMAGE_ERROR_MESSAGE, + SORT_FIELDS, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import deleteContainerRepositoryMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql'; +import getContainerRepositoriesDetails from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql'; +import component from '~/packages_and_registries/container_registry/explorer/pages/list.vue'; +import Tracking from '~/tracking'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +import { $toast } from 'jest/packages_and_registries/shared/mocks'; +import { + graphQLImageListMock, + graphQLImageDeleteMock, + deletedContainerRepository, + graphQLEmptyImageListMock, + graphQLEmptyGroupImageListMock, + pageInfo, + graphQLProjectImageRepositoriesDetailsMock, + dockerCommands, +} from '../mock_data'; +import { GlModal, GlEmptyState } from '../stubs'; + +const localVue = createLocalVue(); + +describe('List Page', () => { + let wrapper; + let apolloProvider; + + const findDeleteModal = () => wrapper.findComponent(GlModal); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const findCliCommands = () => wrapper.findComponent(CliCommands); + const findProjectEmptyState = () => wrapper.findComponent(ProjectEmptyState); + const findGroupEmptyState = () => wrapper.findComponent(GroupEmptyState); + const findRegistryHeader = () => wrapper.findComponent(RegistryHeader); + + const findDeleteAlert = () => wrapper.findComponent(GlAlert); + const findImageList = () => wrapper.findComponent(ImageList); + const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); + const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); + const findDeleteImage = () => wrapper.findComponent(DeleteImage); + const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); + + const waitForApolloRequestRender = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + await nextTick(); + }; + + const mountComponent = ({ + mocks, + resolver = jest.fn().mockResolvedValue(graphQLImageListMock), + detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock), + mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock), + config = { isGroupPage: false }, + query = {}, + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [getContainerRepositoriesQuery, resolver], + [getContainerRepositoriesDetails, detailsResolver], + [deleteContainerRepositoryMutation, mutationResolver], + ]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMount(component, { + localVue, + apolloProvider, + stubs: { + GlModal, + GlEmptyState, + GlSprintf, + RegistryHeader, + TitleArea, + DeleteImage, + }, + mocks: { + $toast, + $route: { + name: 'foo', + query, + }, + ...mocks, + }, + provide() { + return { + config, + ...dockerCommands, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains registry header', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findRegistryHeader().exists()).toBe(true); + expect(findRegistryHeader().props()).toMatchObject({ + imagesCount: 2, + metadataLoading: false, + }); + }); + + describe.each([ + { error: 'connectionError', errorName: 'connection error' }, + { error: 'invalidPathError', errorName: 'invalid path error' }, + ])('handling $errorName', ({ error }) => { + const config = { + containersErrorImage: 'foo', + helpPagePath: 'bar', + isGroupPage: false, + }; + config[error] = true; + + it('should show an empty state', () => { + mountComponent({ config }); + + expect(findEmptyState().exists()).toBe(true); + }); + + it('empty state should have an svg-path', () => { + mountComponent({ config }); + + expect(findEmptyState().props('svgPath')).toBe(config.containersErrorImage); + }); + + it('empty state should have a description', () => { + mountComponent({ config }); + + expect(findEmptyState().props('title')).toContain('connection error'); + }); + + it('should not show the loading or default state', () => { + mountComponent({ config }); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findImageList().exists()).toBe(false); + }); + }); + + describe('isLoading is true', () => { + it('shows the skeleton loader', async () => { + mountComponent(); + + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('imagesList is not visible', () => { + mountComponent(); + + expect(findImageList().exists()).toBe(false); + }); + + it('cli commands is not visible', () => { + mountComponent(); + + expect(findCliCommands().exists()).toBe(false); + }); + + it('title has the metadataLoading props set to true', async () => { + mountComponent(); + + await nextTick(); + + expect(findRegistryHeader().props('metadataLoading')).toBe(true); + }); + }); + + describe('list is empty', () => { + describe('project page', () => { + const resolver = jest.fn().mockResolvedValue(graphQLEmptyImageListMock); + + it('cli commands is not visible', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findCliCommands().exists()).toBe(false); + }); + + it('project empty state is visible', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findProjectEmptyState().exists()).toBe(true); + }); + }); + + describe('group page', () => { + const resolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock); + + const config = { + isGroupPage: true, + }; + + it('group empty state is visible', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findGroupEmptyState().exists()).toBe(true); + }); + + it('cli commands is not visible', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findCliCommands().exists()).toBe(false); + }); + }); + }); + + describe('list is not empty', () => { + describe('unfiltered state', () => { + it('quick start is visible', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findCliCommands().exists()).toBe(true); + }); + + it('list component is visible', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findImageList().exists()).toBe(true); + }); + + describe('additional metadata', () => { + it('is called on component load', async () => { + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ detailsResolver }); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(detailsResolver).toHaveBeenCalled(); + }); + + it('does not block the list ui to show', async () => { + const detailsResolver = jest.fn().mockRejectedValue(); + mountComponent({ detailsResolver }); + + await waitForApolloRequestRender(); + + expect(findImageList().exists()).toBe(true); + }); + + it('loading state is passed to list component', async () => { + // this is a promise that never resolves, to trick apollo to think that this request is still loading + const detailsResolver = jest.fn().mockImplementation(() => new Promise(() => {})); + + mountComponent({ detailsResolver }); + await waitForApolloRequestRender(); + + expect(findImageList().props('metadataLoading')).toBe(true); + }); + }); + + describe('delete image', () => { + const selectImageForDeletion = async () => { + await waitForApolloRequestRender(); + + findImageList().vm.$emit('delete', deletedContainerRepository); + }; + + it('should call deleteItem when confirming deletion', async () => { + const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock); + mountComponent({ mutationResolver }); + + await selectImageForDeletion(); + + findDeleteModal().vm.$emit('primary'); + await waitForApolloRequestRender(); + + expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository); + + const updatedImage = findImageList() + .props('images') + .find((i) => i.id === deletedContainerRepository.id); + + expect(updatedImage.status).toBe(deletedContainerRepository.status); + }); + + it('should show a success alert when delete request is successful', async () => { + mountComponent(); + + await selectImageForDeletion(); + + findDeleteImage().vm.$emit('success'); + await nextTick(); + + const alert = findDeleteAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( + DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), + ); + }); + + describe('when delete request fails it shows an alert', () => { + it('user recoverable error', async () => { + mountComponent(); + + await selectImageForDeletion(); + + findDeleteImage().vm.$emit('error'); + await nextTick(); + + const alert = findDeleteAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( + DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), + ); + }); + }); + }); + }); + + describe('search and sorting', () => { + const doSearch = async () => { + await waitForApolloRequestRender(); + findRegistrySearch().vm.$emit('filter:changed', [ + { type: FILTERED_SEARCH_TERM, value: { data: 'centos6' } }, + ]); + + findRegistrySearch().vm.$emit('filter:submit'); + + await nextTick(); + }; + + it('has a search box element', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + const registrySearch = findRegistrySearch(); + expect(registrySearch.exists()).toBe(true); + expect(registrySearch.props()).toMatchObject({ + filter: [], + sorting: { orderBy: 'UPDATED', sort: 'desc' }, + sortableFields: SORT_FIELDS, + tokens: [], + }); + }); + + it('performs sorting', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' }); + await nextTick(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' })); + }); + + it('performs a search', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + await doSearch(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ name: 'centos6' })); + }); + + it('when search result is empty displays an empty search message', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ resolver, detailsResolver }); + + await waitForApolloRequestRender(); + + resolver.mockResolvedValue(graphQLEmptyImageListMock); + detailsResolver.mockResolvedValue(graphQLEmptyImageListMock); + + await doSearch(); + + expect(findEmptySearchMessage().exists()).toBe(true); + }); + }); + + describe('pagination', () => { + it('prev-page event triggers a fetchMore request', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ resolver, detailsResolver }); + + await waitForApolloRequestRender(); + + findImageList().vm.$emit('prev-page'); + await nextTick(); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ before: pageInfo.startCursor }), + ); + expect(detailsResolver).toHaveBeenCalledWith( + expect.objectContaining({ before: pageInfo.startCursor }), + ); + }); + + it('next-page event triggers a fetchMore request', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ resolver, detailsResolver }); + + await waitForApolloRequestRender(); + + findImageList().vm.$emit('next-page'); + await nextTick(); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ after: pageInfo.endCursor }), + ); + expect(detailsResolver).toHaveBeenCalledWith( + expect.objectContaining({ after: pageInfo.endCursor }), + ); + }); + }); + }); + + describe('modal', () => { + beforeEach(() => { + mountComponent(); + }); + + it('exists', () => { + expect(findDeleteModal().exists()).toBe(true); + }); + + it('contains a description with the path of the item to delete', async () => { + findImageList().vm.$emit('delete', { path: 'foo' }); + await nextTick(); + expect(findDeleteModal().html()).toContain('foo'); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + mountComponent(); + }); + + const testTrackingCall = (action) => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, action, { + label: 'registry_repository_delete', + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + }); + + it('send an event when delete button is clicked', () => { + findImageList().vm.$emit('delete', {}); + + testTrackingCall('click_button'); + }); + + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + testTrackingCall('cancel_delete'); + }); + + it('send an event when the deletion starts', () => { + findDeleteImage().vm.$emit('start'); + testTrackingCall('confirm_delete'); + }); + }); + + describe('url query string handling', () => { + const defaultQueryParams = { + search: [1, 2], + sort: 'asc', + orderBy: 'CREATED', + }; + const queryChangePayload = 'foo'; + + it('query:updated event pushes the new query to the router', async () => { + const push = jest.fn(); + mountComponent({ mocks: { $router: { push } } }); + + await nextTick(); + + findRegistrySearch().vm.$emit('query:changed', queryChangePayload); + + expect(push).toHaveBeenCalledWith({ query: queryChangePayload }); + }); + + it('graphql API call has the variables set from the URL', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ query: defaultQueryParams, resolver }); + + await nextTick(); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + name: 1, + sort: 'CREATED_ASC', + }), + ); + }); + + it.each` + sort | orderBy | search | payload + ${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }} + ${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }} + ${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }} + ${undefined} | ${undefined} | ${undefined} | ${{}} + ${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }} + ${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }} + ${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }} + ${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }} + `( + 'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload', + async ({ sort, orderBy, search, payload }) => { + const resolver = jest.fn().mockResolvedValue({ sort, orderBy }); + mountComponent({ query: { sort, orderBy, search }, resolver }); + + await nextTick(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload)); + }, + ); + }); + + describe('cleanup is on alert', () => { + it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => { + mountComponent({ + config: { + showCleanupPolicyOnAlert: true, + projectPath: 'foo', + isGroupPage: false, + cleanupPoliciesSettingsPath: 'bar', + }, + }); + + await waitForApolloRequestRender(); + + expect(findCleanupAlert().exists()).toBe(true); + expect(findCleanupAlert().props()).toMatchObject({ + projectPath: 'foo', + cleanupPoliciesSettingsPath: 'bar', + }); + }); + + it('is hidden when showCleanupPolicyOnAlert is false', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findCleanupAlert().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js b/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js new file mode 100644 index 00000000000..7d281a53a59 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js @@ -0,0 +1,45 @@ +import { + GlModal as RealGlModal, + GlEmptyState as RealGlEmptyState, + GlSkeletonLoader as RealGlSkeletonLoader, + GlDropdown as RealGlDropdown, +} from '@gitlab/ui'; +import { RouterLinkStub } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import RealDeleteModal from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue'; +import RealListItem from '~/vue_shared/components/registry/list_item.vue'; + +export const GlModal = stubComponent(RealGlModal, { + template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', + methods: { + show: jest.fn(), + }, +}); + +export const GlEmptyState = stubComponent(RealGlEmptyState, { + template: '<div><slot name="description"></slot></div>', +}); + +export const RouterLink = RouterLinkStub; + +export const DeleteModal = stubComponent(RealDeleteModal, { + methods: { + show: jest.fn(), + }, +}); + +export const GlSkeletonLoader = stubComponent(RealGlSkeletonLoader); + +export const ListItem = { + ...RealListItem, + data() { + return { + detailsSlots: [], + isDetailsShown: true, + }; + }, +}; + +export const GlDropdown = stubComponent(RealGlDropdown, { + template: '<div><slot></slot></div>', +}); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js index 1f0252965b0..625f00a8666 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -1,32 +1,40 @@ -import { GlFormInputGroup, GlFormGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; +import { + GlFormInputGroup, + GlFormGroup, + GlSkeletonLoader, + GlSprintf, + GlEmptyState, +} from '@gitlab/ui'; import { createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stripTypenames } from 'helpers/graphql_helpers'; import waitForPromises from 'helpers/wait_for_promises'; +import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants'; import DependencyProxyApp from '~/packages_and_registries/dependency_proxy/app.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; -import { proxyDetailsQuery, proxyData } from './mock_data'; +import { proxyDetailsQuery, proxyData, pagination, proxyManifests } from './mock_data'; const localVue = createLocalVue(); describe('DependencyProxyApp', () => { let wrapper; let apolloProvider; + let resolver; const provideDefaults = { groupPath: 'gitlab-org', dependencyProxyAvailable: true, + noManifestsIllustration: 'noManifestsIllustration', }; - function createComponent({ - provide = provideDefaults, - resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()), - } = {}) { + function createComponent({ provide = provideDefaults } = {}) { localVue.use(VueApollo); const requestHandlers = [[getDependencyProxyDetailsQuery, resolver]]; @@ -53,6 +61,12 @@ describe('DependencyProxyApp', () => { const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findMainArea = () => wrapper.findByTestId('main-area'); const findProxyCountText = () => wrapper.findByTestId('proxy-count'); + const findManifestList = () => wrapper.findComponent(ManifestsList); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + beforeEach(() => { + resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); + }); afterEach(() => { wrapper.destroy(); @@ -78,8 +92,8 @@ describe('DependencyProxyApp', () => { }); it('does not call the graphql endpoint', async () => { - const resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); - createComponent({ ...createComponentArguments, resolver }); + resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); + createComponent({ ...createComponentArguments }); await waitForPromises(); @@ -145,14 +159,73 @@ describe('DependencyProxyApp', () => { it('from group has a description with proxy count', () => { expect(findProxyCountText().text()).toBe('Contains 2 blobs of images (1024 Bytes)'); }); + + describe('manifest lists', () => { + describe('when there are no manifests', () => { + beforeEach(() => { + resolver = jest.fn().mockResolvedValue( + proxyDetailsQuery({ + extend: { dependencyProxyManifests: { nodes: [], pageInfo: pagination() } }, + }), + ); + createComponent(); + return waitForPromises(); + }); + + it('shows the empty state message', () => { + expect(findEmptyState().props()).toMatchObject({ + svgPath: provideDefaults.noManifestsIllustration, + title: DependencyProxyApp.i18n.noManifestTitle, + }); + }); + + it('hides the list', () => { + expect(findManifestList().exists()).toBe(false); + }); + }); + + describe('when there are manifests', () => { + it('hides the empty state message', () => { + expect(findEmptyState().exists()).toBe(false); + }); + + it('shows list', () => { + expect(findManifestList().props()).toMatchObject({ + manifests: proxyManifests(), + pagination: stripTypenames(pagination()), + }); + }); + + it('prev-page event on list fetches the previous page', () => { + findManifestList().vm.$emit('prev-page'); + + expect(resolver).toHaveBeenCalledWith({ + before: pagination().startCursor, + first: null, + fullPath: provideDefaults.groupPath, + last: GRAPHQL_PAGE_SIZE, + }); + }); + + it('next-page event on list fetches the next page', () => { + findManifestList().vm.$emit('next-page'); + + expect(resolver).toHaveBeenCalledWith({ + after: pagination().endCursor, + first: GRAPHQL_PAGE_SIZE, + fullPath: provideDefaults.groupPath, + }); + }); + }); + }); }); + describe('when the dependency proxy is disabled', () => { beforeEach(() => { - createComponent({ - resolver: jest - .fn() - .mockResolvedValue(proxyDetailsQuery({ extendSettings: { enabled: false } })), - }); + resolver = jest + .fn() + .mockResolvedValue(proxyDetailsQuery({ extendSettings: { enabled: false } })); + createComponent(); return waitForPromises(); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js new file mode 100644 index 00000000000..9e4c747a1bd --- /dev/null +++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js @@ -0,0 +1,84 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { stripTypenames } from 'helpers/graphql_helpers'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; + +import Component from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; +import { + proxyManifests, + pagination, +} from 'jest/packages_and_registries/dependency_proxy/mock_data'; + +describe('Manifests List', () => { + let wrapper; + + const defaultProps = { + manifests: proxyManifests(), + pagination: stripTypenames(pagination()), + }; + + const createComponent = (propsData = defaultProps) => { + wrapper = shallowMountExtended(Component, { + propsData, + }); + }; + + const findRows = () => wrapper.findAllComponents(ManifestRow); + const findPagination = () => wrapper.findComponent(GlKeysetPagination); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has the correct title', () => { + createComponent(); + + expect(wrapper.text()).toContain(Component.i18n.listTitle); + }); + + it('shows a row for every manifest', () => { + createComponent(); + + expect(findRows().length).toBe(defaultProps.manifests.length); + }); + + it('binds a manifest to each row', () => { + createComponent(); + + expect(findRows().at(0).props()).toMatchObject({ + manifest: defaultProps.manifests[0], + }); + }); + + describe('pagination', () => { + it('is hidden when there is no next or prev pages', () => { + createComponent({ ...defaultProps, pagination: {} }); + + expect(findPagination().exists()).toBe(false); + }); + + it('has the correct props', () => { + createComponent(); + + expect(findPagination().props()).toMatchObject({ + ...defaultProps.pagination, + }); + }); + + it('emits the next-page event', () => { + createComponent(); + + findPagination().vm.$emit('next'); + + expect(wrapper.emitted('next-page')).toEqual([[]]); + }); + + it('emits the prev-page event', () => { + createComponent(); + + findPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev-page')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js new file mode 100644 index 00000000000..b7cbd875497 --- /dev/null +++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_row_spec.js @@ -0,0 +1,59 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import Component from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; +import { proxyManifests } from 'jest/packages_and_registries/dependency_proxy/mock_data'; + +describe('Manifest Row', () => { + let wrapper; + + const defaultProps = { + manifest: proxyManifests()[0], + }; + + const createComponent = (propsData = defaultProps) => { + wrapper = shallowMountExtended(Component, { + propsData, + stubs: { + GlSprintf, + TimeagoTooltip, + ListItem, + }, + }); + }; + + const findListItem = () => wrapper.findComponent(ListItem); + const findCachedMessages = () => wrapper.findByTestId('cached-message'); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has a list item', () => { + expect(findListItem().exists()).toBe(true); + }); + + it('displays the name', () => { + expect(wrapper.text()).toContain('alpine'); + }); + + it('displays the version', () => { + expect(wrapper.text()).toContain('latest'); + }); + + it('displays the cached time', () => { + expect(findCachedMessages().text()).toContain('Cached'); + }); + + it('has a time ago tooltip component', () => { + expect(findTimeAgoTooltip().props()).toMatchObject({ + time: defaultProps.manifest.createdAt, + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js index 23d42e109f9..8bad22b5287 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js @@ -7,7 +7,21 @@ export const proxyData = () => ({ export const proxySettings = (extend = {}) => ({ enabled: true, ...extend }); -export const proxyDetailsQuery = ({ extendSettings = {} } = {}) => ({ +export const proxyManifests = () => [ + { createdAt: '2021-09-22T09:45:28Z', imageName: 'alpine:latest' }, + { createdAt: '2021-09-21T09:45:28Z', imageName: 'alpine:stable' }, +]; + +export const pagination = (extend) => ({ + endCursor: 'eyJpZCI6IjIwNSIsIm5hbWUiOiJteS9jb21wYW55L2FwcC9teS1hcHAifQ', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'eyJpZCI6IjI0NyIsIm5hbWUiOiJ2ZXJzaW9uX3Rlc3QxIn0', + __typename: 'PageInfo', + ...extend, +}); + +export const proxyDetailsQuery = ({ extendSettings = {}, extend } = {}) => ({ data: { group: { ...proxyData(), @@ -16,6 +30,11 @@ export const proxyDetailsQuery = ({ extendSettings = {} } = {}) => ({ ...proxySettings(extendSettings), __typename: 'DependencyProxySetting', }, + dependencyProxyManifests: { + nodes: proxyManifests(), + pageInfo: pagination(), + }, + ...extend, }, }, }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap index 451cf743e35..519014bb9cf 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap @@ -19,15 +19,15 @@ exports[`PackageTitle renders with tags 1`] = ` <div class="gl-display-flex gl-flex-direction-column" > - <h1 - class="gl-font-size-h1 gl-mt-3 gl-mb-2" + <h2 + class="gl-font-size-h1 gl-mt-3 gl-mb-0" data-testid="title" > @gitlab-org/package-15 - </h1> + </h2> <div - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3" > <gl-icon-stub class="gl-mr-3" @@ -117,15 +117,15 @@ exports[`PackageTitle renders without tags 1`] = ` <div class="gl-display-flex gl-flex-direction-column" > - <h1 - class="gl-font-size-h1 gl-mt-3 gl-mb-2" + <h2 + class="gl-font-size-h1 gl-mt-3 gl-mb-0" data-testid="title" > @gitlab-org/package-15 - </h1> + </h2> <div - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3" > <gl-icon-stub class="gl-mr-3" diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap index 8f69f943112..c95538546c1 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap @@ -27,6 +27,7 @@ exports[`VersionRow renders 1`] = ` > <span class="gl-truncate" + data-testid="truncate-end-container" title="@gitlab-org/package-15" > <span diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js index 5119512564f..0bea84693f6 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js @@ -16,16 +16,15 @@ import PackageFiles from '~/packages_and_registries/package_registry/components/ import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; +import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; import { FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, - DELETE_PACKAGE_ERROR_MESSAGE, PACKAGE_TYPE_COMPOSER, DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, PACKAGE_TYPE_NUGET, } from '~/packages_and_registries/package_registry/constants'; -import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql'; import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql'; import { @@ -34,8 +33,6 @@ import { packageVersions, dependencyLinks, emptyPackageDetailsQuery, - packageDestroyMutation, - packageDestroyMutationError, packageFiles, packageDestroyFileMutation, packageDestroyFileMutationError, @@ -64,14 +61,12 @@ describe('PackagesApp', () => { function createComponent({ resolver = jest.fn().mockResolvedValue(packageDetailsQuery()), - mutationResolver = jest.fn().mockResolvedValue(packageDestroyMutation()), fileDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFileMutation()), } = {}) { localVue.use(VueApollo); const requestHandlers = [ [getPackageDetails, resolver], - [destroyPackageMutation, mutationResolver], [destroyPackageFileMutation, fileDeleteMutationResolver], ]; apolloProvider = createMockApollo(requestHandlers); @@ -82,6 +77,7 @@ describe('PackagesApp', () => { provide, stubs: { PackageTitle, + DeletePackage, GlModal: { template: '<div></div>', methods: { @@ -108,6 +104,7 @@ describe('PackagesApp', () => { const findDependenciesCountBadge = () => wrapper.findComponent(GlBadge); const findNoDependenciesMessage = () => wrapper.findByTestId('no-dependencies-message'); const findDependencyRows = () => wrapper.findAllComponents(DependencyRow); + const findDeletePackage = () => wrapper.findComponent(DeletePackage); afterEach(() => { wrapper.destroy(); @@ -187,14 +184,6 @@ describe('PackagesApp', () => { }); }; - const performDeletePackage = async () => { - await findDeleteButton().trigger('click'); - - findDeleteModal().vm.$emit('primary'); - - await waitForPromises(); - }; - afterEach(() => { Object.defineProperty(document, 'referrer', { value: originalReferrer, @@ -220,7 +209,7 @@ describe('PackagesApp', () => { await waitForPromises(); - await performDeletePackage(); + findDeletePackage().vm.$emit('end'); expect(window.location.replace).toHaveBeenCalledWith( 'projectListUrl?showSuccessDeleteAlert=true', @@ -234,45 +223,13 @@ describe('PackagesApp', () => { await waitForPromises(); - await performDeletePackage(); + findDeletePackage().vm.$emit('end'); expect(window.location.replace).toHaveBeenCalledWith( 'groupListUrl?showSuccessDeleteAlert=true', ); }); }); - - describe('request failure', () => { - it('on global failure it displays an alert', async () => { - createComponent({ mutationResolver: jest.fn().mockRejectedValue() }); - - await waitForPromises(); - - await performDeletePackage(); - - expect(createFlash).toHaveBeenCalledWith( - expect.objectContaining({ - message: DELETE_PACKAGE_ERROR_MESSAGE, - }), - ); - }); - - it('on payload with error it displays an alert', async () => { - createComponent({ - mutationResolver: jest.fn().mockResolvedValue(packageDestroyMutationError()), - }); - - await waitForPromises(); - - await performDeletePackage(); - - expect(createFlash).toHaveBeenCalledWith( - expect.objectContaining({ - message: DELETE_PACKAGE_ERROR_MESSAGE, - }), - ); - }); - }); }); describe('package files', () => { diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js index b24946c8638..8bb05b00e65 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/installations_commands_spec.js @@ -33,12 +33,12 @@ describe('InstallationCommands', () => { }); } - const npmInstallation = () => wrapper.find(NpmInstallation); - const mavenInstallation = () => wrapper.find(MavenInstallation); - const conanInstallation = () => wrapper.find(ConanInstallation); - const nugetInstallation = () => wrapper.find(NugetInstallation); - const pypiInstallation = () => wrapper.find(PypiInstallation); - const composerInstallation = () => wrapper.find(ComposerInstallation); + const npmInstallation = () => wrapper.findComponent(NpmInstallation); + const mavenInstallation = () => wrapper.findComponent(MavenInstallation); + const conanInstallation = () => wrapper.findComponent(ConanInstallation); + const nugetInstallation = () => wrapper.findComponent(NugetInstallation); + const pypiInstallation = () => wrapper.findComponent(PypiInstallation); + const composerInstallation = () => wrapper.findComponent(ComposerInstallation); afterEach(() => { wrapper.destroy(); @@ -57,7 +57,7 @@ describe('InstallationCommands', () => { it(`${packageEntity.packageType} instructions exist`, () => { createComponent({ packageEntity }); - expect(selector()).toExist(); + expect(selector().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js b/spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js new file mode 100644 index 00000000000..5de30829fa5 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/functional/delete_package_spec.js @@ -0,0 +1,160 @@ +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import createFlash from '~/flash'; +import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; + +import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; +import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; +import { + packageDestroyMutation, + packageDestroyMutationError, + packagesListQuery, +} from '../../mock_data'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); + +describe('DeletePackage', () => { + let wrapper; + let apolloProvider; + let resolver; + let mutationResolver; + + const eventPayload = { id: '1' }; + + function createComponent(propsData = {}) { + localVue.use(VueApollo); + + const requestHandlers = [ + [getPackagesQuery, resolver], + [destroyPackageMutation, mutationResolver], + ]; + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(DeletePackage, { + propsData, + localVue, + apolloProvider, + scopedSlots: { + default(props) { + return this.$createElement('button', { + attrs: { + 'data-testid': 'trigger-button', + }, + on: { + click: props.deletePackage, + }, + }); + }, + }, + }); + } + + const findButton = () => wrapper.findByTestId('trigger-button'); + + const clickOnButtonAndWait = (payload) => { + findButton().trigger('click', payload); + return waitForPromises(); + }; + + beforeEach(() => { + resolver = jest.fn().mockResolvedValue(packagesListQuery()); + mutationResolver = jest.fn().mockResolvedValue(packageDestroyMutation()); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('binds deletePackage method to the default slot', () => { + createComponent(); + + findButton().trigger('click'); + + expect(wrapper.emitted('start')).toEqual([[]]); + }); + + it('calls apollo mutation', async () => { + createComponent(); + + await clickOnButtonAndWait(eventPayload); + + expect(mutationResolver).toHaveBeenCalledWith(eventPayload); + }); + + it('passes refetchQueries to apollo mutate', async () => { + const variables = { isGroupPage: true }; + createComponent({ + refetchQueries: [{ query: getPackagesQuery, variables }], + }); + + await clickOnButtonAndWait(eventPayload); + + expect(mutationResolver).toHaveBeenCalledWith(eventPayload); + expect(resolver).toHaveBeenCalledWith(variables); + }); + + describe('on mutation success', () => { + it('emits end event', async () => { + createComponent(); + + await clickOnButtonAndWait(eventPayload); + + expect(wrapper.emitted('end')).toEqual([[]]); + }); + + it('does not call createFlash', async () => { + createComponent(); + + await clickOnButtonAndWait(eventPayload); + + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('calls createFlash with the success message when showSuccessAlert is true', async () => { + createComponent({ showSuccessAlert: true }); + + await clickOnButtonAndWait(eventPayload); + + expect(createFlash).toHaveBeenCalledWith({ + message: DeletePackage.i18n.successMessage, + type: 'success', + }); + }); + }); + + describe.each` + errorType | mutationResolverResponse + ${'connectionError'} | ${jest.fn().mockRejectedValue()} + ${'localError'} | ${jest.fn().mockResolvedValue(packageDestroyMutationError())} + `('on mutation $errorType', ({ mutationResolverResponse }) => { + beforeEach(() => { + mutationResolver = mutationResolverResponse; + }); + + it('emits end event', async () => { + createComponent(); + + await clickOnButtonAndWait(eventPayload); + + expect(wrapper.emitted('end')).toEqual([[]]); + }); + + it('calls createFlash with the error message', async () => { + createComponent({ showSuccessAlert: true }); + + await clickOnButtonAndWait(eventPayload); + + expect(createFlash).toHaveBeenCalledWith({ + message: DeletePackage.i18n.errorMessage, + type: 'warning', + captureError: true, + error: expect.any(Error), + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap index 1b556be5873..5af75868084 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap @@ -8,5 +8,62 @@ exports[`PackagesListApp renders 1`] = ` /> <package-search-stub /> + + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="" + class="gl-max-w-full" + role="img" + src="emptyListIllustration" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="emptyListHelpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div + class="gl-display-flex gl-flex-wrap gl-justify-content-center" + > + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js index 3958cdf21bb..ad848f367e0 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js @@ -2,22 +2,25 @@ import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import { createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PackageListApp from '~/packages_and_registries/package_registry/components/list/app.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; +import OriginalPackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; +import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; import { PROJECT_RESOURCE_TYPE, GROUP_RESOURCE_TYPE, - LIST_QUERY_DEBOUNCE_TIME, + GRAPHQL_PAGE_SIZE, } from '~/packages_and_registries/package_registry/constants'; import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; -import { packagesListQuery } from '../../mock_data'; +import { packagesListQuery, packageData, pagination } from '../../mock_data'; jest.mock('~/lib/utils/common_utils'); jest.mock('~/flash'); @@ -39,11 +42,20 @@ describe('PackagesListApp', () => { const PackageList = { name: 'package-list', template: '<div><slot name="empty-state"></slot></div>', + props: OriginalPackageList.props, }; const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' }; + const searchPayload = { + sort: 'VERSION_DESC', + filters: { packageName: 'foo', packageType: 'CONAN' }, + }; + const findPackageTitle = () => wrapper.findComponent(PackageTitle); const findSearch = () => wrapper.findComponent(PackageSearch); + const findListComponent = () => wrapper.findComponent(PackageList); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findDeletePackage = () => wrapper.findComponent(DeletePackage); const mountComponent = ({ resolver = jest.fn().mockResolvedValue(packagesListQuery()), @@ -61,9 +73,10 @@ describe('PackagesListApp', () => { stubs: { GlEmptyState, GlLoadingIcon, - PackageList, GlSprintf, GlLink, + PackageList, + DeletePackage, }, }); }; @@ -72,15 +85,24 @@ describe('PackagesListApp', () => { wrapper.destroy(); }); - const waitForDebouncedApollo = () => { - jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME); + const waitForFirstRequest = () => { + // emit a search update so the query is executed + findSearch().vm.$emit('update', { sort: 'NAME_DESC', filters: [] }); return waitForPromises(); }; + it('does not execute the query without sort being set', () => { + const resolver = jest.fn().mockResolvedValue(packagesListQuery()); + + mountComponent({ resolver }); + + expect(resolver).not.toHaveBeenCalled(); + }); + it('renders', async () => { mountComponent(); - await waitForDebouncedApollo(); + await waitForFirstRequest(); expect(wrapper.element).toMatchSnapshot(); }); @@ -88,7 +110,7 @@ describe('PackagesListApp', () => { it('has a package title', async () => { mountComponent(); - await waitForDebouncedApollo(); + await waitForFirstRequest(); expect(findPackageTitle().exists()).toBe(true); expect(findPackageTitle().props('count')).toBe(2); @@ -105,25 +127,54 @@ describe('PackagesListApp', () => { const resolver = jest.fn().mockResolvedValue(packagesListQuery()); mountComponent({ resolver }); - const payload = { - sort: 'VERSION_DESC', - filters: { packageName: 'foo', packageType: 'CONAN' }, - }; - - findSearch().vm.$emit('update', payload); + findSearch().vm.$emit('update', searchPayload); - await waitForDebouncedApollo(); - jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME); + await waitForPromises(); expect(resolver).toHaveBeenCalledWith( expect.objectContaining({ - groupSort: payload.sort, - ...payload.filters, + groupSort: searchPayload.sort, + ...searchPayload.filters, }), ); }); }); + describe('list component', () => { + let resolver; + + beforeEach(() => { + resolver = jest.fn().mockResolvedValue(packagesListQuery()); + mountComponent({ resolver }); + + return waitForFirstRequest(); + }); + + it('exists and has the right props', () => { + expect(findListComponent().props()).toMatchObject({ + list: expect.arrayContaining([expect.objectContaining({ id: packageData().id })]), + isLoading: false, + pageInfo: expect.objectContaining({ endCursor: pagination().endCursor }), + }); + }); + + it('when list emits next-page fetches the next set of records', () => { + findListComponent().vm.$emit('next-page'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ after: pagination().endCursor, first: GRAPHQL_PAGE_SIZE }), + ); + }); + + it('when list emits prev-page fetches the prev set of records', () => { + findListComponent().vm.$emit('prev-page'); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ before: pagination().startCursor, last: GRAPHQL_PAGE_SIZE }), + ); + }); + }); + describe.each` type | sortType ${PROJECT_RESOURCE_TYPE} | ${'sort'} @@ -136,9 +187,9 @@ describe('PackagesListApp', () => { beforeEach(() => { provide = { ...defaultProvide, isGroupPage }; - resolver = jest.fn().mockResolvedValue(packagesListQuery(type)); + resolver = jest.fn().mockResolvedValue(packagesListQuery({ type })); mountComponent({ provide, resolver }); - return waitForDebouncedApollo(); + return waitForFirstRequest(); }); it('succeeds', () => { @@ -147,8 +198,85 @@ describe('PackagesListApp', () => { it('calls the resolver with the right parameters', () => { expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ isGroupPage, [sortType]: '' }), + expect.objectContaining({ isGroupPage, [sortType]: 'NAME_DESC' }), ); }); }); + + describe('empty state', () => { + beforeEach(() => { + const resolver = jest.fn().mockResolvedValue(packagesListQuery({ extend: { nodes: [] } })); + mountComponent({ resolver }); + + return waitForFirstRequest(); + }); + it('generate the correct empty list link', () => { + const link = findListComponent().findComponent(GlLink); + + expect(link.attributes('href')).toBe(defaultProvide.emptyListHelpUrl); + expect(link.text()).toBe('publish and share your packages'); + }); + + it('includes the right content on the default tab', () => { + expect(findEmptyState().text()).toContain(PackageListApp.i18n.emptyPageTitle); + }); + }); + + describe('filter without results', () => { + beforeEach(async () => { + mountComponent(); + + await waitForFirstRequest(); + + findSearch().vm.$emit('update', searchPayload); + + return nextTick(); + }); + + it('should show specific empty message', () => { + expect(findEmptyState().text()).toContain(PackageListApp.i18n.noResultsTitle); + expect(findEmptyState().text()).toContain(PackageListApp.i18n.widenFilters); + }); + }); + + describe('delete package', () => { + it('exists and has the correct props', async () => { + mountComponent(); + + await waitForFirstRequest(); + + expect(findDeletePackage().props()).toMatchObject({ + refetchQueries: [{ query: getPackagesQuery, variables: {} }], + showSuccessAlert: true, + }); + }); + + it('deletePackage is bound to package-list package:delete event', async () => { + mountComponent(); + + await waitForFirstRequest(); + + findListComponent().vm.$emit('package:delete', { id: 1 }); + + expect(findDeletePackage().emitted('start')).toEqual([[]]); + }); + + it('start and end event set loading correctly', async () => { + mountComponent(); + + await waitForFirstRequest(); + + findDeletePackage().vm.$emit('start'); + + await nextTick(); + + expect(findListComponent().props('isLoading')).toBe(true); + + findDeletePackage().vm.$emit('end'); + + await nextTick(); + + expect(findListComponent().props('isLoading')).toBe(false); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js index b624e66482d..de4e9c8ae5b 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js @@ -1,93 +1,86 @@ -import { GlTable, GlPagination, GlModal } from '@gitlab/ui'; -import { mount, createLocalVue } from '@vue/test-utils'; -import { last } from 'lodash'; -import Vuex from 'vuex'; -import stubChildren from 'helpers/stub_children'; -import { packageList } from 'jest/packages/mock_data'; +import { GlKeysetPagination, GlModal, GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; -import { TrackingActions } from '~/packages/shared/constants'; -import * as SharedUtils from '~/packages/shared/utils'; +import { + DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_TRACKING_ACTION, +} from '~/packages_and_registries/package_registry/constants'; import PackagesList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; import Tracking from '~/tracking'; - -const localVue = createLocalVue(); -localVue.use(Vuex); +import { packageData } from '../../mock_data'; describe('packages_list', () => { let wrapper; - let store; + + const firstPackage = packageData(); + const secondPackage = { + ...packageData(), + id: 'gid://gitlab/Packages::Package/112', + name: 'second-package', + }; + + const defaultProps = { + list: [firstPackage, secondPackage], + isLoading: false, + pageInfo: {}, + }; const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' }; + const GlModalStub = { + name: GlModal.name, + template: '<div><slot></slot></div>', + methods: { show: jest.fn() }, + }; - const findPackagesListLoader = () => wrapper.find(PackagesListLoader); - const findPackageListPagination = () => wrapper.find(GlPagination); - const findPackageListDeleteModal = () => wrapper.find(GlModal); - const findEmptySlot = () => wrapper.find(EmptySlotStub); - const findPackagesListRow = () => wrapper.find(PackagesListRow); - - const createStore = (isGroupPage, packages, isLoading) => { - const state = { - isLoading, - packages, - pagination: { - perPage: 1, - total: 1, - page: 1, - }, - config: { - isGroupPage, + const findPackagesListLoader = () => wrapper.findComponent(PackagesListLoader); + const findPackageListPagination = () => wrapper.findComponent(GlKeysetPagination); + const findPackageListDeleteModal = () => wrapper.findComponent(GlModalStub); + const findEmptySlot = () => wrapper.findComponent(EmptySlotStub); + const findPackagesListRow = () => wrapper.findComponent(PackagesListRow); + + const mountComponent = (props) => { + wrapper = shallowMountExtended(PackagesList, { + propsData: { + ...defaultProps, + ...props, }, - sorting: { - orderBy: 'version', - sort: 'desc', + stubs: { + GlModal: GlModalStub, + GlSprintf, }, - }; - store = new Vuex.Store({ - state, - getters: { - getList: () => packages, + slots: { + 'empty-state': EmptySlotStub, }, }); - store.dispatch = jest.fn(); }; - const mountComponent = ({ - isGroupPage = false, - packages = packageList, - isLoading = false, - ...options - } = {}) => { - createStore(isGroupPage, packages, isLoading); - - wrapper = mount(PackagesList, { - localVue, - store, - stubs: { - ...stubChildren(PackagesList), - GlTable, - GlModal, - }, - ...options, - }); - }; + beforeEach(() => { + GlModalStub.methods.show.mockReset(); + }); afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('when is loading', () => { beforeEach(() => { - mountComponent({ - packages: [], - isLoading: true, - }); + mountComponent({ isLoading: true }); }); - it('shows skeleton loader when loading', () => { + it('shows skeleton loader', () => { expect(findPackagesListLoader().exists()).toBe(true); }); + + it('does not show the rows', () => { + expect(findPackagesListRow().exists()).toBe(false); + }); + + it('does not show the pagination', () => { + expect(findPackageListPagination().exists()).toBe(false); + }); }); describe('when is not loading', () => { @@ -95,74 +88,61 @@ describe('packages_list', () => { mountComponent(); }); - it('does not show skeleton loader when not loading', () => { + it('does not show skeleton loader', () => { expect(findPackagesListLoader().exists()).toBe(false); }); - }); - describe('layout', () => { - beforeEach(() => { - mountComponent(); + it('shows the rows', () => { + expect(findPackagesListRow().exists()).toBe(true); }); + }); + describe('layout', () => { it('contains a pagination component', () => { - const sorting = findPackageListPagination(); - expect(sorting.exists()).toBe(true); + mountComponent({ pageInfo: { hasPreviousPage: true } }); + + expect(findPackageListPagination().exists()).toBe(true); }); it('contains a modal component', () => { - const sorting = findPackageListDeleteModal(); - expect(sorting.exists()).toBe(true); + mountComponent(); + + expect(findPackageListDeleteModal().exists()).toBe(true); }); }); describe('when the user can destroy the package', () => { beforeEach(() => { mountComponent(); + findPackagesListRow().vm.$emit('packageToDelete', firstPackage); + return nextTick(); }); - it('setItemToBeDeleted sets itemToBeDeleted and open the modal', () => { - const mockModalShow = jest.spyOn(wrapper.vm.$refs.packageListDeleteModal, 'show'); - const item = last(wrapper.vm.list); + it('deleting a package opens the modal', () => { + expect(findPackageListDeleteModal().text()).toContain(firstPackage.name); + }); - findPackagesListRow().vm.$emit('packageToDelete', item); + it('confirming on the modal emits package:delete', async () => { + findPackageListDeleteModal().vm.$emit('ok'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.itemToBeDeleted).toEqual(item); - expect(mockModalShow).toHaveBeenCalled(); - }); - }); + await nextTick(); - it('deleteItemConfirmation resets itemToBeDeleted', () => { - wrapper.setData({ itemToBeDeleted: 1 }); - wrapper.vm.deleteItemConfirmation(); - expect(wrapper.vm.itemToBeDeleted).toEqual(null); + expect(wrapper.emitted('package:delete')[0]).toEqual([firstPackage]); }); - it('deleteItemConfirmation emit package:delete', () => { - const itemToBeDeleted = { id: 2 }; - wrapper.setData({ itemToBeDeleted }); - wrapper.vm.deleteItemConfirmation(); - return wrapper.vm.$nextTick(() => { - expect(wrapper.emitted('package:delete')[0]).toEqual([itemToBeDeleted]); - }); - }); + it('closing the modal resets itemToBeDeleted', async () => { + // triggering the v-model + findPackageListDeleteModal().vm.$emit('input', false); - it('deleteItemCanceled resets itemToBeDeleted', () => { - wrapper.setData({ itemToBeDeleted: 1 }); - wrapper.vm.deleteItemCanceled(); - expect(wrapper.vm.itemToBeDeleted).toEqual(null); + await nextTick(); + + expect(findPackageListDeleteModal().text()).not.toContain(firstPackage.name); }); }); describe('when the list is empty', () => { beforeEach(() => { - mountComponent({ - packages: [], - slots: { - 'empty-state': EmptySlotStub, - }, - }); + mountComponent({ list: [] }); }); it('show the empty slot', () => { @@ -171,45 +151,59 @@ describe('packages_list', () => { }); }); - describe('pagination component', () => { - let pagination; - let modelEvent; - + describe('pagination ', () => { beforeEach(() => { - mountComponent(); - pagination = findPackageListPagination(); - // retrieve the event used by v-model, a more sturdy approach than hardcoding it - modelEvent = pagination.vm.$options.model.event; + mountComponent({ pageInfo: { hasPreviousPage: true } }); }); - it('emits page:changed events when the page changes', () => { - pagination.vm.$emit(modelEvent, 2); - expect(wrapper.emitted('page:changed')).toEqual([[2]]); + it('emits prev-page events when the prev event is fired', () => { + findPackageListPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev-page')).toEqual([[]]); + }); + + it('emits next-page events when the next event is fired', () => { + findPackageListPagination().vm.$emit('next'); + + expect(wrapper.emitted('next-page')).toEqual([[]]); }); }); describe('tracking', () => { let eventSpy; - let utilSpy; - const category = 'foo'; + const category = 'UI::NpmPackages'; beforeEach(() => { - mountComponent(); eventSpy = jest.spyOn(Tracking, 'event'); - utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); - wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } }); + mountComponent(); + findPackagesListRow().vm.$emit('packageToDelete', firstPackage); + return nextTick(); }); - it('tracking category calls packageTypeToTrackCategory', () => { - expect(wrapper.vm.tracking.category).toBe(category); - expect(utilSpy).toHaveBeenCalledWith('conan'); + it('requesting the delete tracks the right action', () => { + expect(eventSpy).toHaveBeenCalledWith( + category, + REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + expect.any(Object), + ); + }); + + it('confirming delete tracks the right action', () => { + findPackageListDeleteModal().vm.$emit('ok'); + + expect(eventSpy).toHaveBeenCalledWith( + category, + DELETE_PACKAGE_TRACKING_ACTION, + expect.any(Object), + ); }); - it('deleteItemConfirmation calls event', () => { - wrapper.vm.deleteItemConfirmation(); + it('canceling delete tracks the right action', () => { + findPackageListDeleteModal().vm.$emit('cancel'); + expect(eventSpy).toHaveBeenCalledWith( category, - TrackingActions.DELETE_PACKAGE, + CANCEL_DELETE_PACKAGE_TRACKING_ACTION, expect.any(Object), ); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index e65b2a6f320..bed7a07ff36 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -1,6 +1,6 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { sortableFields } from '~/packages/list/utils'; +import { sortableFields } from '~/packages_and_registries/package_registry/utils'; import component from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import PackageTypeToken from '~/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js index 3fa96ce1d29..e992ba12faa 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list/constants'; import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -37,8 +36,8 @@ describe('PackageTitle', () => { mountComponent(); expect(findTitleArea().props()).toMatchObject({ - title: LIST_TITLE_TEXT, - infoMessages: [{ text: LIST_INTRO_TEXT, link: 'foo' }], + title: PackageTitle.i18n.LIST_TITLE_TEXT, + infoMessages: [{ text: PackageTitle.i18n.LIST_INTRO_TEXT, link: 'foo' }], }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js index b0cbe34f0b9..26b2f3b359f 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js @@ -1,7 +1,7 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import component from '~/packages/list/components/tokens/package_type_token.vue'; -import { PACKAGE_TYPES } from '~/packages/list/constants'; +import component from '~/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue'; +import { PACKAGE_TYPES } from '~/packages_and_registries/package_registry/constants'; describe('packages_filter', () => { let wrapper; @@ -41,8 +41,8 @@ describe('packages_filter', () => { (packageType, index) => { mountComponent(); const item = findFilteredSearchSuggestions().at(index); - expect(item.text()).toBe(packageType.title); - expect(item.props('value')).toBe(packageType.type); + expect(item.text()).toBe(packageType); + expect(item.props('value')).toBe(packageType); }, ); }); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 70fc096fa44..bacc748db81 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -1,3 +1,5 @@ +import capitalize from 'lodash/capitalize'; + export const packageTags = () => [ { id: 'gid://gitlab/Packages::Tag/87', name: 'bananas_9', __typename: 'PackageTag' }, { id: 'gid://gitlab/Packages::Tag/86', name: 'bananas_8', __typename: 'PackageTag' }, @@ -156,6 +158,15 @@ export const nugetMetadata = () => ({ projectUrl: 'projectUrl', }); +export const pagination = (extend) => ({ + endCursor: 'eyJpZCI6IjIwNSIsIm5hbWUiOiJteS9jb21wYW55L2FwcC9teS1hcHAifQ', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'eyJpZCI6IjI0NyIsIm5hbWUiOiJ2ZXJzaW9uX3Rlc3QxIn0', + __typename: 'PageInfo', + ...extend, +}); + export const packageDetailsQuery = (extendPackage) => ({ data: { package: { @@ -256,7 +267,7 @@ export const packageDestroyFileMutationError = () => ({ ], }); -export const packagesListQuery = (type = 'group') => ({ +export const packagesListQuery = ({ type = 'group', extend = {}, extendPagination = {} } = {}) => ({ data: { [type]: { packages: { @@ -277,9 +288,11 @@ export const packagesListQuery = (type = 'group') => ({ pipelines: { nodes: [] }, }, ], + pageInfo: pagination(extendPagination), __typename: 'PackageConnection', }, - __typename: 'Group', + ...extend, + __typename: capitalize(type), }, }, }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js index c56244a9138..5c9ade7f785 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlFormGroup, GlFormSelect } from 'jest/registry/shared/stubs'; +import { GlFormGroup, GlFormSelect } from 'jest/packages_and_registries/shared/stubs'; import component from '~/packages_and_registries/settings/project/components/expiration_dropdown.vue'; describe('ExpirationDropdown', () => { diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js index dd876d1d295..6b681924fcf 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js @@ -1,6 +1,6 @@ import { GlSprintf, GlFormInput, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { GlFormGroup } from 'jest/registry/shared/stubs'; +import { GlFormGroup } from 'jest/packages_and_registries/shared/stubs'; import component from '~/packages_and_registries/settings/project/components/expiration_input.vue'; import { NAME_REGEX_LENGTH } from '~/packages_and_registries/settings/project/constants'; diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js index 854830391c5..94f7783afe7 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js @@ -1,6 +1,6 @@ import { GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { GlFormGroup } from 'jest/registry/shared/stubs'; +import { GlFormGroup } from 'jest/packages_and_registries/shared/stubs'; import component from '~/packages_and_registries/settings/project/components/expiration_run_text.vue'; import { NEXT_CLEANUP_LABEL, diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js index 3a3eb089b43..45039614e49 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js @@ -1,6 +1,6 @@ import { GlToggle, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { GlFormGroup } from 'jest/registry/shared/stubs'; +import { GlFormGroup } from 'jest/packages_and_registries/shared/stubs'; import component from '~/packages_and_registries/settings/project/components/expiration_toggle.vue'; import { ENABLED_TOGGLE_DESCRIPTION, diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js index 3a71af94d5a..bc104a25ef9 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js @@ -2,7 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { GlCard, GlLoadingIcon } from 'jest/registry/shared/stubs'; +import { GlCard, GlLoadingIcon } from 'jest/packages_and_registries/shared/stubs'; import component from '~/packages_and_registries/settings/project/components/settings_form.vue'; import { UPDATE_SETTINGS_ERROR_MESSAGE, diff --git a/spec/frontend/packages_and_registries/shared/mocks.js b/spec/frontend/packages_and_registries/shared/mocks.js new file mode 100644 index 00000000000..fdef38b6f10 --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/mocks.js @@ -0,0 +1,3 @@ +export const $toast = { + show: jest.fn(), +}; diff --git a/spec/frontend/packages_and_registries/shared/stubs.js b/spec/frontend/packages_and_registries/shared/stubs.js new file mode 100644 index 00000000000..ad41eb42df4 --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/stubs.js @@ -0,0 +1,31 @@ +export const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' }; +export const GlCard = { + name: 'gl-card-stub', + template: ` +<div> + <slot name="header"></slot> + <slot></slot> + <slot name="footer"></slot> +</div> +`, +}; + +export const GlFormGroup = { + name: 'gl-form-group-stub', + props: ['state'], + template: ` + <div> + <slot name="label"></slot> + <slot></slot> + <slot name="description"></slot> + </div>`, +}; + +export const GlFormSelect = { + name: 'gl-form-select-stub', + props: ['disabled', 'value'], + template: ` + <div> + <slot></slot> + </div>`, +}; |