diff options
Diffstat (limited to 'spec/frontend/ci/catalog/components')
12 files changed, 1259 insertions, 0 deletions
diff --git a/spec/frontend/ci/catalog/components/ci_catalog_home_spec.js b/spec/frontend/ci/catalog/components/ci_catalog_home_spec.js new file mode 100644 index 00000000000..1b5c86c19cb --- /dev/null +++ b/spec/frontend/ci/catalog/components/ci_catalog_home_spec.js @@ -0,0 +1,46 @@ +import { shallowMount } from '@vue/test-utils'; +import { createRouter } from '~/ci/catalog/router'; +import ciResourceDetailsPage from '~/ci/catalog/components/pages/ci_resource_details_page.vue'; +import CiCatalogHome from '~/ci/catalog/components/ci_catalog_home.vue'; + +describe('CiCatalogHome', () => { + const defaultProps = {}; + const baseRoute = '/'; + const resourcesPageComponentStub = { + name: 'page-component', + template: '<div>Hello</div>', + }; + const router = createRouter(baseRoute, resourcesPageComponentStub); + + const createComponent = ({ props = {} } = {}) => { + shallowMount(CiCatalogHome, { + propsData: { + ...defaultProps, + ...props, + }, + router, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + describe('router', () => { + it.each` + path | component + ${baseRoute} | ${resourcesPageComponentStub} + ${'/1'} | ${ciResourceDetailsPage} + `('when route is $path it renders the right component', async ({ path, component }) => { + if (path !== '/') { + await router.push(path); + } + + const [root] = router.currentRoute.matched; + + expect(root.components.default).toBe(component); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js new file mode 100644 index 00000000000..658a135534b --- /dev/null +++ b/spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js @@ -0,0 +1,120 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiResourceAbout from '~/ci/catalog/components/details/ci_resource_about.vue'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +describe('CiResourceAbout', () => { + let wrapper; + + const defaultProps = { + isLoadingSharedData: false, + isLoadingDetails: false, + openIssuesCount: 4, + openMergeRequestsCount: 9, + latestVersion: { + id: 1, + tagName: 'v1.0.0', + tagPath: 'path/to/release', + releasedAt: '2022-08-23T17:19:09Z', + }, + webPath: 'path/to/project', + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(CiResourceAbout, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findProjectLink = () => wrapper.findByText('Go to the project'); + const findIssueCount = () => wrapper.findByText(`${defaultProps.openIssuesCount} issues`); + const findMergeRequestCount = () => + wrapper.findByText(`${defaultProps.openMergeRequestsCount} merge requests`); + const findLastRelease = () => + wrapper.findByText( + `Last release at ${formatDate(defaultProps.latestVersion.releasedAt, 'yyyy-mm-dd')}`, + ); + const findAllLoadingItems = () => wrapper.findAllByTestId('skeleton-loading-line'); + + // Shared data items are items which gets their data from the index page query. + const sharedDataItems = [findProjectLink, findLastRelease]; + // additional details items gets their state only when on the details page + const additionalDetailsItems = [findIssueCount, findMergeRequestCount]; + const allItems = [...sharedDataItems, ...additionalDetailsItems]; + + describe('when loading shared data', () => { + beforeEach(() => { + createComponent({ props: { isLoadingSharedData: true, isLoadingDetails: true } }); + }); + + it('renders all server-side data as loading', () => { + allItems.forEach((finder) => { + expect(finder().exists()).toBe(false); + }); + + expect(findAllLoadingItems()).toHaveLength(allItems.length); + }); + }); + + describe('when loading additional details', () => { + beforeEach(() => { + createComponent({ props: { isLoadingDetails: true } }); + }); + + it('renders only the details query as loading', () => { + sharedDataItems.forEach((finder) => { + expect(finder().exists()).toBe(true); + }); + + additionalDetailsItems.forEach((finder) => { + expect(finder().exists()).toBe(false); + }); + + expect(findAllLoadingItems()).toHaveLength(additionalDetailsItems.length); + }); + }); + + describe('when has loaded', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders project link', () => { + expect(findProjectLink().exists()).toBe(true); + }); + + it('renders the number of issues opened', () => { + expect(findIssueCount().exists()).toBe(true); + }); + + it('renders the number of merge requests opened', () => { + expect(findMergeRequestCount().exists()).toBe(true); + }); + + it('renders the last release date', () => { + expect(findLastRelease().exists()).toBe(true); + }); + + describe('links', () => { + it('has the correct project link', () => { + expect(findProjectLink().attributes('href')).toBe(defaultProps.webPath); + }); + + it('has the correct issues link', () => { + expect(findIssueCount().attributes('href')).toBe(`${defaultProps.webPath}/issues`); + }); + + it('has the correct merge request link', () => { + expect(findMergeRequestCount().attributes('href')).toBe( + `${defaultProps.webPath}/merge_requests`, + ); + }); + + it('has no link for release data', () => { + expect(findLastRelease().attributes('href')).toBe(undefined); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js new file mode 100644 index 00000000000..a41996d20b3 --- /dev/null +++ b/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js @@ -0,0 +1,113 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { resolvers } from '~/ci/catalog/graphql/settings'; +import CiResourceComponents from '~/ci/catalog/components/details/ci_resource_components.vue'; +import getCiCatalogcomponentComponents from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import { mockComponents } from '../../mock'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +describe('CiResourceComponents', () => { + let wrapper; + let mockComponentsResponse; + + const components = mockComponents.data.ciCatalogResource.components.nodes; + + const resourceId = 'gid://gitlab/Ci::Catalog::Resource/1'; + + const defaultProps = { resourceId }; + + const createComponent = async () => { + const handlers = [[getCiCatalogcomponentComponents, mockComponentsResponse]]; + const mockApollo = createMockApollo(handlers, resolvers); + + wrapper = mountExtended(CiResourceComponents, { + propsData: { + ...defaultProps, + }, + apolloProvider: mockApollo, + }); + + await waitForPromises(); + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findComponents = () => wrapper.findAllByTestId('component-section'); + + beforeEach(() => { + mockComponentsResponse = jest.fn(); + mockComponentsResponse.mockResolvedValue(mockComponents); + }); + + describe('when queries are loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('render a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not render components', () => { + expect(findComponents()).toHaveLength(0); + }); + + it('does not throw an error', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('when components query throws an error', () => { + beforeEach(async () => { + mockComponentsResponse.mockRejectedValue(); + await createComponent(); + }); + + it('calls createAlert with the correct message', () => { + expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: "There was an error fetching this resource's components", + }); + }); + + it('does not render the loading state', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when queries have loaded', () => { + beforeEach(async () => { + await createComponent(); + }); + + it('renders every component', () => { + expect(findComponents()).toHaveLength(components.length); + }); + + it('renders the component name, description and snippet', () => { + components.forEach((component) => { + expect(wrapper.text()).toContain(component.name); + expect(wrapper.text()).toContain(component.description); + expect(wrapper.text()).toContain(component.path); + }); + }); + + describe('inputs', () => { + it('renders the component parameter attributes', () => { + const [firstComponent] = components; + + firstComponent.inputs.nodes.forEach((input) => { + expect(findComponents().at(0).text()).toContain(input.name); + expect(findComponents().at(0).text()).toContain(input.defaultValue); + expect(findComponents().at(0).text()).toContain('Yes'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js new file mode 100644 index 00000000000..1f7dcf9d4e5 --- /dev/null +++ b/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js @@ -0,0 +1,83 @@ +import { GlTabs, GlTab } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import CiResourceComponents from '~/ci/catalog/components/details/ci_resource_components.vue'; +import CiResourceDetails from '~/ci/catalog/components/details/ci_resource_details.vue'; +import CiResourceReadme from '~/ci/catalog/components/details/ci_resource_readme.vue'; + +describe('CiResourceDetails', () => { + let wrapper; + + const defaultProps = { + resourceId: 'gid://gitlab/Ci::Catalog::Resource/1', + }; + const defaultProvide = { + glFeatures: { ciCatalogComponentsTab: true }, + }; + + const createComponent = ({ provide = {}, props = {} } = {}) => { + wrapper = shallowMount(CiResourceDetails, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { + GlTabs, + }, + }); + }; + const findAllTabs = () => wrapper.findAllComponents(GlTab); + const findCiResourceReadme = () => wrapper.findComponent(CiResourceReadme); + const findCiResourceComponents = () => wrapper.findComponent(CiResourceComponents); + + describe('tabs', () => { + describe('when feature flag `ci_catalog_components_tab` is enabled', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the readme and components tab', () => { + expect(findAllTabs()).toHaveLength(2); + expect(findCiResourceComponents().exists()).toBe(true); + expect(findCiResourceReadme().exists()).toBe(true); + }); + }); + + describe('when feature flag `ci_catalog_components_tab` is disabled', () => { + beforeEach(() => { + createComponent({ + provide: { glFeatures: { ciCatalogComponentsTab: false } }, + }); + }); + + it('renders only readme tab as default', () => { + expect(findCiResourceReadme().exists()).toBe(true); + expect(findCiResourceComponents().exists()).toBe(false); + expect(findAllTabs()).toHaveLength(1); + }); + }); + + describe('UI', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes lazy attribute to all tabs', () => { + findAllTabs().wrappers.forEach((tab) => { + expect(tab.attributes().lazy).not.toBeUndefined(); + }); + }); + + it('passes the right props to the readme component', () => { + expect(findCiResourceReadme().props().resourceId).toBe(defaultProps.resourceId); + }); + + it('passes the right props to the components tab', () => { + expect(findCiResourceComponents().props().resourceId).toBe(defaultProps.resourceId); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js new file mode 100644 index 00000000000..6ab9520508d --- /dev/null +++ b/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js @@ -0,0 +1,139 @@ +import { GlAvatar, GlAvatarLink, GlBadge } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiResourceHeader from '~/ci/catalog/components/details/ci_resource_header.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiResourceAbout from '~/ci/catalog/components/details/ci_resource_about.vue'; +import { catalogSharedDataMock, catalogAdditionalDetailsMock } from '../../mock'; + +describe('CiResourceHeader', () => { + let wrapper; + + const resource = { ...catalogSharedDataMock.data.ciCatalogResource }; + const resourceAdditionalData = { ...catalogAdditionalDetailsMock.data.ciCatalogResource }; + + const defaultProps = { + openIssuesCount: resourceAdditionalData.openIssuesCount, + openMergeRequestsCount: resourceAdditionalData.openMergeRequestsCount, + isLoadingDetails: false, + isLoadingSharedData: false, + resource, + }; + + const findAboutComponent = () => wrapper.findComponent(CiResourceAbout); + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findVersionBadge = () => wrapper.findComponent(GlBadge); + const findPipelineStatusBadge = () => wrapper.findComponent(CiBadgeLink); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(CiResourceHeader, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the project name and description', () => { + expect(wrapper.html()).toContain(resource.name); + expect(wrapper.html()).toContain(resource.description); + }); + + it('renders the namespace and project path', () => { + expect(wrapper.html()).toContain(resource.rootNamespace.fullPath); + expect(wrapper.html()).toContain(resource.rootNamespace.name); + }); + + it('renders the avatar', () => { + const { id, name } = resource; + + expect(findAvatar().exists()).toBe(true); + expect(findAvatarLink().exists()).toBe(true); + expect(findAvatar().props()).toMatchObject({ + entityId: getIdFromGraphQLId(id), + entityName: name, + }); + }); + + it('renders the catalog about section and passes props', () => { + expect(findAboutComponent().exists()).toBe(true); + expect(findAboutComponent().props()).toEqual({ + isLoadingDetails: false, + isLoadingSharedData: false, + openIssuesCount: defaultProps.openIssuesCount, + openMergeRequestsCount: defaultProps.openMergeRequestsCount, + latestVersion: resource.latestVersion, + webPath: resource.webPath, + }); + }); + }); + + describe('Version badge', () => { + describe('without a version', () => { + beforeEach(() => { + createComponent({ props: { resource: { ...resource, latestVersion: null } } }); + }); + + it('does not render', () => { + expect(findVersionBadge().exists()).toBe(false); + }); + }); + + describe('with a version', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders', () => { + expect(findVersionBadge().exists()).toBe(true); + }); + }); + }); + + describe('when the project has a release', () => { + const pipelineStatus = { + detailsPath: 'path/to/pipeline', + icon: 'status_success', + text: 'passed', + group: 'success', + }; + + describe.each` + hasPipelineBadge | describeText | testText | status + ${true} | ${'is'} | ${'renders'} | ${pipelineStatus} + ${false} | ${'is not'} | ${'does not render'} | ${{}} + `('and there $describeText a pipeline', ({ hasPipelineBadge, testText, status }) => { + beforeEach(() => { + createComponent({ + props: { + pipelineStatus: status, + latestVersion: { tagName: '1.0.0', tagPath: 'path/to/release' }, + }, + }); + }); + + it('renders the version badge', () => { + expect(findVersionBadge().exists()).toBe(true); + }); + + it(`${testText} the pipeline status badge`, () => { + expect(findPipelineStatusBadge().exists()).toBe(hasPipelineBadge); + if (hasPipelineBadge) { + expect(findPipelineStatusBadge().props()).toEqual({ + showText: true, + size: 'sm', + status: pipelineStatus, + showTooltip: true, + useLink: true, + }); + } + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js new file mode 100644 index 00000000000..0dadac236a8 --- /dev/null +++ b/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js @@ -0,0 +1,96 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiResourceReadme from '~/ci/catalog/components/details/ci_resource_readme.vue'; +import getCiCatalogResourceReadme from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); + +const readmeHtml = '<h1>This is a readme file</h1>'; +const resourceId = 'gid://gitlab/Ci::Catalog::Resource/1'; + +describe('CiResourceReadme', () => { + let wrapper; + let mockReadmeResponse; + + const readmeMockData = { + data: { + ciCatalogResource: { + id: resourceId, + readmeHtml, + }, + }, + }; + + const defaultProps = { resourceId }; + + const createComponent = ({ props = {} } = {}) => { + const handlers = [[getCiCatalogResourceReadme, mockReadmeResponse]]; + + wrapper = shallowMountExtended(CiResourceReadme, { + propsData: { + ...defaultProps, + ...props, + }, + apolloProvider: createMockApollo(handlers), + }); + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + beforeEach(() => { + mockReadmeResponse = jest.fn(); + }); + + describe('when loading', () => { + beforeEach(() => { + mockReadmeResponse.mockResolvedValue(readmeMockData); + createComponent(); + }); + + it('renders only a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(wrapper.html()).not.toContain(readmeHtml); + }); + }); + + describe('when mounted', () => { + beforeEach(async () => { + mockReadmeResponse.mockResolvedValue(readmeMockData); + + createComponent(); + await waitForPromises(); + }); + + it('renders only the received HTML', () => { + expect(findLoadingIcon().exists()).toBe(false); + expect(wrapper.html()).toContain(readmeHtml); + }); + + it('does not render an error', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('when there is an error loading the readme', () => { + beforeEach(async () => { + mockReadmeResponse.mockRejectedValue({ errors: [] }); + + createComponent(); + await waitForPromises(); + }); + + it('calls the createAlert function to show an error', () => { + expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: "There was a problem loading this project's readme content.", + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/catalog_header_spec.js b/spec/frontend/ci/catalog/components/list/catalog_header_spec.js new file mode 100644 index 00000000000..912fd9e1a93 --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/catalog_header_spec.js @@ -0,0 +1,86 @@ +import { GlBanner, GlButton } from '@gitlab/ui'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CatalogHeader from '~/ci/catalog/components/list/catalog_header.vue'; +import { CATALOG_FEEDBACK_DISMISSED_KEY } from '~/ci/catalog/constants'; + +describe('CatalogHeader', () => { + useLocalStorageSpy(); + + let wrapper; + + const defaultProps = {}; + const defaultProvide = { + pageTitle: 'Catalog page', + pageDescription: 'This is a nice catalog page', + }; + + const findBanner = () => wrapper.findComponent(GlBanner); + const findFeedbackButton = () => findBanner().findComponent(GlButton); + const findTitle = () => wrapper.findByText(defaultProvide.pageTitle); + const findDescription = () => wrapper.findByText(defaultProvide.pageDescription); + + const createComponent = ({ props = {}, stubs = {} } = {}) => { + wrapper = shallowMountExtended(CatalogHeader, { + propsData: { + ...defaultProps, + ...props, + }, + provide: defaultProvide, + stubs: { + ...stubs, + }, + }); + }; + + it('renders the Catalog title and description', () => { + createComponent(); + + expect(findTitle().exists()).toBe(true); + expect(findDescription().exists()).toBe(true); + }); + + describe('Feedback banner', () => { + describe('when user has never dismissed', () => { + beforeEach(() => { + createComponent({ stubs: { GlBanner } }); + }); + + it('is visible', () => { + expect(findBanner().exists()).toBe(true); + }); + + it('has link to feedback issue', () => { + expect(findFeedbackButton().attributes().href).toBe( + 'https://gitlab.com/gitlab-org/gitlab/-/issues/407556', + ); + }); + }); + + describe('when user dismisses it', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets the local storage and removes the banner', async () => { + expect(findBanner().exists()).toBe(true); + + await findBanner().vm.$emit('close'); + + expect(localStorage.setItem).toHaveBeenCalledWith(CATALOG_FEEDBACK_DISMISSED_KEY, 'true'); + expect(findBanner().exists()).toBe(false); + }); + }); + + describe('when user has dismissed it before', () => { + beforeEach(() => { + localStorage.setItem(CATALOG_FEEDBACK_DISMISSED_KEY, 'true'); + createComponent(); + }); + + it('does not show the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/catalog_list_skeleton_loader_spec.js b/spec/frontend/ci/catalog/components/list/catalog_list_skeleton_loader_spec.js new file mode 100644 index 00000000000..d21fd56eb2e --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/catalog_list_skeleton_loader_spec.js @@ -0,0 +1,22 @@ +import { shallowMount } from '@vue/test-utils'; +import CatalogListSkeletonLoader from '~/ci/catalog/components/list/catalog_list_skeleton_loader.vue'; + +describe('CatalogListSkeletonLoader', () => { + let wrapper; + + const findSkeletonLoader = () => wrapper.findComponent(CatalogListSkeletonLoader); + + const createComponent = () => { + wrapper = shallowMount(CatalogListSkeletonLoader, {}); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders skeleton item', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js b/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js new file mode 100644 index 00000000000..7f446064366 --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js @@ -0,0 +1,198 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { GlAvatar, GlBadge, GlButton, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createRouter } from '~/ci/catalog/router/index'; +import CiResourcesListItem from '~/ci/catalog/components/list/ci_resources_list_item.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { CI_RESOURCE_DETAILS_PAGE_NAME } from '~/ci/catalog/router/constants'; +import { catalogSinglePageResponse } from '../../mock'; + +Vue.use(VueRouter); + +let router; +let routerPush; + +describe('CiResourcesListItem', () => { + let wrapper; + + const resource = catalogSinglePageResponse.data.ciCatalogResources.nodes[0]; + const release = { + author: { name: 'author', webUrl: '/user/1' }, + releasedAt: Date.now(), + tagName: '1.0.0', + }; + const defaultProps = { + resource, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(CiResourcesListItem, { + router, + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlSprintf, + RouterLink: true, + RouterView: true, + }, + }); + }; + + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findBadge = () => wrapper.findComponent(GlBadge); + const findResourceName = () => wrapper.findComponent(GlButton); + const findResourceDescription = () => wrapper.findByText(defaultProps.resource.description); + const findUserLink = () => wrapper.findByTestId('user-link'); + const findTimeAgoMessage = () => wrapper.findComponent(GlSprintf); + const findFavorites = () => wrapper.findByTestId('stats-favorites'); + const findForks = () => wrapper.findByTestId('stats-forks'); + + beforeEach(() => { + router = createRouter(); + routerPush = jest.spyOn(router, 'push').mockImplementation(() => {}); + }); + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the resource avatar and passes the right props', () => { + const { icon, id, name } = defaultProps.resource; + + expect(findAvatar().exists()).toBe(true); + expect(findAvatar().props()).toMatchObject({ + entityId: getIdFromGraphQLId(id), + entityName: name, + src: icon, + }); + }); + + it('renders the resource name button', () => { + expect(findResourceName().exists()).toBe(true); + }); + + it('renders the resource version badge', () => { + expect(findBadge().exists()).toBe(true); + }); + + it('renders the resource description', () => { + expect(findResourceDescription().exists()).toBe(true); + }); + + describe('release time', () => { + describe('when there is no release data', () => { + beforeEach(() => { + createComponent({ props: { resource: { ...resource, latestVersion: null } } }); + }); + + it('does not render the release', () => { + expect(findTimeAgoMessage().exists()).toBe(false); + }); + + it('renders the generic `unreleased` badge', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe('Unreleased'); + }); + }); + + describe('when there is release data', () => { + beforeEach(() => { + createComponent({ props: { resource: { ...resource, latestVersion: { ...release } } } }); + }); + + it('renders the user link', () => { + expect(findUserLink().exists()).toBe(true); + expect(findUserLink().attributes('href')).toBe(release.author.webUrl); + }); + + it('renders the time since the resource was released', () => { + expect(findTimeAgoMessage().exists()).toBe(true); + }); + + it('renders the version badge', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe(release.tagName); + }); + }); + }); + }); + + describe('when clicking on an item title', () => { + beforeEach(async () => { + createComponent(); + + await findResourceName().vm.$emit('click'); + }); + + it('navigates to the details page', () => { + expect(routerPush).toHaveBeenCalledWith({ + name: CI_RESOURCE_DETAILS_PAGE_NAME, + params: { + id: getIdFromGraphQLId(resource.id), + }, + }); + }); + }); + + describe('when clicking on an item avatar', () => { + beforeEach(async () => { + createComponent(); + + await findAvatar().vm.$emit('click'); + }); + + it('navigates to the details page', () => { + expect(routerPush).toHaveBeenCalledWith({ + name: CI_RESOURCE_DETAILS_PAGE_NAME, + params: { + id: getIdFromGraphQLId(resource.id), + }, + }); + }); + }); + + describe('statistics', () => { + describe('when there are no statistics', () => { + beforeEach(() => { + createComponent({ + props: { + resource: { + forksCount: 0, + starCount: 0, + }, + }, + }); + }); + + it('render favorites as 0', () => { + expect(findFavorites().exists()).toBe(true); + expect(findFavorites().text()).toBe('0'); + }); + + it('render forks as 0', () => { + expect(findForks().exists()).toBe(true); + expect(findForks().text()).toBe('0'); + }); + }); + + describe('where there are statistics', () => { + beforeEach(() => { + createComponent(); + }); + + it('render favorites', () => { + expect(findFavorites().exists()).toBe(true); + expect(findFavorites().text()).toBe(String(defaultProps.resource.starCount)); + }); + + it('render forks', () => { + expect(findForks().exists()).toBe(true); + expect(findForks().text()).toBe(String(defaultProps.resource.forksCount)); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js b/spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js new file mode 100644 index 00000000000..aca20a83979 --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js @@ -0,0 +1,143 @@ +import { GlKeysetPagination } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiResourcesList from '~/ci/catalog/components/list/ci_resources_list.vue'; +import CiResourcesListItem from '~/ci/catalog/components/list/ci_resources_list_item.vue'; +import { ciCatalogResourcesItemsCount } from '~/ci/catalog/graphql/settings'; +import { catalogResponseBody, catalogSinglePageResponse } from '../../mock'; + +describe('CiResourcesList', () => { + let wrapper; + + const createComponent = ({ props = {} } = {}) => { + const { nodes, pageInfo, count } = catalogResponseBody.data.ciCatalogResources; + + const defaultProps = { + currentPage: 1, + resources: nodes, + pageInfo, + totalCount: count, + }; + + wrapper = shallowMountExtended(CiResourcesList, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlKeysetPagination, + }, + }); + }; + + const findPageCount = () => wrapper.findByTestId('pageCount'); + const findResourcesListItems = () => wrapper.findAllComponents(CiResourcesListItem); + const findPrevBtn = () => wrapper.findByTestId('prevButton'); + const findNextBtn = () => wrapper.findByTestId('nextButton'); + + describe('contains only one page', () => { + const { nodes, pageInfo, count } = catalogSinglePageResponse.data.ciCatalogResources; + + beforeEach(async () => { + await createComponent({ + props: { currentPage: 1, resources: nodes, pageInfo, totalCount: count }, + }); + }); + + it('shows the right number of items', () => { + expect(findResourcesListItems()).toHaveLength(nodes.length); + }); + + it('hides the keyset control for previous page', () => { + expect(findPrevBtn().exists()).toBe(false); + }); + + it('hides the keyset control for next page', () => { + expect(findNextBtn().exists()).toBe(false); + }); + + it('shows the correct count of current page', () => { + expect(findPageCount().text()).toContain('1 of 1'); + }); + }); + + describe.each` + hasPreviousPage | hasNextPage | pageText | expectedTotal | currentPage + ${false} | ${true} | ${'1 of 3'} | ${ciCatalogResourcesItemsCount} | ${1} + ${true} | ${true} | ${'2 of 3'} | ${ciCatalogResourcesItemsCount} | ${2} + ${true} | ${false} | ${'3 of 3'} | ${ciCatalogResourcesItemsCount} | ${3} + `( + 'when on page $pageText', + ({ currentPage, expectedTotal, pageText, hasPreviousPage, hasNextPage }) => { + const { nodes, pageInfo, count } = catalogResponseBody.data.ciCatalogResources; + + const previousPageState = hasPreviousPage ? 'enabled' : 'disabled'; + const nextPageState = hasNextPage ? 'enabled' : 'disabled'; + + beforeEach(async () => { + await createComponent({ + props: { + currentPage, + resources: nodes, + pageInfo: { ...pageInfo, hasPreviousPage, hasNextPage }, + totalCount: count, + }, + }); + }); + + it('shows the right number of items', () => { + expect(findResourcesListItems()).toHaveLength(expectedTotal); + }); + + it(`shows the keyset control for previous page as ${previousPageState}`, () => { + const disableAttr = findPrevBtn().attributes('disabled'); + + if (previousPageState === 'disabled') { + expect(disableAttr).toBeDefined(); + } else { + expect(disableAttr).toBeUndefined(); + } + }); + + it(`shows the keyset control for next page as ${nextPageState}`, () => { + const disableAttr = findNextBtn().attributes('disabled'); + + if (nextPageState === 'disabled') { + expect(disableAttr).toBeDefined(); + } else { + expect(disableAttr).toBeUndefined(); + } + }); + + it('shows the correct count of current page', () => { + expect(findPageCount().text()).toContain(pageText); + }); + }, + ); + + describe('when there is an error getting the page count', () => { + beforeEach(() => { + createComponent({ props: { totalCount: 0 } }); + }); + + it('hides the page count', () => { + expect(findPageCount().exists()).toBe(false); + }); + }); + + describe('emitted events', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + btnText | elFinder | eventName + ${'previous'} | ${findPrevBtn} | ${'onPrevPage'} + ${'next'} | ${findNextBtn} | ${'onNextPage'} + `('emits $eventName when clicking on the $btnText button', async ({ elFinder, eventName }) => { + await elFinder().vm.$emit('click'); + + expect(wrapper.emitted(eventName)).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/list/empty_state_spec.js b/spec/frontend/ci/catalog/components/list/empty_state_spec.js new file mode 100644 index 00000000000..f589ad96a9d --- /dev/null +++ b/spec/frontend/ci/catalog/components/list/empty_state_spec.js @@ -0,0 +1,27 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import EmptyState from '~/ci/catalog/components/list/empty_state.vue'; + +describe('EmptyState', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(EmptyState, { + propsData: { + ...props, + }, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js b/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js new file mode 100644 index 00000000000..40f243ed891 --- /dev/null +++ b/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js @@ -0,0 +1,186 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { CI_CATALOG_RESOURCE_TYPE, cacheConfig } from '~/ci/catalog/graphql/settings'; + +import getCiCatalogResourceSharedData from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql'; +import getCiCatalogResourceDetails from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql'; + +import CiResourceDetails from '~/ci/catalog/components/details/ci_resource_details.vue'; +import CiResourceDetailsPage from '~/ci/catalog/components/pages/ci_resource_details_page.vue'; +import CiResourceHeader from '~/ci/catalog/components/details/ci_resource_header.vue'; +import CiResourceHeaderSkeletonLoader from '~/ci/catalog/components/details/ci_resource_header_skeleton_loader.vue'; + +import { createRouter } from '~/ci/catalog/router/index'; +import { CI_RESOURCE_DETAILS_PAGE_NAME } from '~/ci/catalog/router/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { catalogSharedDataMock, catalogAdditionalDetailsMock } from '../../mock'; + +Vue.use(VueApollo); +Vue.use(VueRouter); + +let router; + +const defaultSharedData = { ...catalogSharedDataMock.data.ciCatalogResource }; +const defaultAdditionalData = { ...catalogAdditionalDetailsMock.data.ciCatalogResource }; + +describe('CiResourceDetailsPage', () => { + let wrapper; + let sharedDataResponse; + let additionalDataResponse; + + const defaultProps = {}; + + const defaultProvide = { + ciCatalogPath: '/ci/catalog/resources', + }; + + const findDetailsComponent = () => wrapper.findComponent(CiResourceDetails); + const findHeaderComponent = () => wrapper.findComponent(CiResourceHeader); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findHeaderSkeletonLoader = () => wrapper.findComponent(CiResourceHeaderSkeletonLoader); + + const createComponent = ({ props = {} } = {}) => { + const handlers = [ + [getCiCatalogResourceSharedData, sharedDataResponse], + [getCiCatalogResourceDetails, additionalDataResponse], + ]; + + const mockApollo = createMockApollo(handlers, undefined, cacheConfig); + + wrapper = shallowMount(CiResourceDetailsPage, { + router, + apolloProvider: mockApollo, + provide: { + ...defaultProvide, + }, + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + RouterView: true, + }, + }); + }; + + beforeEach(async () => { + sharedDataResponse = jest.fn(); + additionalDataResponse = jest.fn(); + + router = createRouter(); + await router.push({ + name: CI_RESOURCE_DETAILS_PAGE_NAME, + params: { id: defaultSharedData.id }, + }); + }); + + describe('when the app is loading', () => { + describe('and shared data is pre-fetched', () => { + beforeEach(() => { + // By mocking a return value and not a promise, we skip the loading + // to simulate having the pre-fetched query + sharedDataResponse.mockReturnValueOnce(catalogSharedDataMock); + additionalDataResponse.mockResolvedValue(catalogAdditionalDetailsMock); + createComponent(); + }); + + it('renders the header skeleton loader', () => { + expect(findHeaderSkeletonLoader().exists()).toBe(false); + }); + + it('passes down the loading state to the header component', () => { + sharedDataResponse.mockReturnValueOnce(catalogSharedDataMock); + + expect(findHeaderComponent().props()).toMatchObject({ + isLoadingDetails: true, + isLoadingSharedData: false, + }); + }); + }); + + describe('and shared data is not pre-fetched', () => { + beforeEach(() => { + sharedDataResponse.mockResolvedValue(catalogSharedDataMock); + additionalDataResponse.mockResolvedValue(catalogAdditionalDetailsMock); + createComponent(); + }); + + it('does not render the header skeleton', () => { + expect(findHeaderSkeletonLoader().exists()).toBe(false); + }); + + it('passes all loading state to the header component as true', () => { + expect(findHeaderComponent().props()).toMatchObject({ + isLoadingDetails: true, + isLoadingSharedData: true, + }); + }); + }); + }); + + describe('and there are no resources', () => { + beforeEach(async () => { + const mockError = new Error('error'); + sharedDataResponse.mockRejectedValue(mockError); + additionalDataResponse.mockRejectedValue(mockError); + + createComponent(); + await waitForPromises(); + }); + + it('renders the empty state', () => { + expect(findDetailsComponent().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(true); + expect(findEmptyState().props('primaryButtonLink')).toBe(defaultProvide.ciCatalogPath); + }); + }); + + describe('when data has loaded', () => { + beforeEach(async () => { + sharedDataResponse.mockResolvedValue(catalogSharedDataMock); + additionalDataResponse.mockResolvedValue(catalogAdditionalDetailsMock); + createComponent(); + + await waitForPromises(); + }); + + it('does not render the header skeleton loader', () => { + expect(findHeaderSkeletonLoader().exists()).toBe(false); + }); + + describe('Catalog header', () => { + it('exists', () => { + expect(findHeaderComponent().exists()).toBe(true); + }); + + it('passes expected props', () => { + expect(findHeaderComponent().props()).toEqual({ + isLoadingDetails: false, + isLoadingSharedData: false, + openIssuesCount: defaultAdditionalData.openIssuesCount, + openMergeRequestsCount: defaultAdditionalData.openMergeRequestsCount, + pipelineStatus: + defaultAdditionalData.versions.nodes[0].commit.pipelines.nodes[0].detailedStatus, + resource: defaultSharedData, + }); + }); + }); + + describe('Catalog details', () => { + it('exists', () => { + expect(findDetailsComponent().exists()).toBe(true); + }); + + it('passes expected props', () => { + expect(findDetailsComponent().props()).toEqual({ + resourceId: convertToGraphQLId(CI_CATALOG_RESOURCE_TYPE, defaultAdditionalData.id), + }); + }); + }); + }); +}); |