Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/ci/catalog/components')
-rw-r--r--spec/frontend/ci/catalog/components/ci_catalog_home_spec.js46
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js120
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js113
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js83
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js139
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js96
-rw-r--r--spec/frontend/ci/catalog/components/list/catalog_header_spec.js86
-rw-r--r--spec/frontend/ci/catalog/components/list/catalog_list_skeleton_loader_spec.js22
-rw-r--r--spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js198
-rw-r--r--spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js143
-rw-r--r--spec/frontend/ci/catalog/components/list/empty_state_spec.js27
-rw-r--r--spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js186
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),
+ });
+ });
+ });
+ });
+});