diff options
Diffstat (limited to 'spec/frontend/ci')
63 files changed, 2760 insertions, 669 deletions
diff --git a/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js b/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js index 2f1dae71572..c9758c5ab24 100644 --- a/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js +++ b/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js @@ -1,5 +1,6 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import RunnerTypeIcon from '~/ci/runner/components/runner_type_icon.vue'; import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue'; import { RUNNER_EMPTY_TEXT } from '~/ci/admin/jobs_table/constants'; import { allRunnersData } from 'jest/ci/runner/mock_data'; @@ -61,4 +62,29 @@ describe('Runner Cell', () => { }); }); }); + + describe('Runner Type Icon', () => { + const findRunnerTypeIcon = () => wrapper.findComponent(RunnerTypeIcon); + + describe('Job with runner', () => { + beforeEach(() => { + createComponent({ job: mockJobWithRunner }); + }); + + it('shows the runner type icon', () => { + expect(findRunnerTypeIcon().exists()).toBe(true); + expect(findRunnerTypeIcon().props('type')).toBe(mockJobWithRunner.runner.runnerType); + }); + }); + + describe('Job without runner', () => { + beforeEach(() => { + createComponent({ job: mockJobWithoutRunner }); + }); + + it('does not show the runner type icon', () => { + expect(findRunnerTypeIcon().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js index 1cbb1a714c9..3628af31aa1 100644 --- a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js +++ b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js @@ -1,16 +1,8 @@ -import { - GlLoadingIcon, - GlTable, - GlLink, - GlBadge, - GlPagination, - GlModal, - GlFormCheckbox, -} from '@gitlab/ui'; +import { GlLoadingIcon, GlTable, GlLink, GlPagination, GlModal, GlFormCheckbox } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import waitForPromises from 'helpers/wait_for_promises'; import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue'; import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue'; @@ -59,13 +51,13 @@ describe('JobArtifactsTable component', () => { const findStatuses = () => wrapper.findAllByTestId('job-artifacts-job-status'); const findSuccessfulJobStatus = () => findStatuses().at(0); - const findFailedJobStatus = () => findStatuses().at(1); + const findCiBadgeLink = () => findSuccessfulJobStatus().findComponent(CiBadgeLink); const findLinks = () => wrapper.findAllComponents(GlLink); const findJobLink = () => findLinks().at(0); const findPipelineLink = () => findLinks().at(1); - const findRefLink = () => findLinks().at(2); - const findCommitLink = () => findLinks().at(3); + const findCommitLink = () => findLinks().at(2); + const findRefLink = () => findLinks().at(3); const findSize = () => wrapper.findByTestId('job-artifacts-size'); const findCreated = () => wrapper.findByTestId('job-artifacts-created'); @@ -209,13 +201,13 @@ describe('JobArtifactsTable component', () => { }); it('shows the job status as an icon for a successful job', () => { - expect(findSuccessfulJobStatus().findComponent(CiIcon).exists()).toBe(true); - expect(findSuccessfulJobStatus().findComponent(GlBadge).exists()).toBe(false); - }); - - it('shows the job status as a badge for other job statuses', () => { - expect(findFailedJobStatus().findComponent(GlBadge).exists()).toBe(true); - expect(findFailedJobStatus().findComponent(CiIcon).exists()).toBe(false); + expect(findCiBadgeLink().props()).toMatchObject({ + status: { + group: 'success', + }, + size: 'sm', + showText: false, + }); }); it('shows links to the job, pipeline, ref, and commit', () => { 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), + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/catalog/mock.js b/spec/frontend/ci/catalog/mock.js new file mode 100644 index 00000000000..21fed6ac8ec --- /dev/null +++ b/spec/frontend/ci/catalog/mock.js @@ -0,0 +1,546 @@ +import { componentsMockData } from '~/ci/catalog/constants'; + +export const catalogResponseBody = { + data: { + ciCatalogResources: { + pageInfo: { + startCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAxNS0wNy0wMyAxMDowMDowMC4wMDAwMDAwMDAgKzAwMDAiLCJpZCI6IjEyOSJ9', + endCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAxNS0wNy0wMyAxMDowMDowMC4wMDAwMDAwMDAgKzAwMDAiLCJpZCI6IjExMCJ9', + hasNextPage: true, + hasPreviousPage: false, + __typename: 'PageInfo', + }, + count: 41, + nodes: [ + { + id: 'gid://gitlab/Ci::Catalog::Resource/129', + icon: null, + name: 'Project-42 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-42', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/128', + icon: null, + name: 'Project-41 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-41', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/127', + icon: null, + name: 'Project-40 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-40', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/126', + icon: null, + name: 'Project-39 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-39', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/125', + icon: null, + name: 'Project-38 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-38', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/124', + icon: null, + name: 'Project-37 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-37', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/123', + icon: null, + name: 'Project-36 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-36', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/122', + icon: null, + name: 'Project-35 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-35', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/121', + icon: null, + name: 'Project-34 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-34', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/120', + icon: null, + name: 'Project-33 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-33', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/119', + icon: null, + name: 'Project-32 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-32', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/118', + icon: null, + name: 'Project-31 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-31', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/117', + icon: null, + name: 'Project-30 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-30', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/116', + icon: null, + name: 'Project-29 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-29', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/115', + icon: null, + name: 'Project-28 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-28', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/114', + icon: null, + name: 'Project-27 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-27', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/113', + icon: null, + name: 'Project-26 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-26', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/112', + icon: null, + name: 'Project-25 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-25', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/111', + icon: null, + name: 'Project-24 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-24', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/110', + icon: null, + name: 'Project-23 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-23', + __typename: 'CiCatalogResource', + }, + ], + __typename: 'CiCatalogResourceConnection', + }, + }, +}; + +export const catalogSinglePageResponse = { + data: { + ciCatalogResources: { + pageInfo: { + startCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAxNS0wNy0wMyAxMDowMDowMC4wMDAwMDAwMDAgKzAwMDAiLCJpZCI6IjEzMiJ9', + endCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAxNS0wNy0wMyAxMDowMDowMC4wMDAwMDAwMDAgKzAwMDAiLCJpZCI6IjEzMCJ9', + hasNextPage: false, + hasPreviousPage: false, + __typename: 'PageInfo', + }, + count: 3, + nodes: [ + { + id: 'gid://gitlab/Ci::Catalog::Resource/132', + icon: null, + name: 'Project-45 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-45', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/131', + icon: null, + name: 'Project-44 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-44', + __typename: 'CiCatalogResource', + }, + { + id: 'gid://gitlab/Ci::Catalog::Resource/130', + icon: null, + name: 'Project-43 Name', + description: 'A simple component', + starCount: 0, + forksCount: 0, + latestVersion: null, + rootNamespace: { + id: 'gid://gitlab/Group/185', + fullPath: 'frontend-fixtures', + name: 'frontend-fixtures', + __typename: 'Namespace', + }, + webPath: '/frontend-fixtures/project-43', + __typename: 'CiCatalogResource', + }, + ], + __typename: 'CiCatalogResourceConnection', + }, + }, +}; + +export const catalogSharedDataMock = { + data: { + ciCatalogResource: { + __typename: 'CiCatalogResource', + id: `gid://gitlab/CiCatalogResource/1`, + icon: null, + description: 'This is the description of the repo', + name: 'Ruby', + rootNamespace: { id: 1, fullPath: '/group/project', name: 'my-dumb-project' }, + starCount: 1, + forksCount: 2, + latestVersion: { + __typename: 'Release', + id: '3', + tagName: '1.0.0', + tagPath: 'path/to/release', + releasedAt: Date.now(), + author: { id: 1, webUrl: 'profile/1', name: 'username' }, + }, + webPath: 'path/to/project', + }, + }, +}; + +export const catalogAdditionalDetailsMock = { + data: { + ciCatalogResource: { + __typename: 'CiCatalogResource', + id: `gid://gitlab/CiCatalogResource/1`, + openIssuesCount: 4, + openMergeRequestsCount: 10, + readmeHtml: '<h1>Hello world</h1>', + versions: { + __typename: 'ReleaseConnection', + nodes: [ + { + __typename: 'Release', + id: 'gid://gitlab/Release/3', + commit: { + __typename: 'Commit', + id: 'gid://gitlab/CommitPresenter/afa936495f20e08c26ed4a67130ee2166f94fa6e', + pipelines: { + __typename: 'PipelineConnection', + nodes: [ + { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/583', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-583-583', + detailsPath: '/root/cicd-circular/-/pipelines/583', + icon: 'status_success', + text: 'passed', + group: 'success', + }, + }, + ], + }, + }, + tagName: 'v1.0.2', + releasedAt: '2022-08-23T17:19:09Z', + }, + ], + }, + }, + }, +}; + +const generateResourcesNodes = (count = 20, startId = 0) => { + const nodes = []; + for (let i = startId; i < startId + count; i += 1) { + nodes.push({ + __typename: 'CiCatalogResource', + id: `gid://gitlab/CiCatalogResource/${i}`, + description: `This is a component that does a bunch of stuff and is really just a number: ${i}`, + forksCount: 5, + icon: 'my-icon', + name: `My component #${i}`, + rootNamespace: { + id: 1, + __typename: 'Namespace', + name: 'namespaceName', + path: 'namespacePath', + }, + starCount: 10, + latestVersion: { + __typename: 'Release', + id: '3', + tagName: '1.0.0', + tagPath: 'path/to/release', + releasedAt: Date.now(), + author: { id: 1, webUrl: 'profile/1', name: 'username' }, + }, + webPath: 'path/to/project', + }); + } + + return nodes; +}; + +export const mockCatalogResourceItem = generateResourcesNodes(1)[0]; + +export const mockComponents = { + data: { + ciCatalogResource: { + __typename: 'CiCatalogResource', + id: `gid://gitlab/CiCatalogResource/1`, + components: { + ...componentsMockData, + }, + }, + }, +}; diff --git a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js index 64227872af3..353b5fd3c47 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js @@ -1,10 +1,4 @@ -import { - GlListboxItem, - GlCollapsibleListbox, - GlDropdownDivider, - GlDropdownItem, - GlIcon, -} from '@gitlab/ui'; +import { GlListboxItem, GlCollapsibleListbox, GlDropdownDivider, GlIcon } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { allEnvironments, ENVIRONMENT_QUERY_LIMIT } from '~/ci/ci_variable_list/constants'; import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; @@ -16,7 +10,6 @@ describe('Ci environments dropdown', () => { const defaultProps = { areEnvironmentsLoading: false, environments: envs, - hasEnvScopeQuery: false, selectedEnvironmentScope: '', }; @@ -25,7 +18,7 @@ describe('Ci environments dropdown', () => { const findActiveIconByIndex = (index) => findListboxItemByIndex(index).findComponent(GlIcon); const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findListboxText = () => findListbox().props('toggleText'); - const findCreateWildcardButton = () => wrapper.findComponent(GlDropdownItem); + const findCreateWildcardButton = () => wrapper.findByTestId('create-wildcard-button'); const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); const findMaxEnvNote = () => wrapper.findByTestId('max-envs-notice'); @@ -57,32 +50,23 @@ describe('Ci environments dropdown', () => { }); describe('Search term is empty', () => { - describe.each` - hasEnvScopeQuery | status | defaultEnvStatus | firstItemValue | envIndices - ${true} | ${'exists'} | ${'prepends'} | ${'*'} | ${[1, 2, 3]} - ${false} | ${'does not exist'} | ${'does not prepend'} | ${envs[0]} | ${[0, 1, 2]} - `( - 'when query for fetching environment scope $status', - ({ defaultEnvStatus, firstItemValue, hasEnvScopeQuery, envIndices }) => { - beforeEach(() => { - createComponent({ props: { environments: envs, hasEnvScopeQuery } }); - }); - - it(`${defaultEnvStatus} * in listbox`, () => { - expect(findListboxItemByIndex(0).text()).toBe(firstItemValue); - }); - - it('renders all environments', () => { - expect(findListboxItemByIndex(envIndices[0]).text()).toBe(envs[0]); - expect(findListboxItemByIndex(envIndices[1]).text()).toBe(envs[1]); - expect(findListboxItemByIndex(envIndices[2]).text()).toBe(envs[2]); - }); - - it('does not display active checkmark', () => { - expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); - }); - }, - ); + beforeEach(() => { + createComponent({ props: { environments: envs } }); + }); + + it(`prepends * in listbox`, () => { + expect(findListboxItemByIndex(0).text()).toBe('*'); + }); + + it('renders all environments', () => { + expect(findListboxItemByIndex(1).text()).toBe(envs[0]); + expect(findListboxItemByIndex(2).text()).toBe(envs[1]); + expect(findListboxItemByIndex(3).text()).toBe(envs[2]); + }); + + it('does not display active checkmark', () => { + expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); + }); }); describe('when `*` is the value of selectedEnvironmentScope props', () => { @@ -98,40 +82,13 @@ describe('Ci environments dropdown', () => { }); }); - describe('when environments are not fetched via graphql', () => { + describe('when fetching environments', () => { const currentEnv = envs[2]; beforeEach(() => { createComponent(); }); - it('filters on the frontend and renders only the environment searched for', async () => { - await findListbox().vm.$emit('search', currentEnv); - - expect(findAllListboxItems()).toHaveLength(1); - expect(findListboxItemByIndex(0).text()).toBe(currentEnv); - }); - - it('does not emit event when searching', async () => { - expect(wrapper.emitted('search-environment-scope')).toBeUndefined(); - - await findListbox().vm.$emit('search', currentEnv); - - expect(wrapper.emitted('search-environment-scope')).toBeUndefined(); - }); - - it('does not display note about max environments shown', () => { - expect(findMaxEnvNote().exists()).toBe(false); - }); - }); - - describe('when fetching environments via graphql', () => { - const currentEnv = envs[2]; - - beforeEach(() => { - createComponent({ props: { hasEnvScopeQuery: true } }); - }); - it('renders dropdown divider', () => { expect(findDropdownDivider().exists()).toBe(true); }); @@ -143,7 +100,7 @@ describe('Ci environments dropdown', () => { }); it('renders dropdown loading icon while fetch query is loading', () => { - createComponent({ props: { areEnvironmentsLoading: true, hasEnvScopeQuery: true } }); + createComponent({ props: { areEnvironmentsLoading: true } }); expect(findListbox().props('loading')).toBe(true); expect(findListbox().props('searching')).toBe(false); @@ -151,7 +108,7 @@ describe('Ci environments dropdown', () => { }); it('renders search loading icon while search query is loading and dropdown is open', async () => { - createComponent({ props: { areEnvironmentsLoading: true, hasEnvScopeQuery: true } }); + createComponent({ props: { areEnvironmentsLoading: true } }); await findListbox().vm.$emit('shown'); expect(findListbox().props('loading')).toBe(false); @@ -188,16 +145,35 @@ describe('Ci environments dropdown', () => { }); }); - describe('when creating a new environment from a search term', () => { - const search = 'new-env'; + describe('when creating a new environment scope from a search term', () => { + const searchTerm = 'new-env'; beforeEach(() => { - createComponent({ searchTerm: search }); + createComponent({ searchTerm }); }); - it('emits create-environment-scope', () => { - findCreateWildcardButton().vm.$emit('click'); + it('sets new environment scope as the selected environment scope', async () => { + findCreateWildcardButton().trigger('click'); + + await findListbox().vm.$emit('search', searchTerm); + + expect(findListbox().props('selected')).toBe(searchTerm); + }); + + it('includes new environment scope in search if it matches search term', async () => { + findCreateWildcardButton().trigger('click'); + + await findListbox().vm.$emit('search', searchTerm); + + expect(findAllListboxItems()).toHaveLength(envs.length + 1); + expect(findListboxItemByIndex(1).text()).toBe(searchTerm); + }); + + it('excludes new environment scope in search if it does not match the search term', async () => { + findCreateWildcardButton().trigger('click'); + + await findListbox().vm.$emit('search', 'not-new-env'); - expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]); + expect(findAllListboxItems()).toHaveLength(envs.length); }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js index ab5d914a6a1..207ea7aa060 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js @@ -1,4 +1,5 @@ -import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect, GlModal } from '@gitlab/ui'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; import CiVariableDrawer from '~/ci/ci_variable_list/components/ci_variable_drawer.vue'; @@ -67,6 +68,8 @@ describe('CI Variable Drawer', () => { }; const findConfirmBtn = () => wrapper.findByTestId('ci-variable-confirm-btn'); + const findConfirmDeleteModal = () => wrapper.findComponent(GlModal); + const findDeleteBtn = () => wrapper.findByTestId('ci-variable-delete-btn'); const findDisabledEnvironmentScopeDropdown = () => wrapper.findComponent(GlFormInput); const findDrawer = () => wrapper.findComponent(GlDrawer); const findEnvironmentScopeDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown); @@ -363,22 +366,118 @@ describe('CI Variable Drawer', () => { }); it('title and confirm button renders the correct text', () => { - expect(findTitle().text()).toBe('Add Variable'); - expect(findConfirmBtn().text()).toBe('Add Variable'); + expect(findTitle().text()).toBe('Add variable'); + expect(findConfirmBtn().text()).toBe('Add variable'); + }); + + it('does not render delete button', () => { + expect(findDeleteBtn().exists()).toBe(false); + }); + + it('dispatches the add-variable event', async () => { + await findKeyField().vm.$emit('input', 'NEW_VARIABLE'); + await findProtectedCheckbox().vm.$emit('input', false); + await findExpandedCheckbox().vm.$emit('input', true); + await findMaskedCheckbox().vm.$emit('input', true); + await findValueField().vm.$emit('input', 'NEW_VALUE'); + + findConfirmBtn().vm.$emit('click'); + + expect(wrapper.emitted('add-variable')).toEqual([ + [ + { + environmentScope: '*', + key: 'NEW_VARIABLE', + masked: true, + protected: false, + raw: false, // opposite of expanded + value: 'NEW_VALUE', + variableType: 'ENV_VAR', + }, + ], + ]); }); }); describe('when editing a variable', () => { beforeEach(() => { createComponent({ - props: { mode: EDIT_VARIABLE_ACTION }, + props: { mode: EDIT_VARIABLE_ACTION, selectedVariable: mockProjectVariableFileType }, stubs: { GlDrawer }, }); }); it('title and confirm button renders the correct text', () => { - expect(findTitle().text()).toBe('Edit Variable'); - expect(findConfirmBtn().text()).toBe('Edit Variable'); + expect(findTitle().text()).toBe('Edit variable'); + expect(findConfirmBtn().text()).toBe('Edit variable'); + }); + + it('dispatches the edit-variable event', async () => { + await findValueField().vm.$emit('input', 'EDITED_VALUE'); + + findConfirmBtn().vm.$emit('click'); + + expect(wrapper.emitted('update-variable')).toEqual([ + [ + { + ...mockProjectVariableFileType, + value: 'EDITED_VALUE', + }, + ], + ]); + }); + }); + + describe('when deleting a variable', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { mode: EDIT_VARIABLE_ACTION, selectedVariable: mockProjectVariableFileType }, + }); + }); + + it('bubbles up the delete-variable event', async () => { + findDeleteBtn().vm.$emit('click'); + + await nextTick(); + + findConfirmDeleteModal().vm.$emit('primary'); + + expect(wrapper.emitted('delete-variable')).toEqual([[mockProjectVariableFileType]]); + }); + }); + + describe('environment scope events', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + mode: EDIT_VARIABLE_ACTION, + selectedVariable: mockProjectVariableFileType, + areScopedVariablesAvailable: true, + hideEnvironmentScope: false, + }, + }); + }); + + it('sets the environment scope', async () => { + await findEnvironmentScopeDropdown().vm.$emit('select-environment', 'staging'); + await findConfirmBtn().vm.$emit('click'); + + expect(wrapper.emitted('update-variable')).toEqual([ + [ + { + ...mockProjectVariableFileType, + environmentScope: 'staging', + }, + ], + ]); + }); + + it('bubbles up the search event', async () => { + await findEnvironmentScopeDropdown().vm.$emit('search-environment-scope', 'staging'); + + expect(wrapper.emitted('search-environment-scope')).toEqual([['staging']]); }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js index 7dce23f72c0..5ba9b3b8c20 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -12,12 +12,10 @@ import { ENVIRONMENT_SCOPE_LINK_TITLE, AWS_TIP_TITLE, AWS_TIP_MESSAGE, - groupString, instanceString, - projectString, variableOptions, } from '~/ci/ci_variable_list/constants'; -import { mockEnvs, mockVariablesWithScopes, mockVariablesWithUniqueScopes } from '../mocks'; +import { mockVariablesWithScopes } from '../mocks'; import ModalStub from '../stubs'; describe('Ci variable modal', () => { @@ -46,7 +44,6 @@ describe('Ci variable modal', () => { areScopedVariablesAvailable: true, environments: [], hideEnvironmentScope: false, - hasEnvScopeQuery: false, mode: ADD_VARIABLE_ACTION, selectedVariable: {}, variables: [], @@ -352,42 +349,6 @@ describe('Ci variable modal', () => { expect(link.attributes('title')).toBe(ENVIRONMENT_SCOPE_LINK_TITLE); expect(link.attributes('href')).toBe(defaultProvide.environmentScopeLink); }); - - describe('when query for envioronment scope exists', () => { - beforeEach(() => { - createComponent({ - props: { - environments: mockEnvs, - hasEnvScopeQuery: true, - variables: mockVariablesWithUniqueScopes(projectString), - }, - }); - }); - - it('does not merge environment scope sources', () => { - const expectedLength = mockEnvs.length; - - expect(findCiEnvironmentsDropdown().props('environments')).toHaveLength(expectedLength); - }); - }); - - describe('when feature flag is disabled', () => { - const mockGroupVariables = mockVariablesWithUniqueScopes(groupString); - beforeEach(() => { - createComponent({ - props: { - environments: mockEnvs, - variables: mockGroupVariables, - }, - }); - }); - - it('merges environment scope sources', () => { - const expectedLength = mockGroupVariables.length + mockEnvs.length; - - expect(findCiEnvironmentsDropdown().props('environments')).toHaveLength(expectedLength); - }); - }); }); describe('and section is hidden', () => { diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js index 79dd638e2bd..04145c2c6aa 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js @@ -23,7 +23,6 @@ describe('Ci variable table', () => { environments: mapEnvironmentNames(mockEnvs), hideEnvironmentScope: false, isLoading: false, - hasEnvScopeQuery: false, maxVariableLimit: 5, pageInfo: { after: '' }, variables: mockVariablesWithScopes(projectString), @@ -70,7 +69,6 @@ describe('Ci variable table', () => { areEnvironmentsLoading: defaultProps.areEnvironmentsLoading, areScopedVariablesAvailable: defaultProps.areScopedVariablesAvailable, environments: defaultProps.environments, - hasEnvScopeQuery: defaultProps.hasEnvScopeQuery, hideEnvironmentScope: defaultProps.hideEnvironmentScope, variables: defaultProps.variables, mode: ADD_VARIABLE_ACTION, @@ -142,7 +140,7 @@ describe('Ci variable table', () => { }); }); - describe('variable events', () => { + describe('variable events for modal', () => { beforeEach(() => { createComponent(); }); @@ -161,6 +159,25 @@ describe('Ci variable table', () => { }); }); + describe('variable events for drawer', () => { + beforeEach(() => { + createComponent({ featureFlags: { ciVariableDrawer: true } }); + }); + + it.each` + eventName + ${'add-variable'} + ${'update-variable'} + ${'delete-variable'} + `('bubbles up the $eventName event', async ({ eventName }) => { + await findCiVariableTable().vm.$emit('set-selected-variable'); + + await findCiVariableDrawer().vm.$emit(eventName, newVariable); + + expect(wrapper.emitted(eventName)).toEqual([[newVariable]]); + }); + }); + describe('pages events', () => { beforeEach(() => { createComponent(); @@ -178,7 +195,7 @@ describe('Ci variable table', () => { }); }); - describe('environment events', () => { + describe('environment events for modal', () => { beforeEach(() => { createComponent(); }); @@ -191,4 +208,18 @@ describe('Ci variable table', () => { expect(wrapper.emitted('search-environment-scope')).toEqual([['staging']]); }); }); + + describe('environment events for drawer', () => { + beforeEach(() => { + createComponent({ featureFlags: { ciVariableDrawer: true } }); + }); + + it('bubbles up the search event', async () => { + await findCiVariableTable().vm.$emit('set-selected-variable'); + + await findCiVariableDrawer().vm.$emit('search-environment-scope', 'staging'); + + expect(wrapper.emitted('search-environment-scope')).toEqual([['staging']]); + }); + }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js index 6fa1915f3c1..c90ff4cc682 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js @@ -52,7 +52,6 @@ const mockProvide = { const defaultProps = { areScopedVariablesAvailable: true, - hasEnvScopeQuery: false, pageInfo: {}, hideEnvironmentScope: false, refetchAfterMutation: false, @@ -514,7 +513,6 @@ describe('Ci Variable Shared Component', () => { areEnvironmentsLoading: false, areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable, hideEnvironmentScope: defaultProps.hideEnvironmentScope, - hasEnvScopeQuery: props.hasEnvScopeQuery, pageInfo: defaultProps.pageInfo, isLoading: false, maxVariableLimit, diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js index 41dfc0ebfda..9c9c99ad5ea 100644 --- a/spec/frontend/ci/ci_variable_list/mocks.js +++ b/spec/frontend/ci/ci_variable_list/mocks.js @@ -189,7 +189,6 @@ export const createProjectProps = () => { componentName: 'ProjectVariable', entity: 'project', fullPath: '/namespace/project/', - hasEnvScopeQuery: true, id: 'gid://gitlab/Project/20', mutationData: { [ADD_MUTATION_ACTION]: addProjectVariable, @@ -214,7 +213,6 @@ export const createGroupProps = () => { componentName: 'GroupVariable', entity: 'group', fullPath: '/my-group', - hasEnvScopeQuery: false, id: 'gid://gitlab/Group/20', mutationData: { [ADD_MUTATION_ACTION]: addGroupVariable, @@ -233,7 +231,6 @@ export const createGroupProps = () => { export const createInstanceProps = () => { return { componentName: 'InstanceVariable', - hasEnvScopeQuery: false, entity: '', mutationData: { [ADD_MUTATION_ACTION]: addAdminVariable, diff --git a/spec/frontend/ci/ci_variable_list/utils_spec.js b/spec/frontend/ci/ci_variable_list/utils_spec.js index beeae71376a..fbcf0e7c5a5 100644 --- a/spec/frontend/ci/ci_variable_list/utils_spec.js +++ b/spec/frontend/ci/ci_variable_list/utils_spec.js @@ -1,58 +1,7 @@ -import { - createJoinedEnvironments, - convertEnvironmentScope, - mapEnvironmentNames, -} from '~/ci/ci_variable_list/utils'; +import { convertEnvironmentScope, mapEnvironmentNames } from '~/ci/ci_variable_list/utils'; import { allEnvironments } from '~/ci/ci_variable_list/constants'; describe('utils', () => { - const environments = ['dev', 'prod']; - const newEnvironments = ['staging']; - - describe('createJoinedEnvironments', () => { - it('returns only `environments` if `variables` argument is undefined', () => { - const variables = undefined; - - expect(createJoinedEnvironments(variables, environments, [])).toEqual(environments); - }); - - it('returns a list of environments and environment scopes taken from variables in alphabetical order', () => { - const envScope1 = 'new1'; - const envScope2 = 'new2'; - - const variables = [{ environmentScope: envScope1 }, { environmentScope: envScope2 }]; - - expect(createJoinedEnvironments(variables, environments, [])).toEqual([ - environments[0], - envScope1, - envScope2, - environments[1], - ]); - }); - - it('returns combined list with new environments included', () => { - const variables = undefined; - - expect(createJoinedEnvironments(variables, environments, newEnvironments)).toEqual([ - ...environments, - ...newEnvironments, - ]); - }); - - it('removes duplicate environments', () => { - const envScope1 = environments[0]; - const envScope2 = 'new2'; - - const variables = [{ environmentScope: envScope1 }, { environmentScope: envScope2 }]; - - expect(createJoinedEnvironments(variables, environments, [])).toEqual([ - environments[0], - envScope2, - environments[1], - ]); - }); - }); - describe('convertEnvironmentScope', () => { it('converts the * to the `All environments` text', () => { expect(convertEnvironmentScope('*')).toBe(allEnvironments.text); diff --git a/spec/frontend/ci/common/pipelines_table_spec.js b/spec/frontend/ci/common/pipelines_table_spec.js index 26dd1a2fcc5..6cf391d72ca 100644 --- a/spec/frontend/ci/common/pipelines_table_spec.js +++ b/spec/frontend/ci/common/pipelines_table_spec.js @@ -1,9 +1,7 @@ -import '~/commons'; import { GlTableLite } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import fixture from 'test_fixtures/pipelines/pipelines.json'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue'; import PipelineOperations from '~/ci/pipelines_page/components/pipeline_operations.vue'; @@ -12,7 +10,7 @@ import PipelineUrl from '~/ci/pipelines_page/components/pipeline_url.vue'; import PipelinesTable from '~/ci/common/pipelines_table.vue'; import PipelinesTimeago from '~/ci/pipelines_page/components/time_ago.vue'; import { - PipelineKeyOptions, + PIPELINE_ID_KEY, BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES, @@ -20,51 +18,43 @@ import { import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; -jest.mock('~/ci/event_hub'); - describe('Pipelines Table', () => { - let pipeline; let wrapper; let trackingSpy; const defaultProvide = { - glFeatures: {}, - withFailedJobsDetails: false, + fullPath: '/my-project/', + useFailedJobsWidget: false, }; - const provideWithDetails = { - glFeatures: { - ciJobFailuresInMr: true, - }, - withFailedJobsDetails: true, + const provideWithFailedJobsWidget = { + useFailedJobsWidget: true, }; - const defaultProps = { - pipelines: [], - viewType: 'root', - pipelineKeyOption: PipelineKeyOptions[0], - }; + const { pipelines } = fixture; - const createMockPipeline = () => { - // Clone fixture as it could be modified by tests - const { pipelines } = JSON.parse(JSON.stringify(fixture)); - return pipelines.find((p) => p.user !== null && p.commit !== null); + const defaultProps = { + pipelines, + pipelineIdType: PIPELINE_ID_KEY, }; - const createComponent = (props = {}, provide = {}) => { - wrapper = extendedWrapper( - mount(PipelinesTable, { - propsData: { - ...defaultProps, - ...props, - }, - provide: { - ...defaultProvide, - ...provide, - }, - stubs: ['PipelineFailedJobsWidget'], - }), - ); + const [firstPipeline] = pipelines; + + const createComponent = ({ props = {}, provide = {}, stubs = {} } = {}) => { + wrapper = mountExtended(PipelinesTable, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { + PipelineOperations: true, + ...stubs, + }, + }); }; const findGlTableLite = () => wrapper.findComponent(GlTableLite); @@ -84,13 +74,9 @@ describe('Pipelines Table', () => { const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); - beforeEach(() => { - pipeline = createMockPipeline(); - }); - describe('Pipelines Table', () => { beforeEach(() => { - createComponent({ pipelines: [pipeline], viewType: 'root' }); + createComponent({ props: { viewType: 'root' } }); }); it('displays table', () => { @@ -105,7 +91,7 @@ describe('Pipelines Table', () => { }); it('should display a table row', () => { - expect(findTableRows()).toHaveLength(1); + expect(findTableRows()).toHaveLength(pipelines.length); }); describe('status cell', () => { @@ -120,7 +106,7 @@ describe('Pipelines Table', () => { }); it('should display the pipeline id', () => { - expect(findPipelineInfo().text()).toContain(`#${pipeline.id}`); + expect(findPipelineInfo().text()).toContain(`#${firstPipeline.id}`); }); }); @@ -130,24 +116,33 @@ describe('Pipelines Table', () => { }); it('should render the right number of stages', () => { - const stagesLength = pipeline.details.stages.length; - expect(findLegacyPipelineMiniGraph().props('stages').length).toBe(stagesLength); + const stagesLength = firstPipeline.details.stages.length; + expect(findLegacyPipelineMiniGraph().props('stages')).toHaveLength(stagesLength); }); it('should render the latest downstream pipelines only', () => { // component receives two downstream pipelines. one of them is already outdated // because we retried the trigger job, so the mini pipeline graph will only // render the newly created downstream pipeline instead - expect(pipeline.triggered).toHaveLength(2); + expect(firstPipeline.triggered).toHaveLength(2); expect(findLegacyPipelineMiniGraph().props('downstreamPipelines')).toHaveLength(1); }); describe('when pipeline does not have stages', () => { beforeEach(() => { - pipeline = createMockPipeline(); - pipeline.details.stages = []; - - createComponent({ pipelines: [pipeline] }); + createComponent({ + props: { + pipelines: [ + { + ...firstPipeline, + details: { + ...firstPipeline.details, + stages: [], + }, + }, + ], + }, + }); }); it('stages are not rendered', () => { @@ -163,6 +158,10 @@ describe('Pipelines Table', () => { }); describe('operations cell', () => { + beforeEach(() => { + createComponent({ stubs: { PipelineOperations } }); + }); + it('should render pipeline operations', () => { expect(findActions().exists()).toBe(true); }); @@ -183,97 +182,101 @@ describe('Pipelines Table', () => { }); describe('failed jobs details', () => { - describe('row', () => { - describe('when the FF is disabled', () => { - beforeEach(() => { - createComponent({ pipelines: [pipeline] }); - }); + describe('when `useFailedJobsWidget` value is provided', () => { + beforeEach(() => { + createComponent({ provide: provideWithFailedJobsWidget }); + }); - it('does not render', () => { - expect(findTableRows()).toHaveLength(1); - expect(findPipelineFailureWidget().exists()).toBe(false); - }); + it('renders', () => { + // We have 2 rows per pipeline with the widget + expect(findTableRows()).toHaveLength(pipelines.length * 2); + expect(findPipelineFailureWidget().exists()).toBe(true); }); - describe('when the FF is enabled', () => { - describe('and `withFailedJobsDetails` value is provided', () => { - beforeEach(() => { - createComponent({ pipelines: [pipeline] }, provideWithDetails); - }); - - it('renders', () => { - expect(findTableRows()).toHaveLength(2); - expect(findPipelineFailureWidget().exists()).toBe(true); - }); - - it('passes the expected props', () => { - expect(findPipelineFailureWidget().props()).toStrictEqual({ - failedJobsCount: pipeline.failed_builds.length, - isPipelineActive: pipeline.active, - pipelineIid: pipeline.iid, - pipelinePath: pipeline.path, - // Make sure the forward slash was removed - projectPath: 'frontend-fixtures/pipelines-project', - }); - }); + it('passes the expected props', () => { + expect(findPipelineFailureWidget().props()).toStrictEqual({ + failedJobsCount: firstPipeline.failed_builds_count, + isPipelineActive: firstPipeline.active, + pipelineIid: firstPipeline.iid, + pipelinePath: firstPipeline.path, + // Make sure the forward slash was removed + projectPath: 'frontend-fixtures/pipelines-project', }); + }); + }); - describe('and `withFailedJobsDetails` value is not provided', () => { - beforeEach(() => { - createComponent( - { pipelines: [pipeline] }, - { glFeatures: { ciJobFailuresInMr: true } }, - ); - }); - - it('does not render', () => { - expect(findTableRows()).toHaveLength(1); - expect(findPipelineFailureWidget().exists()).toBe(false); - }); - }); + describe('and `useFailedJobsWidget` value is not provided', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render', () => { + expect(findTableRows()).toHaveLength(pipelines.length); + expect(findPipelineFailureWidget().exists()).toBe(false); }); }); }); + }); - describe('tracking', () => { - beforeEach(() => { - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + describe('events', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when confirming to cancel a pipeline', () => { + beforeEach(async () => { + await findActions().vm.$emit('cancel-pipeline', firstPipeline); }); - afterEach(() => { - unmockTracking(); + it('emits the `cancel-pipeline` event', () => { + expect(wrapper.emitted('cancel-pipeline')).toEqual([[firstPipeline]]); }); + }); - it('tracks status badge click', () => { - findCiBadgeLink().vm.$emit('ciStatusBadgeClick'); + describe('when retrying a pipeline', () => { + beforeEach(() => { + findActions().vm.$emit('retry-pipeline', firstPipeline); + }); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_ci_status_badge', { - label: TRACKING_CATEGORIES.table, - }); + it('emits the `retry-pipeline` event', () => { + expect(wrapper.emitted('retry-pipeline')).toEqual([[firstPipeline]]); }); + }); - it('tracks retry pipeline button click', () => { - findRetryBtn().vm.$emit('click'); + describe('when refreshing pipelines', () => { + beforeEach(() => { + findActions().vm.$emit('refresh-pipelines-table'); + }); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_retry_button', { - label: TRACKING_CATEGORIES.table, - }); + it('emits the `refresh-pipelines-table` event', () => { + expect(wrapper.emitted('refresh-pipelines-table')).toEqual([[]]); }); + }); + }); - it('tracks cancel pipeline button click', () => { - findCancelBtn().vm.$emit('click'); + describe('tracking', () => { + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_cancel_button', { - label: TRACKING_CATEGORIES.table, - }); + afterEach(() => { + unmockTracking(); + }); + + it('tracks status badge click', () => { + findCiBadgeLink().vm.$emit('ciStatusBadgeClick'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_ci_status_badge', { + label: TRACKING_CATEGORIES.table, }); + }); - it('tracks pipeline mini graph stage click', () => { - findLegacyPipelineMiniGraph().vm.$emit('miniGraphStageClick'); + it('tracks pipeline mini graph stage click', () => { + findLegacyPipelineMiniGraph().vm.$emit('miniGraphStageClick'); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_minigraph', { - label: TRACKING_CATEGORIES.table, - }); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_minigraph', { + label: TRACKING_CATEGORIES.table, }); }); }); diff --git a/spec/frontend/ci/job_details/components/job_header_spec.js b/spec/frontend/ci/job_details/components/job_header_spec.js index 6fc55732353..609369316f5 100644 --- a/spec/frontend/ci/job_details/components/job_header_spec.js +++ b/spec/frontend/ci/job_details/components/job_header_spec.js @@ -16,7 +16,7 @@ describe('Header CI Component', () => { text: 'failed', details_path: 'path', }, - name: 'Job build_job', + name: 'build_job', time: '2017-05-08T14:57:39.781Z', user: { id: 1234, @@ -34,17 +34,15 @@ describe('Header CI Component', () => { const findUserLink = () => wrapper.findComponent(GlAvatarLink); const findSidebarToggleBtn = () => wrapper.findComponent(GlButton); const findStatusTooltip = () => wrapper.findComponent(GlTooltip); - const findActionButtons = () => wrapper.findByTestId('job-header-action-buttons'); const findJobName = () => wrapper.findByTestId('job-name'); - const createComponent = (props, slots) => { + const createComponent = (props) => { wrapper = extendedWrapper( shallowMount(JobHeader, { propsData: { ...defaultProps, ...props, }, - ...slots, }), ); }; @@ -54,6 +52,10 @@ describe('Header CI Component', () => { createComponent(); }); + it('renders the correct job name', () => { + expect(findJobName().text()).toBe(defaultProps.name); + }); + it('should render status badge', () => { expect(findCiBadgeLink().exists()).toBe(true); }); @@ -65,10 +67,6 @@ describe('Header CI Component', () => { it('should render sidebar toggle button', () => { expect(findSidebarToggleBtn().exists()).toBe(true); }); - - it('should not render header action buttons when slot is empty', () => { - expect(findActionButtons().exists()).toBe(false); - }); }); describe('user avatar', () => { @@ -124,31 +122,12 @@ describe('Header CI Component', () => { }); }); - describe('job name', () => { - beforeEach(() => { - createComponent(); - }); - - it('should render the job name', () => { - expect(findJobName().text()).toBe('Job build_job'); - }); - }); - - describe('slot', () => { - it('should render header action buttons', () => { - createComponent({}, { slots: { default: 'Test Actions' } }); - - expect(findActionButtons().exists()).toBe(true); - expect(findActionButtons().text()).toBe('Test Actions'); - }); - }); - describe('shouldRenderTriggeredLabel', () => { it('should render created keyword when the shouldRenderTriggeredLabel is false', () => { createComponent({ shouldRenderTriggeredLabel: false }); - expect(wrapper.text()).toContain('created'); - expect(wrapper.text()).not.toContain('started'); + expect(wrapper.text()).toContain('Created'); + expect(wrapper.text()).not.toContain('Started'); }); }); }); diff --git a/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js b/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js index e3d5c448338..5abf2a5ce53 100644 --- a/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js +++ b/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js @@ -1,6 +1,7 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import CollapsibleSection from '~/ci/job_details/components/log/collapsible_section.vue'; +import LogLine from '~/ci/job_details/components/log/line.vue'; import LogLineHeader from '~/ci/job_details/components/log/line_header.vue'; import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data'; @@ -9,9 +10,9 @@ describe('Job Log Collapsible Section', () => { const jobLogEndpoint = 'jobs/335'; - const findCollapsibleLine = () => wrapper.find('.collapsible-line'); - const findCollapsibleLineSvg = () => wrapper.find('.collapsible-line svg'); const findLogLineHeader = () => wrapper.findComponent(LogLineHeader); + const findLogLineHeaderSvg = () => findLogLineHeader().find('svg'); + const findLogLines = () => wrapper.findAllComponents(LogLine); const createComponent = (props = {}) => { wrapper = mount(CollapsibleSection, { @@ -30,11 +31,16 @@ describe('Job Log Collapsible Section', () => { }); it('renders clickable header line', () => { - expect(findCollapsibleLine().attributes('role')).toBe('button'); + expect(findLogLineHeader().text()).toBe('1 foo'); + expect(findLogLineHeader().attributes('role')).toBe('button'); }); - it('renders an icon with the closed state', () => { - expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('chevron-lg-right-icon'); + it('renders an icon with a closed state', () => { + expect(findLogLineHeaderSvg().attributes('data-testid')).toBe('chevron-lg-right-icon'); + }); + + it('does not render collapsed lines', () => { + expect(findLogLines()).toHaveLength(0); }); }); @@ -47,15 +53,17 @@ describe('Job Log Collapsible Section', () => { }); it('renders clickable header line', () => { - expect(findCollapsibleLine().attributes('role')).toBe('button'); + expect(findLogLineHeader().text()).toContain('foo'); + expect(findLogLineHeader().attributes('role')).toBe('button'); }); it('renders an icon with the open state', () => { - expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('chevron-lg-down-icon'); + expect(findLogLineHeaderSvg().attributes('data-testid')).toBe('chevron-lg-down-icon'); }); - it('renders collapsible lines content', () => { - expect(wrapper.findAll('.js-line').length).toEqual(collapsibleSectionOpened.lines.length); + it('renders collapsible lines', () => { + expect(findLogLines().at(0).text()).toContain('this is a collapsible nested section'); + expect(findLogLines()).toHaveLength(collapsibleSectionOpened.lines.length); }); }); @@ -65,7 +73,7 @@ describe('Job Log Collapsible Section', () => { jobLogEndpoint, }); - findCollapsibleLine().trigger('click'); + findLogLineHeader().trigger('click'); await nextTick(); expect(wrapper.emitted('onClickCollapsibleLine').length).toBe(1); diff --git a/spec/frontend/ci/job_details/components/log/line_header_spec.js b/spec/frontend/ci/job_details/components/log/line_header_spec.js index 7d1b05346f2..45296e4b6c2 100644 --- a/spec/frontend/ci/job_details/components/log/line_header_spec.js +++ b/spec/frontend/ci/job_details/components/log/line_header_spec.js @@ -16,7 +16,7 @@ describe('Job Log Header Line', () => { style: 'term-fg-l-green', }, ], - lineNumber: 76, + lineNumber: 77, }, isClosed: true, path: '/jashkenas/underscore/-/jobs/335', diff --git a/spec/frontend/ci/job_details/components/log/line_number_spec.js b/spec/frontend/ci/job_details/components/log/line_number_spec.js index d5c1d0fd985..db964e341b7 100644 --- a/spec/frontend/ci/job_details/components/log/line_number_spec.js +++ b/spec/frontend/ci/job_details/components/log/line_number_spec.js @@ -5,7 +5,7 @@ describe('Job Log Line Number', () => { let wrapper; const data = { - lineNumber: 0, + lineNumber: 1, path: '/jashkenas/underscore/-/jobs/335', }; diff --git a/spec/frontend/ci/job_details/components/log/line_spec.js b/spec/frontend/ci/job_details/components/log/line_spec.js index b6f3a2b68df..dad41d0cd7f 100644 --- a/spec/frontend/ci/job_details/components/log/line_spec.js +++ b/spec/frontend/ci/job_details/components/log/line_spec.js @@ -224,7 +224,7 @@ describe('Job Log Line', () => { offset: 24526, content: [{ text: 'job log content' }], section: 'custom-section', - lineNumber: 76, + lineNumber: 77, }, path: '/root/ci-project/-/jobs/6353', }); diff --git a/spec/frontend/ci/job_details/components/log/log_spec.js b/spec/frontend/ci/job_details/components/log/log_spec.js index cc1621b87d6..1931d5046dc 100644 --- a/spec/frontend/ci/job_details/components/log/log_spec.js +++ b/spec/frontend/ci/job_details/components/log/log_spec.js @@ -7,7 +7,7 @@ import { scrollToElement } from '~/lib/utils/common_utils'; import Log from '~/ci/job_details/components/log/log.vue'; import LogLineHeader from '~/ci/job_details/components/log/line_header.vue'; import { logLinesParser } from '~/ci/job_details/store/utils'; -import { jobLog } from './mock_data'; +import { mockJobLog, mockJobLogLineCount } from './mock_data'; jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), @@ -39,7 +39,7 @@ describe('Job Log', () => { }; state = { - jobLog: logLinesParser(jobLog), + jobLog: logLinesParser(mockJobLog), jobLogEndpoint: 'jobs/id', }; @@ -57,15 +57,18 @@ describe('Job Log', () => { createComponent(); }); - it('renders a line number for each open line', () => { - expect(wrapper.find('#L1').text()).toBe('1'); - expect(wrapper.find('#L2').text()).toBe('2'); - expect(wrapper.find('#L3').text()).toBe('3'); - }); + it.each([...Array(mockJobLogLineCount).keys()])( + 'renders a line number for each line %d', + (index) => { + const lineNumber = wrapper + .findAll('.js-log-line') + .at(index) + .find(`#L${index + 1}`); - it('links to the provided path and correct line number', () => { - expect(wrapper.find('#L1').attributes('href')).toBe(`${state.jobLogEndpoint}#L1`); - }); + expect(lineNumber.text()).toBe(`${index + 1}`); + expect(lineNumber.attributes('href')).toBe(`${state.jobLogEndpoint}#L${index + 1}`); + }, + ); }); describe('collapsible sections', () => { @@ -103,7 +106,7 @@ describe('Job Log', () => { await waitForPromises(); - expect(wrapper.find('#L6').exists()).toBe(false); + expect(wrapper.find('#L9').exists()).toBe(false); expect(scrollToElement).not.toHaveBeenCalled(); }); }); @@ -116,19 +119,19 @@ describe('Job Log', () => { it('scrolls to line number', async () => { createComponent(); - state.jobLog = logLinesParser(jobLog, [], '#L6'); + state.jobLog = logLinesParser(mockJobLog, [], '#L6'); await waitForPromises(); expect(scrollToElement).toHaveBeenCalledTimes(1); - state.jobLog = logLinesParser(jobLog, [], '#L7'); + state.jobLog = logLinesParser(mockJobLog, [], '#L7'); await waitForPromises(); expect(scrollToElement).toHaveBeenCalledTimes(1); }); it('line number within collapsed section is visible', () => { - state.jobLog = logLinesParser(jobLog, [], '#L6'); + state.jobLog = logLinesParser(mockJobLog, [], '#L6'); createComponent(); @@ -148,7 +151,7 @@ describe('Job Log', () => { ], section: 'prepare-executor', section_header: true, - lineNumber: 2, + lineNumber: 3, }, ]; diff --git a/spec/frontend/ci/job_details/components/log/mock_data.js b/spec/frontend/ci/job_details/components/log/mock_data.js index fa51b92a044..14669872cc1 100644 --- a/spec/frontend/ci/job_details/components/log/mock_data.js +++ b/spec/frontend/ci/job_details/components/log/mock_data.js @@ -1,4 +1,4 @@ -export const jobLog = [ +export const mockJobLog = [ { offset: 1000, content: [{ text: 'Running with gitlab-runner 12.1.0 (de7731dd)' }], @@ -19,69 +19,50 @@ export const jobLog = [ }, { offset: 1003, - content: [{ text: 'Starting service postgres:9.6.14 ...', style: 'text-green' }], + content: [{ text: 'Docker executor with image registry.gitlab.com ...' }], section: 'prepare-executor', }, { offset: 1004, - content: [ - { - text: 'Restore cache', - style: 'term-fg-l-cyan term-bold', - }, - ], - section: 'restore-cache', - section_header: true, - section_options: { - collapsed: 'true', - }, + content: [{ text: 'Starting service ...', style: 'term-fg-l-green' }], + section: 'prepare-executor', }, { offset: 1005, - content: [ - { - text: 'Checking cache for ruby-gems-debian-bullseye-ruby-3.0-16...', - style: 'term-fg-l-green term-bold', - }, - ], - section: 'restore-cache', - }, -]; - -export const utilsMockData = [ - { - offset: 1001, - content: [{ text: ' on docker-auto-scale-com 8a6210b8' }], + content: [], + section: 'prepare-executor', + section_duration: '00:09', }, { - offset: 1002, + offset: 1006, content: [ { - text: - 'Using Docker executor with image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-84-node-12.x-yarn-1.21-postgresql-11-graphicsmagick-1.3.34', + text: 'Getting source from Git repository', }, ], - section: 'prepare-executor', + section: 'get-sources', section_header: true, }, { - offset: 1003, - content: [{ text: 'Starting service postgres:9.6.14 ...' }], - section: 'prepare-executor', + offset: 1007, + content: [{ text: 'Fetching changes with git depth set to 20...' }], + section: 'get-sources', }, { - offset: 1004, - content: [{ text: 'Pulling docker image postgres:9.6.14 ...', style: 'term-fg-l-green' }], - section: 'prepare-executor', + offset: 1008, + content: [{ text: 'Initialized empty Git repository', style: 'term-fg-l-green' }], + section: 'get-sources', }, { - offset: 1005, + offset: 1009, content: [], - section: 'prepare-executor', - section_duration: '10:00', + section: 'get-sources', + section_duration: '00:19', }, ]; +export const mockJobLogLineCount = 8; // `text` entries in mockJobLog + export const originalTrace = [ { offset: 1, @@ -191,7 +172,7 @@ export const collapsibleSectionClosed = { offset: 80, content: [{ text: 'this is a collapsible nested section' }], section: 'prepare-script', - lineNumber: 3, + lineNumber: 2, }, ], }; @@ -212,7 +193,7 @@ export const collapsibleSectionOpened = { offset: 80, content: [{ text: 'this is a collapsible nested section' }], section: 'prepare-script', - lineNumber: 3, + lineNumber: 2, }, ], }; diff --git a/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js b/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js index 1d61bf3243f..e539be2b220 100644 --- a/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js @@ -30,31 +30,31 @@ describe('Artifacts block', () => { 'These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.'; const expiredArtifact = { - expire_at: expireAt, + expireAt, expired: true, locked: false, }; const nonExpiredArtifact = { - download_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', - browse_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', - keep_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/keep', - expire_at: expireAt, + downloadPath: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', + browsePath: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', + keepPath: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/keep', + expireAt, expired: false, locked: false, }; const lockedExpiredArtifact = { ...expiredArtifact, - download_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', - browse_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', + downloadPath: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', + browsePath: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', expired: true, locked: true, }; const lockedNonExpiredArtifact = { ...nonExpiredArtifact, - keep_path: undefined, + keepPath: undefined, locked: true, }; diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js index 1063bec6f3b..81181fc71b2 100644 --- a/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js @@ -55,15 +55,9 @@ describe('Sidebar Header', () => { const findEraseButton = () => wrapper.findByTestId('job-log-erase-link'); const findNewIssueButton = () => wrapper.findByTestId('job-new-issue'); const findTerminalLink = () => wrapper.findByTestId('terminal-link'); - const findJobName = () => wrapper.findByTestId('job-name'); const findRetryButton = () => wrapper.findComponent(JobRetryButton); describe('when rendering contents', () => { - it('renders the correct job name', async () => { - await createComponentWithApollo(); - expect(findJobName().text()).toBe(mockJobResponse.data.project.job.name); - }); - it('does not render buttons with no paths', async () => { await createComponentWithApollo(); expect(findCancelButton().exists()).toBe(false); diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js index e188d99b8b1..37a2ca75df0 100644 --- a/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js @@ -53,7 +53,6 @@ describe('Job Sidebar Details Container', () => { ['erased_at', 'Erased: 3 weeks ago'], ['finished_at', 'Finished: 3 weeks ago'], ['queued_duration', 'Queued: 9 seconds'], - ['id', 'Job ID: #4757'], ['runner', 'Runner: #1 (ABCDEFGH) local ci runner'], ['coverage', 'Coverage: 20%'], ])('uses %s to render job-%s', async (detail, value) => { @@ -78,7 +77,7 @@ describe('Job Sidebar Details Container', () => { createWrapper(); await store.dispatch('receiveJobSuccess', job); - expect(findAllDetailsRow()).toHaveLength(8); + expect(findAllDetailsRow()).toHaveLength(7); }); describe('duration row', () => { diff --git a/spec/frontend/ci/job_details/job_app_spec.js b/spec/frontend/ci/job_details/job_app_spec.js index c2d91771495..ff84b2d0283 100644 --- a/spec/frontend/ci/job_details/job_app_spec.js +++ b/spec/frontend/ci/job_details/job_app_spec.js @@ -31,8 +31,6 @@ describe('Job App', () => { const initSettings = { endpoint: `${TEST_HOST}jobs/123.json`, pagePath: `${TEST_HOST}jobs/123`, - logState: - 'eyJvZmZzZXQiOjE3NDUxLCJuX29wZW5fdGFncyI6MCwiZmdfY29sb3IiOm51bGwsImJnX2NvbG9yIjpudWxsLCJzdHlsZV9tYXNrIjowfQ%3D%3D', }; const props = { diff --git a/spec/frontend/ci/job_details/store/actions_spec.js b/spec/frontend/ci/job_details/store/actions_spec.js index bb5c1fe32bd..2799bc9578c 100644 --- a/spec/frontend/ci/job_details/store/actions_spec.js +++ b/spec/frontend/ci/job_details/store/actions_spec.js @@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import { - setJobEndpoint, setJobLogOptions, clearEtagPoll, stopPolling, @@ -39,25 +38,21 @@ describe('Job State actions', () => { mockedState = state(); }); - describe('setJobEndpoint', () => { - it('should commit SET_JOB_ENDPOINT mutation', () => { - return testAction( - setJobEndpoint, - 'job/872324.json', - mockedState, - [{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }], - [], - ); - }); - }); - describe('setJobLogOptions', () => { it('should commit SET_JOB_LOG_OPTIONS mutation', () => { return testAction( setJobLogOptions, - { pagePath: 'job/872324/trace.json' }, + { endpoint: '/group1/project1/-/jobs/99.json', pagePath: '/group1/project1/-/jobs/99' }, mockedState, - [{ type: types.SET_JOB_LOG_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], + [ + { + type: types.SET_JOB_LOG_OPTIONS, + payload: { + endpoint: '/group1/project1/-/jobs/99.json', + pagePath: '/group1/project1/-/jobs/99', + }, + }, + ], [], ); }); diff --git a/spec/frontend/ci/job_details/store/mutations_spec.js b/spec/frontend/ci/job_details/store/mutations_spec.js index 0835c534fb9..78b29efed68 100644 --- a/spec/frontend/ci/job_details/store/mutations_spec.js +++ b/spec/frontend/ci/job_details/store/mutations_spec.js @@ -12,11 +12,17 @@ describe('Jobs Store Mutations', () => { stateCopy = state(); }); - describe('SET_JOB_ENDPOINT', () => { + describe('SET_JOB_LOG_OPTIONS', () => { it('should set jobEndpoint', () => { - mutations[types.SET_JOB_ENDPOINT](stateCopy, 'job/21312321.json'); + mutations[types.SET_JOB_LOG_OPTIONS](stateCopy, { + endpoint: '/group1/project1/-/jobs/99.json', + pagePath: '/group1/project1/-/jobs/99', + }); - expect(stateCopy.jobEndpoint).toEqual('job/21312321.json'); + expect(stateCopy).toMatchObject({ + jobLogEndpoint: '/group1/project1/-/jobs/99', + jobEndpoint: '/group1/project1/-/jobs/99.json', + }); }); }); @@ -39,13 +45,13 @@ describe('Jobs Store Mutations', () => { describe('RECEIVE_JOB_LOG_SUCCESS', () => { describe('when job log has state', () => { it('sets jobLogState', () => { - const stateLog = + const logState = 'eyJvZmZzZXQiOjczNDQ1MSwibl9vcGVuX3RhZ3MiOjAsImZnX2NvbG9yIjpudWxsLCJiZ19jb2xvciI6bnVsbCwic3R5bGVfbWFzayI6MH0='; mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { - state: stateLog, + state: logState, }); - expect(stateCopy.jobLogState).toEqual(stateLog); + expect(stateCopy.jobLogState).toEqual(logState); }); }); @@ -100,7 +106,7 @@ describe('Jobs Store Mutations', () => { { offset: 1, content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], - lineNumber: 0, + lineNumber: 1, }, ]); }); @@ -121,7 +127,7 @@ describe('Jobs Store Mutations', () => { { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }], - lineNumber: 0, + lineNumber: 1, }, ]); }); diff --git a/spec/frontend/ci/job_details/store/utils_spec.js b/spec/frontend/ci/job_details/store/utils_spec.js index 4ffba35761e..394ce0ab737 100644 --- a/spec/frontend/ci/job_details/store/utils_spec.js +++ b/spec/frontend/ci/job_details/store/utils_spec.js @@ -6,10 +6,10 @@ import { addDurationToHeader, isCollapsibleSection, findOffsetAndRemove, - getIncrementalLineNumber, + getNextLineNumber, } from '~/ci/job_details/store/utils'; import { - utilsMockData, + mockJobLog, originalTrace, regularIncremental, regularIncrementalRepeated, @@ -187,39 +187,49 @@ describe('Jobs Store Utils', () => { let result; beforeEach(() => { - result = logLinesParser(utilsMockData); + result = logLinesParser(mockJobLog); }); describe('regular line', () => { it('adds a lineNumber property with correct index', () => { - expect(result[0].lineNumber).toEqual(0); - expect(result[1].line.lineNumber).toEqual(1); + expect(result[0].lineNumber).toEqual(1); + expect(result[1].lineNumber).toEqual(2); + expect(result[2].line.lineNumber).toEqual(3); + expect(result[2].lines[0].lineNumber).toEqual(4); + expect(result[2].lines[1].lineNumber).toEqual(5); + expect(result[3].line.lineNumber).toEqual(6); + expect(result[3].lines[0].lineNumber).toEqual(7); + expect(result[3].lines[1].lineNumber).toEqual(8); }); }); describe('collapsible section', () => { it('adds a `isClosed` property', () => { - expect(result[1].isClosed).toEqual(false); + expect(result[2].isClosed).toEqual(false); + expect(result[3].isClosed).toEqual(false); }); it('adds a `isHeader` property', () => { - expect(result[1].isHeader).toEqual(true); + expect(result[2].isHeader).toEqual(true); + expect(result[3].isHeader).toEqual(true); }); it('creates a lines array property with the content of the collapsible section', () => { - expect(result[1].lines.length).toEqual(2); - expect(result[1].lines[0].content).toEqual(utilsMockData[2].content); - expect(result[1].lines[1].content).toEqual(utilsMockData[3].content); + expect(result[2].lines.length).toEqual(2); + expect(result[2].lines[0].content).toEqual(mockJobLog[3].content); + expect(result[2].lines[1].content).toEqual(mockJobLog[4].content); }); }); describe('section duration', () => { it('adds the section information to the header section', () => { - expect(result[1].line.section_duration).toEqual(utilsMockData[4].section_duration); + expect(result[2].line.section_duration).toEqual(mockJobLog[5].section_duration); + expect(result[3].line.section_duration).toEqual(mockJobLog[9].section_duration); }); it('does not add section duration as a line', () => { - expect(result[1].lines.includes(utilsMockData[4])).toEqual(false); + expect(result[2].lines.includes(mockJobLog[5])).toEqual(false); + expect(result[3].lines.includes(mockJobLog[9])).toEqual(false); }); }); }); @@ -316,17 +326,24 @@ describe('Jobs Store Utils', () => { }); }); - describe('getIncrementalLineNumber', () => { - describe('when last line is 0', () => { + describe('getNextLineNumber', () => { + describe('when there is no previous log', () => { + it('returns 1', () => { + expect(getNextLineNumber([])).toEqual(1); + expect(getNextLineNumber(undefined)).toEqual(1); + }); + }); + + describe('when last line is 1', () => { it('returns 1', () => { const log = [ { content: [], - lineNumber: 0, + lineNumber: 1, }, ]; - expect(getIncrementalLineNumber(log)).toEqual(1); + expect(getNextLineNumber(log)).toEqual(2); }); }); @@ -343,7 +360,7 @@ describe('Jobs Store Utils', () => { }, ]; - expect(getIncrementalLineNumber(log)).toEqual(102); + expect(getNextLineNumber(log)).toEqual(102); }); }); @@ -364,7 +381,7 @@ describe('Jobs Store Utils', () => { }, ]; - expect(getIncrementalLineNumber(log)).toEqual(102); + expect(getNextLineNumber(log)).toEqual(102); }); }); @@ -391,7 +408,7 @@ describe('Jobs Store Utils', () => { }, ]; - expect(getIncrementalLineNumber(log)).toEqual(104); + expect(getNextLineNumber(log)).toEqual(104); }); }); }); @@ -410,7 +427,7 @@ describe('Jobs Store Utils', () => { text: 'Downloading', }, ], - lineNumber: 0, + lineNumber: 1, }, { offset: 2, @@ -419,7 +436,7 @@ describe('Jobs Store Utils', () => { text: 'log line', }, ], - lineNumber: 1, + lineNumber: 2, }, ]); }); @@ -438,7 +455,7 @@ describe('Jobs Store Utils', () => { text: 'log line', }, ], - lineNumber: 0, + lineNumber: 1, }, ]); }); @@ -462,7 +479,7 @@ describe('Jobs Store Utils', () => { }, ], section: 'section', - lineNumber: 0, + lineNumber: 1, }, lines: [], }, @@ -488,7 +505,7 @@ describe('Jobs Store Utils', () => { }, ], section: 'section', - lineNumber: 0, + lineNumber: 1, }, lines: [ { @@ -499,7 +516,7 @@ describe('Jobs Store Utils', () => { }, ], section: 'section', - lineNumber: 1, + lineNumber: 2, }, ], }, diff --git a/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js b/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js index cb8f6ed8f9b..bb44d970bd7 100644 --- a/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js +++ b/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js @@ -40,20 +40,20 @@ describe('Job Cell', () => { }; describe('Job Id', () => { - it('displays the job id and links to the job', () => { + it('displays the job id, job name and links to the job', () => { createComponent(); - const expectedJobId = `#${getIdFromGraphQLId(mockJob.id)}`; + const expectedJobId = `#${getIdFromGraphQLId(mockJob.id)}: ${mockJob.name}`; expect(findJobIdLink().text()).toBe(expectedJobId); expect(findJobIdLink().attributes('href')).toBe(mockJob.detailedStatus.detailsPath); expect(findJobIdNoLink().exists()).toBe(false); }); - it('display the job id with no link', () => { + it('display the job id and job name with no link', () => { createComponent(jobAsGuest); - const expectedJobId = `#${getIdFromGraphQLId(jobAsGuest.id)}`; + const expectedJobId = `#${getIdFromGraphQLId(jobAsGuest.id)}: ${jobAsGuest.name}`; expect(findJobIdNoLink().text()).toBe(expectedJobId); expect(findJobIdNoLink().exists()).toBe(true); diff --git a/spec/frontend/ci/jobs_page/components/job_cells/duration_cell_spec.js b/spec/frontend/ci/jobs_page/components/job_cells/status_cell_spec.js index 21f14ba0c98..e66942cc730 100644 --- a/spec/frontend/ci/jobs_page/components/job_cells/duration_cell_spec.js +++ b/spec/frontend/ci/jobs_page/components/job_cells/status_cell_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import DurationCell from '~/ci/jobs_page/components/job_cells/duration_cell.vue'; +import StatusCell from '~/ci/jobs_page/components/job_cells/status_cell.vue'; describe('Duration Cell', () => { let wrapper; @@ -12,7 +12,7 @@ describe('Duration Cell', () => { const createComponent = (props) => { wrapper = extendedWrapper( - shallowMount(DurationCell, { + shallowMount(StatusCell, { propsData: { job: { ...props, diff --git a/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js b/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js index f4893c4077f..0f85c4590ec 100644 --- a/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js +++ b/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js @@ -6,7 +6,7 @@ describe('Jobs table empty state', () => { let wrapper; const pipelineEditorPath = '/root/project/-/ci/editor'; - const emptyStateSvgPath = 'assets/jobs-empty-state.svg'; + const emptyStateSvgPath = 'illustrations/empty-state/empty-pipeline-md.svg'; const findEmptyState = () => wrapper.findComponent(GlEmptyState); diff --git a/spec/frontend/ci/jobs_page/components/jobs_table_spec.js b/spec/frontend/ci/jobs_page/components/jobs_table_spec.js index 3adb95bf371..d4e0ce92bc2 100644 --- a/spec/frontend/ci/jobs_page/components/jobs_table_spec.js +++ b/spec/frontend/ci/jobs_page/components/jobs_table_spec.js @@ -2,6 +2,7 @@ import { GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { DEFAULT_FIELDS_ADMIN } from '~/ci/admin/jobs_table/constants'; import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue'; @@ -47,11 +48,11 @@ describe('Jobs Table', () => { expect(findCiBadgeLink().exists()).toBe(true); }); - it('displays the job stage and name', () => { + it('displays the job stage, id and name', () => { const [firstJob] = mockJobsNodes; - expect(findJobStage().text()).toBe(firstJob.stage.name); - expect(findJobName().text()).toBe(firstJob.name); + expect(findJobStage().text()).toBe(`Stage: ${firstJob.stage.name}`); + expect(findJobName().text()).toBe(`#${getIdFromGraphQLId(firstJob.id)}: ${firstJob.name}`); }); it('displays the coverage for only jobs that have coverage', () => { diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js index 107f0df5c02..de9ee8a16bf 100644 --- a/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js +++ b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js @@ -1,10 +1,11 @@ import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; -import { GlBadge, GlModal, GlToast } from '@gitlab/ui'; +import { GlModal, GlToast } from '@gitlab/ui'; import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue'; import axios from '~/lib/utils/axios_utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import ActionComponent from '~/ci/common/private/job_action_component.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { @@ -27,9 +28,10 @@ describe('pipeline graph job item', () => { const findJobWithoutLink = () => wrapper.findByTestId('job-without-link'); const findJobWithLink = () => wrapper.findByTestId('job-with-link'); const findActionVueComponent = () => wrapper.findComponent(ActionComponent); - const findActionComponent = () => wrapper.findByTestId('ci-action-component'); - const findBadge = () => wrapper.findComponent(GlBadge); + const findActionComponent = () => wrapper.findByTestId('ci-action-button'); + const findBadge = () => wrapper.findByTestId('job-bridge-badge'); const findJobLink = () => wrapper.findByTestId('job-with-link'); + const findJobCiBadge = () => wrapper.findComponent(CiBadgeLink); const findModal = () => wrapper.findComponent(GlModal); const clickOnModalPrimaryBtn = () => findModal().vm.$emit('primary'); @@ -57,6 +59,9 @@ describe('pipeline graph job item', () => { mocks: { ...mocks, }, + stubs: { + CiBadgeLink, + }, }); }; @@ -81,7 +86,8 @@ describe('pipeline graph job item', () => { expect(link.attributes('title')).toBe(`${mockJob.name} - ${mockJob.status.label}`); - expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); + expect(findJobCiBadge().exists()).toBe(true); + expect(findJobCiBadge().find('.ci-status-icon-success').exists()).toBe(true); expect(wrapper.text()).toBe(mockJob.name); }); @@ -99,7 +105,8 @@ describe('pipeline graph job item', () => { }); it('should render status and name', () => { - expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); + expect(findJobCiBadge().exists()).toBe(true); + expect(findJobCiBadge().find('.ci-status-icon-success').exists()).toBe(true); expect(findJobLink().exists()).toBe(false); expect(wrapper.text()).toBe(mockJobWithoutDetails.name); @@ -110,6 +117,15 @@ describe('pipeline graph job item', () => { }); }); + describe('CiBadgeLink', () => { + it('should not render a link', () => { + createWrapper(); + + expect(findJobCiBadge().exists()).toBe(true); + expect(findJobCiBadge().props('useLink')).toBe(false); + }); + }); + describe('action icon', () => { it('should render the action icon', () => { createWrapper(); diff --git a/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js index 5541b0db54a..5fe8581e81b 100644 --- a/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js +++ b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js @@ -37,7 +37,7 @@ describe('Linked pipeline', () => { const findButton = () => wrapper.findComponent(GlButton); const findCancelButton = () => wrapper.findByLabelText('Cancel downstream pipeline'); const findCardTooltip = () => wrapper.findComponent(GlTooltip); - const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title'); + const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title-content'); const findExpandButton = () => wrapper.findByTestId('expand-pipeline-button'); const findLinkedPipeline = () => wrapper.findComponent({ ref: 'linkedPipeline' }); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); diff --git a/spec/frontend/ci/pipeline_details/mock_data.js b/spec/frontend/ci/pipeline_details/mock_data.js index e32d0a0df47..56365622544 100644 --- a/spec/frontend/ci/pipeline_details/mock_data.js +++ b/spec/frontend/ci/pipeline_details/mock_data.js @@ -640,7 +640,7 @@ export const mockPipeline = (projectPath) => { triggered_by: null, triggered: [], }, - pipelineScheduleUrl: 'foo', + pipelineSchedulesPath: 'foo', pipelineKey: 'id', viewType: 'root', }; @@ -865,7 +865,7 @@ export const mockPipelineTag = () => { triggered_by: null, triggered: [], }, - pipelineScheduleUrl: 'foo', + pipelineSchedulesPath: 'foo', pipelineKey: 'id', viewType: 'root', }; @@ -1072,7 +1072,7 @@ export const mockPipelineBranch = () => { triggered_by: null, triggered: [], }, - pipelineScheduleUrl: 'foo', + pipelineSchedulesPath: 'foo', pipelineKey: 'id', viewType: 'root', }; diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js index 1a2ed60a6f4..9bb0618b758 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js @@ -1,4 +1,4 @@ -import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -43,7 +43,7 @@ describe('Pipeline Status', () => { }, projectFullPath: mockProjectFullPath, }, - stubs: { GlLink, GlSprintf }, + stubs: { GlSprintf }, }); }; diff --git a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js index 30a0b868c5f..4b357a9fc7c 100644 --- a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js +++ b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js @@ -2,7 +2,7 @@ import { GlDropdown } from '@gitlab/ui'; import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import LegacyPipelineStage from '~/ci/pipeline_mini_graph/legacy_pipeline_stage.vue'; @@ -52,7 +52,7 @@ describe('Pipelines stage component', () => { }); const findCiActionBtn = () => wrapper.find('.js-ci-action'); - const findCiIcon = () => wrapper.findComponent(CiIcon); + const findCiIcon = () => wrapper.findComponent(CiBadgeLink); const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); const findDropdownMenu = () => @@ -106,17 +106,6 @@ describe('Pipelines stage component', () => { expect(findDropdownToggle().exists()).toBe(true); expect(findCiIcon().exists()).toBe(true); }); - - it('renders a borderless ci-icon', () => { - expect(findCiIcon().exists()).toBe(true); - expect(findCiIcon().props('isBorderless')).toBe(true); - expect(findCiIcon().classes('borderless')).toBe(true); - }); - - it('renders a ci-icon with a custom border class', () => { - expect(findCiIcon().exists()).toBe(true); - expect(findCiIcon().classes('gl-border')).toBe(true); - }); }); describe('when user opens dropdown and stage request is successful', () => { diff --git a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js index 0396029cdaf..3c9d235bfcc 100644 --- a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js +++ b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js @@ -50,19 +50,6 @@ describe('Linked pipeline mini list', () => { expect(findCiIcon().exists()).toBe(true); }); - it('should render a borderless ci-icon', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().props('isBorderless')).toBe(true); - expect(findCiIcon().classes('borderless')).toBe(true); - }); - - it('should render a ci-icon with a custom border class', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().classes('gl-border')).toBe(true); - }); - it('should render the correct ci status icon', () => { expect(findCiIcon().classes('ci-status-icon-running')).toBe(true); }); @@ -124,19 +111,6 @@ describe('Linked pipeline mini list', () => { expect(findLinkedPipelineMiniList().classes('is-downstream')).toBe(true); }); - it('should render a borderless ci-icon', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().props('isBorderless')).toBe(true); - expect(findCiIcon().classes('borderless')).toBe(true); - }); - - it('should render a ci-icon with a custom border class', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().classes('gl-border')).toBe(true); - }); - it('should render the pipeline counter', () => { expect(findLinkedPipelineCounter().exists()).toBe(true); }); diff --git a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js index 1d4ae33c667..2807cc0f2a1 100644 --- a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js @@ -55,12 +55,12 @@ describe('Pipeline New Form', () => { const findForm = () => wrapper.findComponent(GlForm); const findRefsDropdown = () => wrapper.findComponent(RefsDropdown); - const findSubmitButton = () => wrapper.findByTestId('run_pipeline_button'); - const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); + const findSubmitButton = () => wrapper.findByTestId('run-pipeline-button'); + const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row-container'); const findRemoveIcons = () => wrapper.findAllByTestId('remove-ci-variable-row'); const findVariableTypes = () => wrapper.findAllByTestId('pipeline-form-ci-variable-type'); - const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); - const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value'); + const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key-field'); + const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value-field'); const findValueDropdowns = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value-dropdown'); const findValueDropdownItems = (dropdown) => dropdown.findAllComponents(GlDropdownItem); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_empty_state_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_empty_state_spec.js new file mode 100644 index 00000000000..5ad0f915f62 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_empty_state_spec.js @@ -0,0 +1,37 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import PipelineSchedulesEmptyState from '~/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue'; + +describe('Pipeline Schedules Empty State', () => { + let wrapper; + + const mockSchedulePath = 'root/test/-/pipeline_schedules/new"'; + + const createComponent = () => { + wrapper = shallowMount(PipelineSchedulesEmptyState, { + provide: { + newSchedulePath: mockSchedulePath, + }, + stubs: { GlSprintf }, + }); + }; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findLink = () => wrapper.findComponent(GlLink); + + beforeEach(() => { + createComponent(); + }); + + it('shows empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + + it('has link to create new schedule', () => { + expect(findEmptyState().props('primaryButtonLink')).toBe(mockSchedulePath); + }); + + it('has link to help documentation', () => { + expect(findLink().attributes('href')).toBe('/help/ci/pipelines/schedules'); + }); +}); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js index eb76b0bfbb4..d1844d609f2 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon, GlPagination, GlTabs } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { trimText } from 'helpers/text_helper'; @@ -14,6 +14,7 @@ import deletePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/muta import playPipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '~/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; +import { SCHEDULES_PER_PAGE } from '~/ci/pipeline_schedules/constants'; import { mockGetPipelineSchedulesGraphQLResponse, mockPipelineScheduleNodes, @@ -22,6 +23,7 @@ import { playMutationResponse, takeOwnershipMutationResponse, emptyPipelineSchedulesResponse, + mockPipelineSchedulesResponseWithPagination, } from '../mock_data'; Vue.use(VueApollo); @@ -34,6 +36,9 @@ describe('Pipeline schedules app', () => { let wrapper; const successHandler = jest.fn().mockResolvedValue(mockGetPipelineSchedulesGraphQLResponse); + const successHandlerWithPagination = jest + .fn() + .mockResolvedValue(mockPipelineSchedulesResponseWithPagination); const successEmptyHandler = jest.fn().mockResolvedValue(emptyPipelineSchedulesResponse); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); @@ -81,6 +86,11 @@ describe('Pipeline schedules app', () => { const findInactiveTab = () => wrapper.findByTestId('pipeline-schedules-inactive-tab'); const findSchedulesCharacteristics = () => wrapper.findByTestId('pipeline-schedules-characteristics'); + const findPagination = () => wrapper.findComponent(GlPagination); + const setPage = async (page) => { + findPagination().vm.$emit('input', page); + await waitForPromises(); + }; describe('default', () => { beforeEach(() => { @@ -107,6 +117,10 @@ describe('Pipeline schedules app', () => { it('new schedule button links to new schedule path', () => { expect(findNewButton().attributes('href')).toBe('/root/ci-project/-/pipeline_schedules/new'); }); + + it('does not display pagination when no next page exists', () => { + expect(findPagination().exists()).toBe(false); + }); }); describe('fetching pipeline schedules', () => { @@ -333,6 +347,10 @@ describe('Pipeline schedules app', () => { ids: null, projectPath: 'gitlab-org/gitlab', status: null, + first: SCHEDULES_PER_PAGE, + last: null, + nextPageCursor: '', + prevPageCursor: '', }); }); }); @@ -370,4 +388,57 @@ describe('Pipeline schedules app', () => { }); }); }); + + describe('pagination', () => { + const { pageInfo } = mockPipelineSchedulesResponseWithPagination.data.project.pipelineSchedules; + + beforeEach(async () => { + createComponent([[getPipelineSchedulesQuery, successHandlerWithPagination]]); + + await waitForPromises(); + }); + + it('displays pagination', () => { + expect(findPagination().exists()).toBe(true); + expect(findPagination().props()).toMatchObject({ + value: 1, + prevPage: Number(pageInfo.hasPreviousPage), + nextPage: Number(pageInfo.hasNextPage), + }); + expect(successHandlerWithPagination).toHaveBeenCalledWith({ + projectPath: 'gitlab-org/gitlab', + ids: null, + first: SCHEDULES_PER_PAGE, + last: null, + nextPageCursor: '', + prevPageCursor: '', + }); + }); + + it('updates query variables when going to next page', async () => { + await setPage(2); + + expect(successHandlerWithPagination).toHaveBeenCalledWith({ + projectPath: 'gitlab-org/gitlab', + ids: null, + first: SCHEDULES_PER_PAGE, + last: null, + prevPageCursor: '', + nextPageCursor: pageInfo.endCursor, + }); + expect(findPagination().props('value')).toEqual(2); + }); + + it('when switching tabs pagination should reset', async () => { + await setPage(2); + + expect(findPagination().props('value')).toEqual(2); + + await findInactiveTab().trigger('click'); + + await waitForPromises(); + + expect(findPagination().props('value')).toEqual(1); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js index 711b120c61e..1bff296305d 100644 --- a/spec/frontend/ci/pipeline_schedules/mock_data.js +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -48,6 +48,26 @@ export const mockSinglePipelineScheduleNodeNoVars = { }, }; +export const mockPipelineSchedulesResponseWithPagination = { + data: { + currentUser: mockGetPipelineSchedulesGraphQLResponse.data.currentUser, + project: { + id: mockGetPipelineSchedulesGraphQLResponse.data.project.id, + pipelineSchedules: { + count: 3, + nodes: mockGetPipelineSchedulesGraphQLResponse.data.project.pipelineSchedules.nodes, + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjQ0In0', + endCursor: 'eyJpZCI6IjI4In0', + __typename: 'PageInfo', + }, + }, + }, + }, +}; + export const emptyPipelineSchedulesResponse = { data: { currentUser: { @@ -59,6 +79,13 @@ export const emptyPipelineSchedulesResponse = { pipelineSchedules: { count: 0, nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + __typename: 'PageInfo', + }, }, }, }, diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js index b5c9a3030e0..6b0d5b18f7d 100644 --- a/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js +++ b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js @@ -15,6 +15,7 @@ describe('Pipeline label component', () => { const findAutoDevopsTag = () => wrapper.findByTestId('pipeline-url-autodevops'); const findAutoDevopsTagLink = () => wrapper.findByTestId('pipeline-url-autodevops-link'); const findDetachedTag = () => wrapper.findByTestId('pipeline-url-detached'); + const findMergedResultsTag = () => wrapper.findByTestId('pipeline-url-merged-results'); const findFailureTag = () => wrapper.findByTestId('pipeline-url-failure'); const findForkTag = () => wrapper.findByTestId('pipeline-url-fork'); const findTrainTag = () => wrapper.findByTestId('pipeline-url-train'); @@ -25,6 +26,7 @@ describe('Pipeline label component', () => { wrapper = shallowMountExtended(PipelineLabelsComponent, { propsData: { ...defaultProps, ...props }, provide: { + pipelineSchedulesPath: 'group/project/-/schedules', targetProjectFullPath: projectPath, }, }); @@ -41,6 +43,7 @@ describe('Pipeline label component', () => { expect(findScheduledTag().exists()).toBe(false); expect(findForkTag().exists()).toBe(false); expect(findTrainTag().exists()).toBe(false); + expect(findMergedResultsTag().exists()).toBe(false); }); it('should render the stuck tag when flag is provided', () => { @@ -140,9 +143,33 @@ describe('Pipeline label component', () => { expect(findForkTag().text()).toBe('fork'); }); + it('should render the merged results badge when the pipeline is a merged results pipeline', () => { + const mergedResultsPipeline = defaultProps.pipeline; + mergedResultsPipeline.flags.merged_result_pipeline = true; + + createComponent({ + ...mergedResultsPipeline, + }); + + expect(findMergedResultsTag().text()).toBe('merged results'); + }); + + it('should not render the merged results badge when the pipeline is not a merged results pipeline', () => { + const mergedResultsPipeline = defaultProps.pipeline; + mergedResultsPipeline.flags.merged_result_pipeline = false; + + createComponent({ + ...mergedResultsPipeline, + }); + + expect(findMergedResultsTag().exists()).toBe(false); + }); + it('should render the train badge when the pipeline is a merge train pipeline', () => { const mergeTrainPipeline = defaultProps.pipeline; mergeTrainPipeline.flags.merge_train_pipeline = true; + // a merge train pipeline is also a merged results pipeline + mergeTrainPipeline.flags.merged_result_pipeline = true; createComponent({ ...mergeTrainPipeline, @@ -161,4 +188,17 @@ describe('Pipeline label component', () => { expect(findTrainTag().exists()).toBe(false); }); + + it('should not render the merged results badge when the pipeline is a merge train pipeline', () => { + const mergeTrainPipeline = defaultProps.pipeline; + mergeTrainPipeline.flags.merge_train_pipeline = true; + // a merge train pipeline is also a merged results pipeline + mergeTrainPipeline.flags.merged_result_pipeline = true; + + createComponent({ + ...mergeTrainPipeline, + }); + + expect(findMergedResultsTag().exists()).toBe(false); + }); }); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js index d2eab64b317..6205a37e291 100644 --- a/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js +++ b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js @@ -1,10 +1,13 @@ +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PipelinesManualActions from '~/ci/pipelines_page/components/pipelines_manual_actions.vue'; import PipelineMultiActions from '~/ci/pipelines_page/components/pipeline_multi_actions.vue'; import PipelineOperations from '~/ci/pipelines_page/components/pipeline_operations.vue'; -import eventHub from '~/ci/event_hub'; +import PipelineStopModal from '~/ci/pipelines_page/components/pipeline_stop_modal.vue'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; describe('Pipeline operations', () => { + let trackingSpy; let wrapper; const defaultProps = { @@ -36,6 +39,7 @@ describe('Pipeline operations', () => { const findMultiActions = () => wrapper.findComponent(PipelineMultiActions); const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); + const findPipelineStopModal = () => wrapper.findComponent(PipelineStopModal); it('should display pipeline manual actions', () => { createComponent(); @@ -49,28 +53,71 @@ describe('Pipeline operations', () => { expect(findMultiActions().exists()).toBe(true); }); + it('does not show the confirmation modal', () => { + createComponent(); + + expect(findPipelineStopModal().props().showConfirmationModal).toBe(false); + }); + + describe('when cancelling a pipeline', () => { + beforeEach(async () => { + createComponent(); + await findCancelBtn().vm.$emit('click'); + }); + + it('should show a confirmation modal', () => { + expect(findPipelineStopModal().props().showConfirmationModal).toBe(true); + }); + + it('should emit cancel-pipeline event when confirming', async () => { + await findPipelineStopModal().vm.$emit('submit'); + + expect(wrapper.emitted('cancel-pipeline')).toEqual([[defaultProps.pipeline]]); + expect(findPipelineStopModal().props().showConfirmationModal).toBe(false); + }); + + it('should hide the modal when closing', async () => { + await findPipelineStopModal().vm.$emit('close-modal'); + + expect(findPipelineStopModal().props().showConfirmationModal).toBe(false); + }); + }); + describe('events', () => { beforeEach(() => { createComponent(); - - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); it('should emit retryPipeline event', () => { findRetryBtn().vm.$emit('click'); - expect(eventHub.$emit).toHaveBeenCalledWith( - 'retryPipeline', - defaultProps.pipeline.retry_path, - ); + expect(wrapper.emitted('retry-pipeline')).toEqual([[defaultProps.pipeline]]); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks retry pipeline button click', () => { + findRetryBtn().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_retry_button', { + label: TRACKING_CATEGORIES.table, + }); }); - it('should emit openConfirmationModal event', () => { + it('tracks cancel pipeline button click', () => { findCancelBtn().vm.$emit('click'); - expect(eventHub.$emit).toHaveBeenCalledWith('openConfirmationModal', { - pipeline: defaultProps.pipeline, - endpoint: defaultProps.pipeline.cancel_path, + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_cancel_button', { + label: TRACKING_CATEGORIES.table, }); }); }); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js index 4d78a923542..1e276840c07 100644 --- a/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js +++ b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js @@ -1,15 +1,17 @@ import { shallowMount } from '@vue/test-utils'; -import { GlSprintf } from '@gitlab/ui'; +import { GlModal, GlSprintf } from '@gitlab/ui'; import { mockPipelineHeader } from 'jest/ci/pipeline_details/mock_data'; import PipelineStopModal from '~/ci/pipelines_page/components/pipeline_stop_modal.vue'; describe('PipelineStopModal', () => { let wrapper; - const createComponent = () => { + const createComponent = ({ props = {} } = {}) => { wrapper = shallowMount(PipelineStopModal, { propsData: { pipeline: mockPipelineHeader, + showConfirmationModal: false, + ...props, }, stubs: { GlSprintf, @@ -17,11 +19,43 @@ describe('PipelineStopModal', () => { }); }; + const findModal = () => wrapper.findComponent(GlModal); + beforeEach(() => { createComponent(); }); - it('should render "stop pipeline" warning', () => { - expect(wrapper.text()).toMatch(`You’re about to stop pipeline #${mockPipelineHeader.id}.`); + describe('when `showConfirmationModal` is false', () => { + it('passes the visiblity value to the modal', () => { + expect(findModal().props().visible).toBe(false); + }); + }); + + describe('when `showConfirmationModal` is true', () => { + beforeEach(() => { + createComponent({ props: { showConfirmationModal: true } }); + }); + + it('passes the visiblity value to the modal', () => { + expect(findModal().props().visible).toBe(true); + }); + + it('renders "stop pipeline" warning', () => { + expect(wrapper.text()).toMatch(`You're about to stop pipeline #${mockPipelineHeader.id}.`); + }); + }); + + describe('events', () => { + beforeEach(() => { + createComponent({ props: { showConfirmationModal: true } }); + }); + + it('emits the close-modal event when the visiblity changes', async () => { + expect(wrapper.emitted('close-modal')).toBeUndefined(); + + await findModal().vm.$emit('change', false); + + expect(wrapper.emitted('close-modal')).toEqual([[]]); + }); }); }); diff --git a/spec/frontend/ci/pipelines_page/pipelines_spec.js b/spec/frontend/ci/pipelines_page/pipelines_spec.js index 5d1f431e57c..fd95f98e7f8 100644 --- a/spec/frontend/ci/pipelines_page/pipelines_spec.js +++ b/spec/frontend/ci/pipelines_page/pipelines_spec.js @@ -28,7 +28,7 @@ import NavigationControls from '~/ci/pipelines_page/components/nav_controls.vue' import PipelinesComponent from '~/ci/pipelines_page/pipelines.vue'; import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue'; import PipelinesTableComponent from '~/ci/common/pipelines_table.vue'; -import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/ci/constants'; +import { PIPELINE_IID_KEY, RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/ci/constants'; import Store from '~/ci/pipeline_details/stores/pipelines_store'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; @@ -57,28 +57,23 @@ describe('Pipelines', () => { let mockApollo; let mock; let trackingSpy; + let mutationMock; - const paths = { - emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', - errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', - noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + const withPermissionsProps = { ciLintPath: '/ci/lint', resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`, newPipelinePath: `${mockProjectPath}/pipelines/new`, - ciRunnerSettingsPath: `${mockProjectPath}/-/settings/ci_cd#js-runners-settings`, - }; - - const noPermissions = { - emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', - errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', - noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + canCreatePipeline: true, }; const defaultProps = { hasGitlabCi: true, - canCreatePipeline: true, - ...paths, + canCreatePipeline: false, + projectId: mockProjectId, + defaultBranchName: mockDefaultBranchName, + endpoint: mockPipelinesEndpoint, + params: {}, }; const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); @@ -87,10 +82,9 @@ describe('Pipelines', () => { const findNavigationControls = () => wrapper.findComponent(NavigationControls); const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent); const findTablePagination = () => wrapper.findComponent(TablePagination); - const findPipelineKeyCollapsibleBoxVue = () => wrapper.findComponent(GlCollapsibleListbox); + const findPipelineKeyCollapsibleBox = () => wrapper.findComponent(GlCollapsibleListbox); const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); - const findPipelineKeyCollapsibleBox = () => wrapper.findByTestId('pipeline-key-collapsible-box'); const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); @@ -98,25 +92,23 @@ describe('Pipelines', () => { wrapper.find('[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle'); const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]'); - const createComponent = (props = defaultProps) => { - const { mutationMock, ...restProps } = props; + const createComponent = ({ props = {}, withPermissions = true } = {}) => { mockApollo = createMockApollo([[setSortPreferenceMutation, mutationMock]]); + const permissionsProps = withPermissions ? { ...withPermissionsProps } : {}; wrapper = extendedWrapper( mount(PipelinesComponent, { provide: { pipelineEditorPath: '', suggestedCiTemplates: [], - ciRunnerSettingsPath: paths.ciRunnerSettingsPath, + ciRunnerSettingsPath: defaultProps.ciRunnerSettingsPath, anyRunnersAvailable: true, }, propsData: { + ...defaultProps, + ...permissionsProps, + ...props, store: new Store(), - projectId: mockProjectId, - defaultBranchName: mockDefaultBranchName, - endpoint: mockPipelinesEndpoint, - params: {}, - ...restProps, }, apolloProvider: mockApollo, }), @@ -124,12 +116,11 @@ describe('Pipelines', () => { }; beforeEach(() => { - setWindowLocation(TEST_HOST); - }); - - beforeEach(() => { mock = new MockAdapter(axios); + setWindowLocation(TEST_HOST); + mutationMock = jest.fn(); + jest.spyOn(window.history, 'pushState'); jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); @@ -169,7 +160,9 @@ describe('Pipelines', () => { describe('when user has no permissions', () => { beforeEach(async () => { - createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + createComponent({ + withPermissions: false, + }); await waitForPromises(); }); @@ -225,11 +218,13 @@ describe('Pipelines', () => { }); it('renders Run pipeline link', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + expect(findRunPipelineButton().attributes('href')).toBe( + withPermissionsProps.newPipelinePath, + ); }); it('renders CI lint link', () => { - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCiLintButton().attributes('href')).toBe(withPermissionsProps.ciLintPath); }); it('renders Clear runner cache button', () => { @@ -382,7 +377,7 @@ describe('Pipelines', () => { it('should change the text to Show Pipeline IID', async () => { expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); - findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + findPipelineKeyCollapsibleBox().vm.$emit('select', PIPELINE_IID_KEY); await waitForPromises(); @@ -390,21 +385,21 @@ describe('Pipelines', () => { }); it('calls mutation to save idType preference', () => { - const mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse); - createComponent({ ...defaultProps, mutationMock }); + mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse); + createComponent(); - findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + findPipelineKeyCollapsibleBox().vm.$emit('select', PIPELINE_IID_KEY); - expect(mutationMock).toHaveBeenCalledWith({ input: { visibilityPipelineIdType: 'IID' } }); + expect(mutationMock).toHaveBeenCalledWith({ + input: { visibilityPipelineIdType: PIPELINE_IID_KEY.toUpperCase() }, + }); }); it('captures error when mutation response has errors', async () => { - const mutationMock = jest - .fn() - .mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors); - createComponent({ ...defaultProps, mutationMock }); + mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors); + createComponent(); - findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + findPipelineKeyCollapsibleBox().vm.$emit('select', PIPELINE_IID_KEY); await waitForPromises(); expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!')); @@ -610,11 +605,13 @@ describe('Pipelines', () => { }); it('renders Run pipeline link', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + expect(findRunPipelineButton().attributes('href')).toBe( + withPermissionsProps.newPipelinePath, + ); }); it('renders CI lint link', () => { - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCiLintButton().attributes('href')).toBe(withPermissionsProps.ciLintPath); }); it('renders Clear runner cache button', () => { @@ -651,7 +648,7 @@ describe('Pipelines', () => { describe('when CI is not enabled and user has permissions', () => { beforeEach(async () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + createComponent({ props: { hasGitlabCi: false } }); await waitForPromises(); }); @@ -678,7 +675,7 @@ describe('Pipelines', () => { describe('when CI is not enabled and user has no permissions', () => { beforeEach(async () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); + createComponent({ props: { hasGitlabCi: false }, withPermissions: false }); await waitForPromises(); }); @@ -700,7 +697,7 @@ describe('Pipelines', () => { describe('when CI is enabled and user has no permissions', () => { beforeEach(() => { - createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + createComponent({ props: { hasGitlabCi: true }, withPermissions: false }); return waitForPromises(); }); @@ -798,8 +795,10 @@ describe('Pipelines', () => { describe('when user has no permissions', () => { beforeEach(async () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions }); - + createComponent({ + props: { hasGitlabCi: false }, + withPermissions: false, + }); await waitForPromises(); }); @@ -834,9 +833,11 @@ describe('Pipelines', () => { }); it('renders buttons', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + expect(findRunPipelineButton().attributes('href')).toBe( + withPermissionsProps.newPipelinePath, + ); - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCiLintButton().attributes('href')).toBe(withPermissionsProps.ciLintPath); expect(findCleanCacheButton().text()).toBe('Clear runner caches'); }); diff --git a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js index c9349c64bfb..4a75c353487 100644 --- a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -103,11 +103,6 @@ describe('AdminRunnerShowApp', () => { it('shows basic runner details', () => { const expected = `Description My Runner Last contact Never contacted - Version 1.0.0 - IP Address None - Executor None - Architecture None - Platform darwin Configuration Runs untagged jobs Maximum job timeout None Token expiry diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js index 1bbcb991619..bc28147db27 100644 --- a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js @@ -156,9 +156,7 @@ describe('AdminRunnersApp', () => { await createComponent({ mountFn: mountExtended }); }); - // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/414975 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('fetches counts', () => { + it('fetches counts', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); }); diff --git a/spec/frontend/ci/runner/components/runner_details_spec.js b/spec/frontend/ci/runner/components/runner_details_spec.js index cc91340655b..9d5f89a2642 100644 --- a/spec/frontend/ci/runner/components/runner_details_spec.js +++ b/spec/frontend/ci/runner/components/runner_details_spec.js @@ -49,13 +49,6 @@ describe('RunnerDetails', () => { ${'Description'} | ${{ description: null }} | ${'None'} ${'Last contact'} | ${{ contactedAt: mockOneHourAgo }} | ${'1 hour ago'} ${'Last contact'} | ${{ contactedAt: null }} | ${'Never contacted'} - ${'Version'} | ${{ version: '12.3' }} | ${'12.3'} - ${'Version'} | ${{ version: null }} | ${'None'} - ${'Executor'} | ${{ executorName: 'shell' }} | ${'shell'} - ${'Architecture'} | ${{ architectureName: 'amd64' }} | ${'amd64'} - ${'Platform'} | ${{ platformName: 'darwin' }} | ${'darwin'} - ${'IP Address'} | ${{ ipAddress: '127.0.0.1' }} | ${'127.0.0.1'} - ${'IP Address'} | ${{ ipAddress: null }} | ${'None'} ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: true }} | ${'Protected, Runs untagged jobs'} ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: false }} | ${'Protected'} ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED, runUntagged: true }} | ${'Runs untagged jobs'} diff --git a/spec/frontend/ci/runner/components/runner_details_tabs_spec.js b/spec/frontend/ci/runner/components/runner_details_tabs_spec.js index 689d0575726..516209794ad 100644 --- a/spec/frontend/ci/runner/components/runner_details_tabs_spec.js +++ b/spec/frontend/ci/runner/components/runner_details_tabs_spec.js @@ -54,7 +54,7 @@ describe('RunnerDetailsTabs', () => { ...options, }); - routerPush = jest.spyOn(wrapper.vm.$router, 'push').mockImplementation(() => {}); + routerPush = jest.spyOn(wrapper.vm.$router, 'push'); return waitForPromises(); }; @@ -67,9 +67,8 @@ describe('RunnerDetailsTabs', () => { }); it('shows runner jobs', async () => { - setWindowLocation(`#${JOBS_ROUTE_PATH}`); - - await createComponent({ mountFn: mountExtended }); + createComponent({ mountFn: mountExtended }); + await wrapper.vm.$router.push({ path: JOBS_ROUTE_PATH }); expect(findRunnerDetails().exists()).toBe(false); expect(findRunnerJobs().props('runner')).toBe(mockRunner); @@ -101,10 +100,9 @@ describe('RunnerDetailsTabs', () => { } }); - it.each(['#/', '#/unknown-tab'])('shows details when location hash is `%s`', async (hash) => { - setWindowLocation(hash); - - await createComponent({ mountFn: mountExtended }); + it.each(['#/', '#/unknown-tab'])('shows details when location hash is `%s`', async (path) => { + createComponent({ mountFn: mountExtended }); + await wrapper.vm.$router.push({ path }); expect(findTabs().props('value')).toBe(0); expect(findRunnerDetails().exists()).toBe(true); diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js index 9da640afeb7..7c00aa48d31 100644 --- a/spec/frontend/ci/runner/components/runner_list_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_spec.js @@ -1,14 +1,11 @@ import { GlTableLite, GlSkeletonLoader } from '@gitlab/ui'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import { - extendedWrapper, - shallowMountExtended, - mountExtended, -} from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createLocalState } from '~/ci/runner/graphql/list/local_state'; +import { stubComponent } from 'helpers/stub_component'; import RunnerList from '~/ci/runner/components/runner_list.vue'; import RunnerBulkDelete from '~/ci/runner/components/runner_bulk_delete.vue'; @@ -29,14 +26,11 @@ describe('RunnerList', () => { const findHeaders = () => wrapper.findAll('th'); const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]'); const findCell = ({ row = 0, fieldKey }) => - extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); + findRows().at(row).find(`[data-testid="td-${fieldKey}"]`); const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete); const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox); - const createComponent = ( - { props = {}, provide = {}, ...options } = {}, - mountFn = shallowMountExtended, - ) => { + const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => { ({ cacheConfig, localMutations } = createLocalState()); wrapper = mountFn(RunnerList, { @@ -49,7 +43,6 @@ describe('RunnerList', () => { localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, - ...provide, }, ...options, }); @@ -81,7 +74,11 @@ describe('RunnerList', () => { }); it('Sets runner id as a row key', () => { - createComponent(); + createComponent({ + stubs: { + GlTableLite: stubComponent(GlTableLite), + }, + }); expect(findTable().attributes('primary-key')).toBe('id'); }); @@ -220,7 +217,12 @@ describe('RunnerList', () => { describe('When data is loading', () => { it('shows a busy state', () => { - createComponent({ props: { runners: [], loading: true } }); + createComponent({ + props: { runners: [], loading: true }, + stubs: { + GlTableLite: stubComponent(GlTableLite), + }, + }); expect(findTable().classes('gl-opacity-6')).toBe(true); }); diff --git a/spec/frontend/ci/runner/components/runner_type_icon_spec.js b/spec/frontend/ci/runner/components/runner_type_icon_spec.js new file mode 100644 index 00000000000..01f3de10aa6 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_type_icon_spec.js @@ -0,0 +1,67 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTypeIcon from '~/ci/runner/components/runner_type_icon.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { assertProps } from 'helpers/assert_props'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_INSTANCE_TYPE, + I18N_GROUP_TYPE, + I18N_PROJECT_TYPE, +} from '~/ci/runner/constants'; + +describe('RunnerTypeIcon', () => { + let wrapper; + + const findIcon = () => wrapper.findComponent(GlIcon); + const getTooltip = () => getBinding(findIcon().element, 'gl-tooltip'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTypeIcon, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + describe.each` + type | tooltipText + ${INSTANCE_TYPE} | ${I18N_INSTANCE_TYPE} + ${GROUP_TYPE} | ${I18N_GROUP_TYPE} + ${PROJECT_TYPE} | ${I18N_PROJECT_TYPE} + `('displays $type runner', ({ type, tooltipText }) => { + beforeEach(() => { + createComponent({ props: { type } }); + }); + + it(`with no text`, () => { + expect(findIcon().text()).toBe(''); + }); + + it(`with aria-label`, () => { + expect(findIcon().props('ariaLabel')).toBeDefined(); + }); + + it('with a tooltip', () => { + expect(getTooltip().value).toBeDefined(); + expect(getTooltip().value).toContain(tooltipText); + }); + }); + + it('validation fails for an incorrect type', () => { + expect(() => { + assertProps(RunnerTypeIcon, { type: 'AN_UNKNOWN_VALUE' }); + }).toThrow(); + }); + + it('does not render content when type is missing', () => { + createComponent({ props: { type: undefined } }); + + expect(findIcon().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js index 7438c47e32c..8258bd1d507 100644 --- a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js @@ -108,11 +108,6 @@ describe('GroupRunnerShowApp', () => { it('shows basic runner details', () => { const expected = `Description My Runner Last contact Never contacted - Version 1.0.0 - IP Address None - Executor None - Architecture None - Platform darwin Configuration Runs untagged jobs Maximum job timeout None Token expiry diff --git a/spec/frontend/ci/runner/sentry_utils_spec.js b/spec/frontend/ci/runner/sentry_utils_spec.js index 2f17cc43ac5..59d386a5899 100644 --- a/spec/frontend/ci/runner/sentry_utils_spec.js +++ b/spec/frontend/ci/runner/sentry_utils_spec.js @@ -4,24 +4,12 @@ import { captureException } from '~/ci/runner/sentry_utils'; jest.mock('@sentry/browser'); describe('~/ci/runner/sentry_utils', () => { - let mockSetTag; - - beforeEach(() => { - mockSetTag = jest.fn(); - - Sentry.withScope.mockImplementation((fn) => { - const scope = { setTag: mockSetTag }; - fn(scope); - }); - }); - describe('captureException', () => { const mockError = new Error('Something went wrong!'); it('error is reported to sentry', () => { captureException({ error: mockError }); - expect(Sentry.withScope).toHaveBeenCalled(); expect(Sentry.captureException).toHaveBeenCalledWith(mockError); }); @@ -30,10 +18,11 @@ describe('~/ci/runner/sentry_utils', () => { captureException({ error: mockError, component: mockComponentName }); - expect(Sentry.withScope).toHaveBeenCalled(); - expect(Sentry.captureException).toHaveBeenCalledWith(mockError); - - expect(mockSetTag).toHaveBeenCalledWith('vue_component', mockComponentName); + expect(Sentry.captureException).toHaveBeenCalledWith(mockError, { + tags: { + vue_component: mockComponentName, + }, + }); }); }); }); |