diff options
Diffstat (limited to 'spec/frontend/ci')
141 files changed, 5525 insertions, 1446 deletions
diff --git a/spec/frontend/ci/artifacts/components/app_spec.js b/spec/frontend/ci/artifacts/components/app_spec.js new file mode 100644 index 00000000000..c6874428e2a --- /dev/null +++ b/spec/frontend/ci/artifacts/components/app_spec.js @@ -0,0 +1,118 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import ArtifactsApp from '~/ci/artifacts/components/app.vue'; +import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue'; +import getBuildArtifactsSizeQuery from '~/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { PAGE_TITLE, TOTAL_ARTIFACTS_SIZE, SIZE_UNKNOWN } from '~/ci/artifacts/constants'; + +const TEST_BUILD_ARTIFACTS_SIZE = 1024; +const TEST_PROJECT_PATH = 'project/path'; +const TEST_PROJECT_ID = 'gid://gitlab/Project/22'; + +const createBuildArtifactsSizeResponse = ({ + buildArtifactsSize = TEST_BUILD_ARTIFACTS_SIZE, + nullStatistics = false, +}) => ({ + data: { + project: { + __typename: 'Project', + id: TEST_PROJECT_ID, + statistics: nullStatistics + ? null + : { + __typename: 'ProjectStatistics', + buildArtifactsSize, + }, + }, + }, +}); + +Vue.use(VueApollo); + +describe('ArtifactsApp component', () => { + let wrapper; + let apolloProvider; + let getBuildArtifactsSizeSpy; + + const findTitle = () => wrapper.findByTestId('artifacts-page-title'); + const findBuildArtifactsSize = () => wrapper.findByTestId('build-artifacts-size'); + const findJobArtifactsTable = () => wrapper.findComponent(JobArtifactsTable); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const createComponent = () => { + wrapper = shallowMountExtended(ArtifactsApp, { + provide: { projectPath: 'project/path' }, + apolloProvider, + }); + }; + + beforeEach(() => { + getBuildArtifactsSizeSpy = jest.fn(); + + apolloProvider = createMockApollo([[getBuildArtifactsSizeQuery, getBuildArtifactsSizeSpy]]); + }); + + describe('when loading', () => { + beforeEach(() => { + // Promise that never resolves so it's always loading + getBuildArtifactsSizeSpy.mockReturnValue(new Promise(() => {})); + + createComponent(); + }); + + it('shows the page title', () => { + expect(findTitle().text()).toBe(PAGE_TITLE); + }); + + it('shows a skeleton while loading the artifacts size', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows the job artifacts table', () => { + expect(findJobArtifactsTable().exists()).toBe(true); + }); + + it('does not show message', () => { + expect(findBuildArtifactsSize().text()).toBe(''); + }); + + it('calls apollo query', () => { + expect(getBuildArtifactsSizeSpy).toHaveBeenCalledWith({ projectPath: TEST_PROJECT_PATH }); + }); + }); + + describe.each` + buildArtifactsSize | nullStatistics | expectedText + ${TEST_BUILD_ARTIFACTS_SIZE} | ${false} | ${numberToHumanSize(TEST_BUILD_ARTIFACTS_SIZE)} + ${null} | ${false} | ${SIZE_UNKNOWN} + ${null} | ${true} | ${SIZE_UNKNOWN} + `( + 'when buildArtifactsSize is $buildArtifactsSize', + ({ buildArtifactsSize, nullStatistics, expectedText }) => { + beforeEach(async () => { + getBuildArtifactsSizeSpy.mockResolvedValue( + createBuildArtifactsSizeResponse({ buildArtifactsSize, nullStatistics }), + ); + + createComponent(); + + await waitForPromises(); + }); + + it('hides loader', () => { + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('shows the size', () => { + expect(findBuildArtifactsSize().text()).toMatchInterpolatedText( + `${TOTAL_ARTIFACTS_SIZE} ${expectedText}`, + ); + }); + }, + ); +}); diff --git a/spec/frontend/ci/artifacts/components/artifact_row_spec.js b/spec/frontend/ci/artifacts/components/artifact_row_spec.js new file mode 100644 index 00000000000..96ddedc3a9d --- /dev/null +++ b/spec/frontend/ci/artifacts/components/artifact_row_spec.js @@ -0,0 +1,127 @@ +import { GlBadge, GlFriendlyWrap, GlFormCheckbox } from '@gitlab/ui'; +import mockGetJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ArtifactRow from '~/ci/artifacts/components/artifact_row.vue'; +import { BULK_DELETE_FEATURE_FLAG, I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants'; + +describe('ArtifactRow component', () => { + let wrapper; + + const artifact = mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[0]; + + const findName = () => wrapper.findByTestId('job-artifact-row-name'); + const findBadge = () => wrapper.findComponent(GlBadge); + const findSize = () => wrapper.findByTestId('job-artifact-row-size'); + const findDownloadButton = () => wrapper.findByTestId('job-artifact-row-download-button'); + const findDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button'); + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + + const createComponent = ({ canDestroyArtifacts = true, glFeatures = {}, props = {} } = {}) => { + wrapper = shallowMountExtended(ArtifactRow, { + propsData: { + artifact, + isSelected: false, + isLoading: false, + isLastRow: false, + isSelectedArtifactsLimitReached: false, + ...props, + }, + provide: { canDestroyArtifacts, glFeatures }, + stubs: { GlBadge, GlFriendlyWrap }, + }); + }; + + describe('artifact details', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('displays the artifact name and type', () => { + expect(findName().text()).toContain(artifact.name); + expect(findBadge().text()).toBe(artifact.fileType.toLowerCase()); + }); + + it('displays the artifact size', () => { + expect(findSize().text()).toBe(numberToHumanSize(artifact.size)); + }); + + it('displays the download button as a link to the download path', () => { + expect(findDownloadButton().attributes('href')).toBe(artifact.downloadPath); + }); + }); + + describe('delete button', () => { + it('does not show when user does not have permission', () => { + createComponent({ canDestroyArtifacts: false }); + + expect(findDeleteButton().exists()).toBe(false); + }); + + it('shows when user has permission', () => { + createComponent(); + + expect(findDeleteButton().exists()).toBe(true); + }); + + it('emits the delete event when clicked', async () => { + createComponent(); + + expect(wrapper.emitted('delete')).toBeUndefined(); + + findDeleteButton().vm.$emit('click'); + await waitForPromises(); + + expect(wrapper.emitted('delete')).toBeDefined(); + }); + }); + + describe('bulk delete checkbox', () => { + describe('with permission and feature flag enabled', () => { + it('emits selectArtifact when toggled', () => { + createComponent({ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true } }); + + findCheckbox().vm.$emit('input', true); + + expect(wrapper.emitted('selectArtifact')).toStrictEqual([[artifact, true]]); + }); + + describe('when the selected artifacts limit is reached', () => { + it('remains enabled if the artifact was selected', () => { + createComponent({ + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + props: { isSelected: true, isSelectedArtifactsLimitReached: true }, + }); + + expect(findCheckbox().attributes('disabled')).toBeUndefined(); + expect(findCheckbox().attributes('title')).toBe(''); + }); + + it('is disabled if the artifact was not selected', () => { + createComponent({ + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + props: { isSelected: false, isSelectedArtifactsLimitReached: true }, + }); + + expect(findCheckbox().attributes('disabled')).toBeDefined(); + expect(findCheckbox().attributes('title')).toBe(I18N_BULK_DELETE_MAX_SELECTED); + }); + }); + }); + + it('is not shown without permission', () => { + createComponent({ canDestroyArtifacts: false }); + + expect(findCheckbox().exists()).toBe(false); + }); + + it('is not shown with feature flag disabled', () => { + createComponent(); + + expect(findCheckbox().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/artifacts/components/artifacts_bulk_delete_spec.js b/spec/frontend/ci/artifacts/components/artifacts_bulk_delete_spec.js new file mode 100644 index 00000000000..549f6e1e375 --- /dev/null +++ b/spec/frontend/ci/artifacts/components/artifacts_bulk_delete_spec.js @@ -0,0 +1,58 @@ +import { GlSprintf, GlAlert } from '@gitlab/ui'; +import mockGetJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ArtifactsBulkDelete from '~/ci/artifacts/components/artifacts_bulk_delete.vue'; +import { I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants'; + +describe('ArtifactsBulkDelete component', () => { + let wrapper; + + const selectedArtifacts = [ + mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[0].id, + mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[1].id, + ]; + + const findText = () => wrapper.findComponent(GlSprintf).text(); + const findDeleteButton = () => wrapper.findByTestId('bulk-delete-delete-button'); + const findClearButton = () => wrapper.findByTestId('bulk-delete-clear-button'); + const findAlertText = () => wrapper.findComponent(GlAlert).text(); + + const createComponent = (props) => { + wrapper = shallowMountExtended(ArtifactsBulkDelete, { + propsData: { + selectedArtifacts, + isSelectedArtifactsLimitReached: false, + ...props, + }, + stubs: { GlSprintf }, + }); + }; + + describe('selected artifacts box', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays selected artifacts count', () => { + expect(findText()).toContain(String(selectedArtifacts.length)); + }); + + it('emits showBulkDeleteModal event when the delete button is clicked', () => { + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('showBulkDeleteModal')).toBeDefined(); + }); + + it('emits clearSelectedArtifacts event when the clear button is clicked', () => { + findClearButton().vm.$emit('click'); + + expect(wrapper.emitted('clearSelectedArtifacts')).toBeDefined(); + }); + }); + + it('shows an alert when the selected artifacts limit is reached', () => { + createComponent({ isSelectedArtifactsLimitReached: true }); + + expect(findAlertText()).toBe(I18N_BULK_DELETE_MAX_SELECTED); + }); +}); diff --git a/spec/frontend/ci/artifacts/components/artifacts_table_row_details_spec.js b/spec/frontend/ci/artifacts/components/artifacts_table_row_details_spec.js new file mode 100644 index 00000000000..479ecf6b183 --- /dev/null +++ b/spec/frontend/ci/artifacts/components/artifacts_table_row_details_spec.js @@ -0,0 +1,138 @@ +import { GlModal } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; +import waitForPromises from 'helpers/wait_for_promises'; +import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue'; +import ArtifactRow from '~/ci/artifacts/components/artifact_row.vue'; +import ArtifactDeleteModal from '~/ci/artifacts/components/artifact_delete_modal.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import destroyArtifactMutation from '~/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql'; +import { I18N_DESTROY_ERROR, I18N_MODAL_TITLE } from '~/ci/artifacts/constants'; +import { createAlert } from '~/alert'; + +jest.mock('~/alert'); + +const { artifacts } = getJobArtifactsResponse.data.project.jobs.nodes[0]; +const refetchArtifacts = jest.fn(); + +Vue.use(VueApollo); + +describe('ArtifactsTableRowDetails component', () => { + let wrapper; + let requestHandlers; + + const findModal = () => wrapper.findComponent(GlModal); + + const createComponent = ({ + handlers = { + destroyArtifactMutation: jest.fn(), + }, + selectedArtifacts = [], + } = {}) => { + requestHandlers = handlers; + wrapper = mountExtended(ArtifactsTableRowDetails, { + apolloProvider: createMockApollo([ + [destroyArtifactMutation, requestHandlers.destroyArtifactMutation], + ]), + propsData: { + artifacts, + selectedArtifacts, + refetchArtifacts, + queryVariables: {}, + isSelectedArtifactsLimitReached: false, + }, + provide: { canDestroyArtifacts: true }, + data() { + return { deletingArtifactId: null }; + }, + }); + }; + + describe('passes correct props', () => { + beforeEach(() => { + createComponent(); + }); + + it('to the artifact rows', () => { + [0, 1, 2].forEach((index) => { + expect(wrapper.findAllComponents(ArtifactRow).at(index).props()).toMatchObject({ + artifact: artifacts.nodes[index], + }); + }); + }); + }); + + describe('when the artifact row emits the delete event', () => { + it('shows the artifact delete modal', async () => { + createComponent(); + await waitForPromises(); + + expect(findModal().props('visible')).toBe(false); + + await wrapper.findComponent(ArtifactRow).vm.$emit('delete'); + + expect(findModal().props('visible')).toBe(true); + expect(findModal().props('title')).toBe(I18N_MODAL_TITLE(artifacts.nodes[0].name)); + }); + }); + + describe('when the artifact delete modal emits its primary event', () => { + it('triggers the destroyArtifact GraphQL mutation', async () => { + createComponent(); + await waitForPromises(); + + wrapper.findComponent(ArtifactRow).vm.$emit('delete'); + wrapper.findComponent(ArtifactDeleteModal).vm.$emit('primary'); + + expect(requestHandlers.destroyArtifactMutation).toHaveBeenCalledWith({ + id: artifacts.nodes[0].id, + }); + }); + + it('displays an alert message and refetches artifacts when the mutation fails', async () => { + createComponent({ + destroyArtifactMutation: jest.fn().mockRejectedValue(new Error('Error!')), + }); + await waitForPromises(); + + expect(wrapper.emitted('refetch')).toBeUndefined(); + + wrapper.findComponent(ArtifactRow).vm.$emit('delete'); + wrapper.findComponent(ArtifactDeleteModal).vm.$emit('primary'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message: I18N_DESTROY_ERROR }); + expect(wrapper.emitted('refetch')).toBeDefined(); + }); + }); + + describe('when the artifact delete modal is cancelled', () => { + it('does not trigger the destroyArtifact GraphQL mutation', async () => { + createComponent(); + await waitForPromises(); + + wrapper.findComponent(ArtifactRow).vm.$emit('delete'); + wrapper.findComponent(ArtifactDeleteModal).vm.$emit('cancel'); + + expect(requestHandlers.destroyArtifactMutation).not.toHaveBeenCalled(); + }); + }); + + describe('bulk delete selection', () => { + it('is not selected for unselected artifact', async () => { + createComponent(); + await waitForPromises(); + + expect(wrapper.findAllComponents(ArtifactRow).at(0).props('isSelected')).toBe(false); + }); + + it('is selected for selected artifacts', async () => { + createComponent({ selectedArtifacts: [artifacts.nodes[0].id] }); + await waitForPromises(); + + expect(wrapper.findAllComponents(ArtifactRow).at(0).props('isSelected')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/artifacts/components/feedback_banner_spec.js b/spec/frontend/ci/artifacts/components/feedback_banner_spec.js new file mode 100644 index 00000000000..53e0fdac6f6 --- /dev/null +++ b/spec/frontend/ci/artifacts/components/feedback_banner_spec.js @@ -0,0 +1,59 @@ +import { GlBanner } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import FeedbackBanner from '~/ci/artifacts/components/feedback_banner.vue'; +import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; +import { + I18N_FEEDBACK_BANNER_TITLE, + I18N_FEEDBACK_BANNER_BUTTON, + FEEDBACK_URL, +} from '~/ci/artifacts/constants'; + +const mockBannerImagePath = 'banner/image/path'; + +describe('Artifacts management feedback banner', () => { + let wrapper; + let userCalloutDismissSpy; + + const findBanner = () => wrapper.findComponent(GlBanner); + + const createComponent = ({ shouldShowCallout = true } = {}) => { + userCalloutDismissSpy = jest.fn(); + + wrapper = shallowMount(FeedbackBanner, { + provide: { + artifactsManagementFeedbackImagePath: mockBannerImagePath, + }, + stubs: { + UserCalloutDismisser: makeMockUserCalloutDismisser({ + dismiss: userCalloutDismissSpy, + shouldShowCallout, + }), + }, + }); + }; + + it('is displayed with the correct props', () => { + createComponent(); + + expect(findBanner().props()).toMatchObject({ + title: I18N_FEEDBACK_BANNER_TITLE, + buttonText: I18N_FEEDBACK_BANNER_BUTTON, + buttonLink: FEEDBACK_URL, + svgPath: mockBannerImagePath, + }); + }); + + it('dismisses the callout when closed', () => { + createComponent(); + + findBanner().vm.$emit('close'); + + expect(userCalloutDismissSpy).toHaveBeenCalled(); + }); + + it('is not displayed once it has been dismissed', () => { + createComponent({ shouldShowCallout: false }); + + expect(findBanner().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 new file mode 100644 index 00000000000..514644a92f2 --- /dev/null +++ b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js @@ -0,0 +1,684 @@ +import { + GlLoadingIcon, + GlTable, + GlLink, + GlBadge, + 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 waitForPromises from 'helpers/wait_for_promises'; +import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue'; +import FeedbackBanner from '~/ci/artifacts/components/feedback_banner.vue'; +import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue'; +import ArtifactDeleteModal from '~/ci/artifacts/components/artifact_delete_modal.vue'; +import ArtifactsBulkDelete from '~/ci/artifacts/components/artifacts_bulk_delete.vue'; +import BulkDeleteModal from '~/ci/artifacts/components/bulk_delete_modal.vue'; +import JobCheckbox from '~/ci/artifacts/components/job_checkbox.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import getJobArtifactsQuery from '~/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql'; +import bulkDestroyArtifactsMutation from '~/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; +import { + ARCHIVE_FILE_TYPE, + JOBS_PER_PAGE, + I18N_FETCH_ERROR, + INITIAL_CURRENT_PAGE, + BULK_DELETE_FEATURE_FLAG, + I18N_BULK_DELETE_ERROR, + SELECTED_ARTIFACTS_MAX_COUNT, +} from '~/ci/artifacts/constants'; +import { totalArtifactsSizeForJob } from '~/ci/artifacts/utils'; +import { createAlert } from '~/alert'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); + +describe('JobArtifactsTable component', () => { + let wrapper; + let requestHandlers; + + const mockToastShow = jest.fn(); + + const findBanner = () => wrapper.findComponent(FeedbackBanner); + + const findLoadingState = () => wrapper.findComponent(GlLoadingIcon); + const findTable = () => wrapper.findComponent(GlTable); + const findDetailsRows = () => wrapper.findAllComponents(ArtifactsTableRowDetails); + const findDetailsInRow = (i) => + findTable().findAll('tbody tr').at(i).findComponent(ArtifactsTableRowDetails); + + const findCount = () => wrapper.findByTestId('job-artifacts-count'); + const findCountAt = (i) => wrapper.findAllByTestId('job-artifacts-count').at(i); + + const findDeleteModal = () => wrapper.findComponent(ArtifactDeleteModal); + const findBulkDeleteModal = () => wrapper.findComponent(BulkDeleteModal); + + const findStatuses = () => wrapper.findAllByTestId('job-artifacts-job-status'); + const findSuccessfulJobStatus = () => findStatuses().at(0); + const findFailedJobStatus = () => findStatuses().at(1); + + 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 findSize = () => wrapper.findByTestId('job-artifacts-size'); + const findCreated = () => wrapper.findByTestId('job-artifacts-created'); + + const findDownloadButton = () => wrapper.findByTestId('job-artifacts-download-button'); + const findBrowseButton = () => wrapper.findByTestId('job-artifacts-browse-button'); + const findDeleteButton = () => wrapper.findByTestId('job-artifacts-delete-button'); + const findArtifactDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button'); + + // first checkbox is a "select all", this finder should get the first job checkbox + const findJobCheckbox = (i = 1) => wrapper.findAllComponents(GlFormCheckbox).at(i); + const findAnyCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findBulkDelete = () => wrapper.findComponent(ArtifactsBulkDelete); + const findBulkDeleteContainer = () => wrapper.findByTestId('bulk-delete-container'); + + const findPagination = () => wrapper.findComponent(GlPagination); + const setPage = async (page) => { + findPagination().vm.$emit('input', page); + await waitForPromises(); + }; + + const projectId = 'some/project/id'; + + let enoughJobsToPaginate = [...getJobArtifactsResponse.data.project.jobs.nodes]; + while (enoughJobsToPaginate.length <= JOBS_PER_PAGE) { + enoughJobsToPaginate = [ + ...enoughJobsToPaginate, + ...getJobArtifactsResponse.data.project.jobs.nodes, + ]; + } + const getJobArtifactsResponseThatPaginates = { + data: { + project: { + jobs: { + nodes: enoughJobsToPaginate, + pageInfo: { ...getJobArtifactsResponse.data.project.jobs.pageInfo, hasNextPage: true }, + }, + }, + }, + }; + + const job = getJobArtifactsResponse.data.project.jobs.nodes[0]; + const archiveArtifact = job.artifacts.nodes.find( + (artifact) => artifact.fileType === ARCHIVE_FILE_TYPE, + ); + const job2 = getJobArtifactsResponse.data.project.jobs.nodes[1]; + + const destroyedCount = job.artifacts.nodes.length; + const destroyedIds = job.artifacts.nodes.map((node) => node.id); + const bulkDestroyMutationHandler = jest.fn().mockResolvedValue({ + data: { + bulkDestroyJobArtifacts: { errors: [], destroyedCount, destroyedIds }, + }, + }); + + const maxSelectedArtifacts = new Array(SELECTED_ARTIFACTS_MAX_COUNT).fill({}); + + const createComponent = ({ + handlers = { + getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse), + bulkDestroyArtifactsMutation: bulkDestroyMutationHandler, + }, + data = {}, + canDestroyArtifacts = true, + glFeatures = {}, + } = {}) => { + requestHandlers = handlers; + wrapper = mountExtended(JobArtifactsTable, { + apolloProvider: createMockApollo([ + [getJobArtifactsQuery, requestHandlers.getJobArtifactsQuery], + [bulkDestroyArtifactsMutation, requestHandlers.bulkDestroyArtifactsMutation], + ]), + provide: { + projectPath: 'project/path', + projectId, + canDestroyArtifacts, + artifactsManagementFeedbackImagePath: 'banner/image/path', + glFeatures, + }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, + data() { + return data; + }, + }); + }; + + it('renders feedback banner', () => { + createComponent(); + + expect(findBanner().exists()).toBe(true); + }); + + it('when loading, shows a loading state', () => { + createComponent(); + + expect(findLoadingState().exists()).toBe(true); + }); + + it('on error, shows an alert', async () => { + createComponent({ + handlers: { + getJobArtifactsQuery: jest.fn().mockRejectedValue(new Error('Error!')), + }, + }); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message: I18N_FETCH_ERROR }); + }); + + it('with data, renders the table', async () => { + createComponent(); + + await waitForPromises(); + + expect(findTable().exists()).toBe(true); + }); + + describe('job details', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('shows the artifact count', () => { + expect(findCount().text()).toBe(`${job.artifacts.nodes.length} files`); + }); + + 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); + }); + + it('shows links to the job, pipeline, ref, and commit', () => { + expect(findJobLink().text()).toBe(job.name); + expect(findJobLink().attributes('href')).toBe(job.webPath); + + expect(findPipelineLink().text()).toBe(`#${getIdFromGraphQLId(job.pipeline.id)}`); + expect(findPipelineLink().attributes('href')).toBe(job.pipeline.path); + + expect(findRefLink().text()).toBe(job.refName); + expect(findRefLink().attributes('href')).toBe(job.refPath); + + expect(findCommitLink().text()).toBe(job.shortSha); + expect(findCommitLink().attributes('href')).toBe(job.commitPath); + }); + + it('shows the total size of artifacts', () => { + expect(findSize().text()).toBe(totalArtifactsSizeForJob(job)); + }); + + it('shows the created time', () => { + expect(findCreated().text()).toBe('5 years ago'); + }); + + describe('row expansion', () => { + it('toggles the visibility of the row details', async () => { + expect(findDetailsRows().length).toBe(0); + + findCount().trigger('click'); + await nextTick(); + + expect(findDetailsRows().length).toBe(1); + + findCount().trigger('click'); + await nextTick(); + + expect(findDetailsRows().length).toBe(0); + }); + + it('expands and collapses jobs', async () => { + // both jobs start collapsed + expect(findDetailsInRow(0).exists()).toBe(false); + expect(findDetailsInRow(1).exists()).toBe(false); + + findCountAt(0).trigger('click'); + await nextTick(); + + // first job is expanded, second row has its details + expect(findDetailsInRow(0).exists()).toBe(false); + expect(findDetailsInRow(1).exists()).toBe(true); + expect(findDetailsInRow(2).exists()).toBe(false); + + findCountAt(1).trigger('click'); + await nextTick(); + + // both jobs are expanded, each has details below it + expect(findDetailsInRow(0).exists()).toBe(false); + expect(findDetailsInRow(1).exists()).toBe(true); + expect(findDetailsInRow(2).exists()).toBe(false); + expect(findDetailsInRow(3).exists()).toBe(true); + + findCountAt(0).trigger('click'); + await nextTick(); + + // first job collapsed, second job expanded + expect(findDetailsInRow(0).exists()).toBe(false); + expect(findDetailsInRow(1).exists()).toBe(false); + expect(findDetailsInRow(2).exists()).toBe(true); + }); + + it('keeps the job expanded when an artifact is deleted', async () => { + findCount().trigger('click'); + await waitForPromises(); + + expect(findDetailsInRow(0).exists()).toBe(false); + expect(findDetailsInRow(1).exists()).toBe(true); + + findArtifactDeleteButton().vm.$emit('click'); + await nextTick(); + + expect(findDeleteModal().findComponent(GlModal).props('visible')).toBe(true); + + findDeleteModal().vm.$emit('primary'); + await waitForPromises(); + + expect(findDetailsInRow(0).exists()).toBe(false); + expect(findDetailsInRow(1).exists()).toBe(true); + }); + }); + }); + + describe('download button', () => { + it('is a link to the download path for the archive artifact', async () => { + createComponent(); + + await waitForPromises(); + + expect(findDownloadButton().attributes('href')).toBe(archiveArtifact.downloadPath); + }); + + it('is disabled when there is no download path', async () => { + const jobWithoutDownloadPath = { + ...job, + archive: { downloadPath: null }, + }; + + createComponent({ + handlers: { getJobArtifactsQuery: jest.fn() }, + data: { jobArtifacts: [jobWithoutDownloadPath] }, + }); + + await waitForPromises(); + + expect(findDownloadButton().attributes('disabled')).toBeDefined(); + }); + }); + + describe('browse button', () => { + it('is a link to the browse path for the job', async () => { + createComponent(); + + await waitForPromises(); + + expect(findBrowseButton().attributes('href')).toBe(job.browseArtifactsPath); + }); + + it('is disabled when there is no browse path', async () => { + const jobWithoutBrowsePath = { + ...job, + browseArtifactsPath: null, + }; + + createComponent({ + handlers: { getJobArtifactsQuery: jest.fn() }, + data: { jobArtifacts: [jobWithoutBrowsePath] }, + }); + + await waitForPromises(); + + expect(findBrowseButton().attributes('disabled')).toBeDefined(); + }); + }); + + describe('delete button', () => { + const artifactsFromJob = job.artifacts.nodes.map((node) => node.id); + + describe('with delete permission and bulk delete feature flag enabled', () => { + beforeEach(async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + }); + + await waitForPromises(); + }); + + it('opens the confirmation modal with the artifacts from the job', async () => { + await findDeleteButton().vm.$emit('click'); + + expect(findBulkDeleteModal().props()).toMatchObject({ + visible: true, + artifactsToDelete: artifactsFromJob, + }); + }); + + it('on confirm, deletes the artifacts from the job and shows a toast', async () => { + findDeleteButton().vm.$emit('click'); + findBulkDeleteModal().vm.$emit('primary'); + + expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ + projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), + ids: artifactsFromJob, + }); + + await waitForPromises(); + + expect(mockToastShow).toHaveBeenCalledWith( + `${artifactsFromJob.length} selected artifacts deleted`, + ); + }); + + it('does not clear selected artifacts on success', async () => { + // select job 2 via checkbox + findJobCheckbox(2).vm.$emit('input', true); + + // click delete button job 1 + findDeleteButton().vm.$emit('click'); + + // job 2's artifacts should still be selected + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( + job2.artifacts.nodes.map((node) => node.id), + ); + + // confirm delete + findBulkDeleteModal().vm.$emit('primary'); + + // job 1's artifacts should be deleted + expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ + projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), + ids: artifactsFromJob, + }); + + await waitForPromises(); + + // job 2's artifacts should still be selected + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( + job2.artifacts.nodes.map((node) => node.id), + ); + }); + }); + + it('shows an alert and does not clear selected artifacts on error', async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + handlers: { + getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse), + bulkDestroyArtifactsMutation: jest.fn().mockRejectedValue(), + }, + }); + await waitForPromises(); + + // select job 2 via checkbox + findJobCheckbox(2).vm.$emit('input', true); + + // click delete button job 1 + findDeleteButton().vm.$emit('click'); + + // confirm delete + findBulkDeleteModal().vm.$emit('primary'); + + await waitForPromises(); + + // job 2's artifacts should still be selected + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( + job2.artifacts.nodes.map((node) => node.id), + ); + expect(createAlert).toHaveBeenCalledWith({ + captureError: true, + error: expect.any(Error), + message: I18N_BULK_DELETE_ERROR, + }); + }); + + it('is disabled when bulk delete feature flag is disabled', async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false }, + }); + + await waitForPromises(); + + expect(findDeleteButton().attributes('disabled')).toBeDefined(); + }); + + it('is hidden when user does not have delete permission', async () => { + createComponent({ + canDestroyArtifacts: false, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false }, + }); + + await waitForPromises(); + + expect(findDeleteButton().exists()).toBe(false); + }); + }); + + describe('bulk delete', () => { + const selectedArtifacts = job.artifacts.nodes.map((node) => node.id); + + describe('with permission and feature flag enabled', () => { + beforeEach(async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + }); + + await waitForPromises(); + }); + + it('shows selected artifacts when a job is checked', async () => { + expect(findBulkDeleteContainer().exists()).toBe(false); + + await findJobCheckbox().vm.$emit('input', true); + + expect(findBulkDeleteContainer().exists()).toBe(true); + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(selectedArtifacts); + }); + + it('disappears when selected artifacts are cleared', async () => { + await findJobCheckbox().vm.$emit('input', true); + + expect(findBulkDeleteContainer().exists()).toBe(true); + + await findBulkDelete().vm.$emit('clearSelectedArtifacts'); + + expect(findBulkDeleteContainer().exists()).toBe(false); + }); + + it('shows a modal to confirm bulk delete', async () => { + findJobCheckbox().vm.$emit('input', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); + + await nextTick(); + + expect(findBulkDeleteModal().props('visible')).toBe(true); + }); + + it('deletes the selected artifacts and shows a toast', async () => { + findJobCheckbox().vm.$emit('input', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); + findBulkDeleteModal().vm.$emit('primary'); + + expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ + projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), + ids: selectedArtifacts, + }); + + await waitForPromises(); + + expect(mockToastShow).toHaveBeenCalledWith( + `${selectedArtifacts.length} selected artifacts deleted`, + ); + }); + + it('clears selected artifacts on success', async () => { + findJobCheckbox().vm.$emit('input', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); + findBulkDeleteModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]); + }); + }); + + describe('when the selected artifacts limit is reached', () => { + beforeEach(async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + data: { selectedArtifacts: maxSelectedArtifacts }, + }); + + await nextTick(); + }); + + it('passes isSelectedArtifactsLimitReached to bulk delete', () => { + expect(findBulkDelete().props('isSelectedArtifactsLimitReached')).toBe(true); + }); + + it('passes isSelectedArtifactsLimitReached to job checkbox', () => { + expect(wrapper.findComponent(JobCheckbox).props('isSelectedArtifactsLimitReached')).toBe( + true, + ); + }); + + it('passes isSelectedArtifactsLimitReached to table row details', async () => { + findCount().trigger('click'); + await nextTick(); + + expect(findDetailsInRow(1).props('isSelectedArtifactsLimitReached')).toBe(true); + }); + }); + + it('shows an alert and does not clear selected artifacts on error', async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + handlers: { + getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse), + bulkDestroyArtifactsMutation: jest.fn().mockRejectedValue(), + }, + }); + + await waitForPromises(); + + findJobCheckbox().vm.$emit('input', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); + findBulkDeleteModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(selectedArtifacts); + expect(createAlert).toHaveBeenCalledWith({ + captureError: true, + error: expect.any(Error), + message: I18N_BULK_DELETE_ERROR, + }); + }); + + it('shows no checkboxes without permission', async () => { + createComponent({ + canDestroyArtifacts: false, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + }); + + await waitForPromises(); + + expect(findAnyCheckbox().exists()).toBe(false); + }); + + it('shows no checkboxes with feature flag disabled', async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false }, + }); + + await waitForPromises(); + + expect(findAnyCheckbox().exists()).toBe(false); + }); + }); + + describe('pagination', () => { + const { pageInfo } = getJobArtifactsResponseThatPaginates.data.project.jobs; + const query = jest.fn().mockResolvedValue(getJobArtifactsResponseThatPaginates); + + beforeEach(async () => { + createComponent({ + handlers: { + getJobArtifactsQuery: query, + }, + data: { pageInfo }, + }); + + await nextTick(); + }); + + it('renders pagination and passes page props', () => { + expect(findPagination().props()).toMatchObject({ + value: INITIAL_CURRENT_PAGE, + prevPage: Number(pageInfo.hasPreviousPage), + nextPage: Number(pageInfo.hasNextPage), + }); + + expect(query).toHaveBeenCalledWith({ + projectPath: 'project/path', + firstPageSize: JOBS_PER_PAGE, + lastPageSize: null, + nextPageCursor: '', + prevPageCursor: '', + }); + }); + + it('updates query variables when going to previous page', async () => { + await setPage(1); + + expect(query).toHaveBeenLastCalledWith({ + projectPath: 'project/path', + firstPageSize: null, + lastPageSize: JOBS_PER_PAGE, + prevPageCursor: pageInfo.startCursor, + }); + expect(findPagination().props('value')).toEqual(1); + }); + + it('updates query variables when going to next page', async () => { + await setPage(2); + + expect(query).toHaveBeenLastCalledWith({ + projectPath: 'project/path', + firstPageSize: JOBS_PER_PAGE, + lastPageSize: null, + prevPageCursor: '', + nextPageCursor: pageInfo.endCursor, + }); + expect(findPagination().props('value')).toEqual(2); + }); + }); +}); diff --git a/spec/frontend/ci/artifacts/components/job_checkbox_spec.js b/spec/frontend/ci/artifacts/components/job_checkbox_spec.js new file mode 100644 index 00000000000..8b47571239c --- /dev/null +++ b/spec/frontend/ci/artifacts/components/job_checkbox_spec.js @@ -0,0 +1,132 @@ +import { GlFormCheckbox } from '@gitlab/ui'; +import mockGetJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import JobCheckbox from '~/ci/artifacts/components/job_checkbox.vue'; +import { I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants'; + +describe('JobCheckbox component', () => { + let wrapper; + + const mockArtifactNodes = mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes; + const mockSelectedArtifacts = [mockArtifactNodes[0], mockArtifactNodes[1]]; + const mockUnselectedArtifacts = [mockArtifactNodes[2]]; + + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + + const createComponent = ({ + hasArtifacts = true, + selectedArtifacts = mockSelectedArtifacts, + unselectedArtifacts = mockUnselectedArtifacts, + isSelectedArtifactsLimitReached = false, + } = {}) => { + wrapper = shallowMountExtended(JobCheckbox, { + propsData: { + hasArtifacts, + selectedArtifacts, + unselectedArtifacts, + isSelectedArtifactsLimitReached, + }, + mocks: { GlFormCheckbox }, + }); + }; + + it('is disabled when the job has no artifacts', () => { + createComponent({ hasArtifacts: false }); + + expect(findCheckbox().attributes('disabled')).toBeDefined(); + }); + + describe('when some artifacts from this job are selected', () => { + describe('when the selected artifacts limit has not been reached', () => { + beforeEach(() => { + createComponent(); + }); + + it('is indeterminate', () => { + expect(findCheckbox().attributes('indeterminate')).toBe('true'); + expect(findCheckbox().attributes('checked')).toBeUndefined(); + }); + + it('selects the unselected artifacts on click', () => { + findCheckbox().vm.$emit('input', true); + + expect(wrapper.emitted('selectArtifact')).toMatchObject([ + [mockUnselectedArtifacts[0], true], + ]); + }); + }); + + describe('when the selected artifacts limit has been reached', () => { + beforeEach(() => { + // limit has been reached by selecting artifacts from this job + createComponent({ + selectedArtifacts: mockSelectedArtifacts, + isSelectedArtifactsLimitReached: true, + }); + }); + + it('remains enabled', () => { + // job checkbox remains enabled to allow de-selection + expect(findCheckbox().attributes('disabled')).toBeUndefined(); + expect(findCheckbox().attributes('title')).not.toBe(I18N_BULK_DELETE_MAX_SELECTED); + }); + }); + }); + + describe('when all artifacts from this job are selected', () => { + beforeEach(() => { + createComponent({ unselectedArtifacts: [] }); + }); + + it('is checked', () => { + expect(findCheckbox().attributes('checked')).toBe('true'); + }); + + it('deselects the selected artifacts on click', () => { + findCheckbox().vm.$emit('input', false); + + expect(wrapper.emitted('selectArtifact')).toMatchObject([ + [mockSelectedArtifacts[0], false], + [mockSelectedArtifacts[1], false], + ]); + }); + }); + + describe('when no artifacts from this job are selected', () => { + describe('when the selected artifacts limit has not been reached', () => { + beforeEach(() => { + createComponent({ selectedArtifacts: [] }); + }); + + it('is enabled and not checked', () => { + expect(findCheckbox().attributes('checked')).toBeUndefined(); + expect(findCheckbox().attributes('disabled')).toBeUndefined(); + expect(findCheckbox().attributes('title')).toBe(''); + }); + + it('selects the artifacts on click', () => { + findCheckbox().vm.$emit('input', true); + + expect(wrapper.emitted('selectArtifact')).toMatchObject([ + [mockUnselectedArtifacts[0], true], + ]); + }); + }); + + describe('when the selected artifacts limit has been reached', () => { + beforeEach(() => { + // limit has been reached by selecting artifacts from other jobs + createComponent({ + selectedArtifacts: [], + isSelectedArtifactsLimitReached: true, + }); + }); + + it('is disabled when the selected artifacts limit has been reached', () => { + // job checkbox is disabled to block further selection + expect(findCheckbox().attributes('disabled')).toBeDefined(); + expect(findCheckbox().attributes('title')).toBe(I18N_BULK_DELETE_MAX_SELECTED); + }); + }); + }); +}); diff --git a/spec/frontend/ci/artifacts/graphql/cache_update_spec.js b/spec/frontend/ci/artifacts/graphql/cache_update_spec.js new file mode 100644 index 00000000000..3c415534c7c --- /dev/null +++ b/spec/frontend/ci/artifacts/graphql/cache_update_spec.js @@ -0,0 +1,67 @@ +import getJobArtifactsQuery from '~/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql'; +import { removeArtifactFromStore } from '~/ci/artifacts/graphql/cache_update'; + +describe('Artifact table cache updates', () => { + let store; + + const cacheMock = { + project: { + jobs: { + nodes: [ + { artifacts: { nodes: [{ id: 'foo' }] } }, + { artifacts: { nodes: [{ id: 'bar' }] } }, + ], + }, + }, + }; + + const query = getJobArtifactsQuery; + const variables = { fullPath: 'path/to/project' }; + + beforeEach(() => { + store = { + readQuery: jest.fn().mockReturnValue(cacheMock), + writeQuery: jest.fn(), + }; + }); + + describe('removeArtifactFromStore', () => { + it('calls readQuery', () => { + removeArtifactFromStore(store, 'foo', query, variables); + expect(store.readQuery).toHaveBeenCalledWith({ query, variables }); + }); + + it('writes the correct result in the cache', () => { + removeArtifactFromStore(store, 'foo', query, variables); + expect(store.writeQuery).toHaveBeenCalledWith({ + query, + variables, + data: { + project: { + jobs: { + nodes: [{ artifacts: { nodes: [] } }, { artifacts: { nodes: [{ id: 'bar' }] } }], + }, + }, + }, + }); + }); + + it('does not remove an unknown artifact', () => { + removeArtifactFromStore(store, 'baz', query, variables); + expect(store.writeQuery).toHaveBeenCalledWith({ + query, + variables, + data: { + project: { + jobs: { + nodes: [ + { artifacts: { nodes: [{ id: 'foo' }] } }, + { artifacts: { nodes: [{ id: 'bar' }] } }, + ], + }, + }, + }, + }); + }); + }); +}); diff --git a/spec/frontend/ci/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js index d4f588a0e09..4b7ca36f331 100644 --- a/spec/frontend/ci/ci_lint/components/ci_lint_spec.js +++ b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js @@ -48,7 +48,6 @@ describe('CI Lint', () => { afterEach(() => { mockMutate.mockClear(); - wrapper.destroy(); }); it('displays the editor', () => { diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js index e4abedb412f..8990a70d4ef 100644 --- a/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js +++ b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js @@ -1,5 +1,7 @@ import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlPipelineSchedulesEdit from 'test_fixtures/pipeline_schedules/edit.html'; +import htmlPipelineSchedulesEditWithVariables from 'test_fixtures/pipeline_schedules/edit_with_variables.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import VariableList from '~/ci/ci_variable_list/ci_variable_list'; const HIDE_CLASS = 'hide'; @@ -11,7 +13,7 @@ describe('VariableList', () => { describe('with only key/value inputs', () => { describe('with no variables', () => { beforeEach(() => { - loadHTMLFixture('pipeline_schedules/edit.html'); + setHTMLFixture(htmlPipelineSchedulesEdit); $wrapper = $('.js-ci-variable-list-section'); variableList = new VariableList({ @@ -69,7 +71,7 @@ describe('VariableList', () => { describe('with persisted variables', () => { beforeEach(() => { - loadHTMLFixture('pipeline_schedules/edit_with_variables.html'); + setHTMLFixture(htmlPipelineSchedulesEditWithVariables); $wrapper = $('.js-ci-variable-list-section'); variableList = new VariableList({ @@ -106,7 +108,7 @@ describe('VariableList', () => { describe('toggleEnableRow method', () => { beforeEach(() => { - loadHTMLFixture('pipeline_schedules/edit_with_variables.html'); + setHTMLFixture(htmlPipelineSchedulesEditWithVariables); $wrapper = $('.js-ci-variable-list-section'); variableList = new VariableList({ diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js index 71e8e6d3afb..3ef5427f288 100644 --- a/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js +++ b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js @@ -1,12 +1,13 @@ import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlPipelineSchedulesEdit from 'test_fixtures/pipeline_schedules/edit.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import setupNativeFormVariableList from '~/ci/ci_variable_list/native_form_variable_list'; describe('NativeFormVariableList', () => { let $wrapper; beforeEach(() => { - loadHTMLFixture('pipeline_schedules/edit.html'); + setHTMLFixture(htmlPipelineSchedulesEdit); $wrapper = $('.js-ci-variable-list-section'); setupNativeFormVariableList({ diff --git a/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js index 5e0c35c9f90..1d0dcf242a4 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js @@ -1,7 +1,16 @@ import { shallowMount } from '@vue/test-utils'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, +} from '~/ci/ci_variable_list/constants'; import ciAdminVariables from '~/ci/ci_variable_list/components/ci_admin_variables.vue'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; +import addAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql'; +import deleteAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql'; +import updateAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql'; +import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql'; describe('Ci Project Variable wrapper', () => { let wrapper; @@ -16,18 +25,23 @@ describe('Ci Project Variable wrapper', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Passes down the correct props to ci_variable_shared', () => { expect(findCiShared().props()).toEqual({ areScopedVariablesAvailable: false, componentName: 'InstanceVariables', entity: '', hideEnvironmentScope: true, - mutationData: wrapper.vm.$options.mutationData, - queryData: wrapper.vm.$options.queryData, + mutationData: { + [ADD_MUTATION_ACTION]: addAdminVariable, + [UPDATE_MUTATION_ACTION]: updateAdminVariable, + [DELETE_MUTATION_ACTION]: deleteAdminVariable, + }, + queryData: { + ciVariables: { + lookup: expect.any(Function), + query: getAdminVariables, + }, + }, refetchAfterMutation: true, fullPath: null, id: null, 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 2fd395a1230..1937e3b34b7 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,13 +1,17 @@ import { GlListboxItem, GlCollapsibleListbox, GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { allEnvironments } from '~/ci/ci_variable_list/constants'; +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'; describe('Ci environments dropdown', () => { let wrapper; const envs = ['dev', 'prod', 'staging']; - const defaultProps = { environments: envs, selectedEnvironmentScope: '' }; + const defaultProps = { + areEnvironmentsLoading: false, + environments: envs, + selectedEnvironmentScope: '', + }; const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem); const findListboxItemByIndex = (index) => wrapper.findAllComponents(GlListboxItem).at(index); @@ -15,22 +19,24 @@ describe('Ci environments dropdown', () => { const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findListboxText = () => findListbox().props('toggleText'); const findCreateWildcardButton = () => wrapper.findComponent(GlDropdownItem); + const findMaxEnvNote = () => wrapper.findByTestId('max-envs-notice'); - const createComponent = ({ props = {}, searchTerm = '' } = {}) => { - wrapper = mount(CiEnvironmentsDropdown, { + const createComponent = ({ props = {}, searchTerm = '', enableFeatureFlag = false } = {}) => { + wrapper = mountExtended(CiEnvironmentsDropdown, { propsData: { ...defaultProps, ...props, }, + provide: { + glFeatures: { + ciLimitEnvironmentScope: enableFeatureFlag, + }, + }, }); findListbox().vm.$emit('search', searchTerm); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('No environments found', () => { beforeEach(() => { createComponent({ searchTerm: 'stable' }); @@ -44,19 +50,32 @@ describe('Ci environments dropdown', () => { }); describe('Search term is empty', () => { - beforeEach(() => { - createComponent({ props: { environments: envs } }); - }); + describe.each` + featureFlag | flagStatus | defaultEnvStatus | firstItemValue | envIndices + ${true} | ${'enabled'} | ${'prepends'} | ${'*'} | ${[1, 2, 3]} + ${false} | ${'disabled'} | ${'does not prepend'} | ${envs[0]} | ${[0, 1, 2]} + `( + 'when ciLimitEnvironmentScope feature flag is $flagStatus', + ({ featureFlag, defaultEnvStatus, firstItemValue, envIndices }) => { + beforeEach(() => { + createComponent({ props: { environments: envs }, enableFeatureFlag: featureFlag }); + }); - it('renders all environments when search term is empty', () => { - expect(findListboxItemByIndex(0).text()).toBe(envs[0]); - expect(findListboxItemByIndex(1).text()).toBe(envs[1]); - expect(findListboxItemByIndex(2).text()).toBe(envs[2]); - }); + it(`${defaultEnvStatus} * in listbox`, () => { + expect(findListboxItemByIndex(0).text()).toBe(firstItemValue); + }); - it('does not display active checkmark on the inactive stage', () => { - expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); - }); + 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); + }); + }, + ); }); describe('when `*` is the value of selectedEnvironmentScope props', () => { @@ -72,46 +91,92 @@ describe('Ci environments dropdown', () => { }); }); - describe('Environments found', () => { + describe('When ciLimitEnvironmentScope feature flag is disabled', () => { const currentEnv = envs[2]; beforeEach(() => { - createComponent({ searchTerm: currentEnv }); + createComponent(); }); - it('renders only the environment searched for', () => { + 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 display create button', () => { - expect(findCreateWildcardButton().exists()).toBe(false); + 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(); }); - describe('Custom events', () => { - describe('when selecting an environment', () => { - const itemIndex = 0; + it('does not display note about max environments shown', () => { + expect(findMaxEnvNote().exists()).toBe(false); + }); + }); - beforeEach(() => { - createComponent(); - }); + describe('When ciLimitEnvironmentScope feature flag is enabled', () => { + const currentEnv = envs[2]; - it('emits `select-environment` when an environment is clicked', () => { - findListbox().vm.$emit('select', envs[itemIndex]); - expect(wrapper.emitted('select-environment')).toEqual([[envs[itemIndex]]]); - }); + beforeEach(() => { + createComponent({ enableFeatureFlag: true }); + }); + + it('renders environments passed down to it', async () => { + await findListbox().vm.$emit('search', currentEnv); + + expect(findAllListboxItems()).toHaveLength(envs.length); + }); + + it('emits event when searching', async () => { + expect(wrapper.emitted('search-environment-scope')).toHaveLength(1); + + await findListbox().vm.$emit('search', currentEnv); + + expect(wrapper.emitted('search-environment-scope')).toHaveLength(2); + expect(wrapper.emitted('search-environment-scope')[1]).toEqual([currentEnv]); + }); + + it('renders loading icon while search query is loading', () => { + createComponent({ enableFeatureFlag: true, props: { areEnvironmentsLoading: true } }); + + expect(findListbox().props('searching')).toBe(true); + }); + + it('displays note about max environments shown', () => { + expect(findMaxEnvNote().exists()).toBe(true); + expect(findMaxEnvNote().text()).toContain(String(ENVIRONMENT_QUERY_LIMIT)); + }); + }); + + describe('Custom events', () => { + describe('when selecting an environment', () => { + const itemIndex = 0; + + beforeEach(() => { + createComponent(); }); - describe('when creating a new environment from a search term', () => { - const search = 'new-env'; - beforeEach(() => { - createComponent({ searchTerm: search }); - }); + it('emits `select-environment` when an environment is clicked', () => { + findListbox().vm.$emit('select', envs[itemIndex]); - it('emits create-environment-scope', () => { - findCreateWildcardButton().vm.$emit('click'); - expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]); - }); + expect(wrapper.emitted('select-environment')).toEqual([[envs[itemIndex]]]); + }); + }); + + describe('when creating a new environment from a search term', () => { + const search = 'new-env'; + beforeEach(() => { + createComponent({ searchTerm: search }); + }); + + it('emits create-environment-scope', () => { + findCreateWildcardButton().vm.$emit('click'); + + expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]); }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js index c0fb133b9b1..7436210fe70 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js @@ -4,6 +4,15 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciGroupVariables from '~/ci/ci_variable_list/components/ci_group_variables.vue'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, +} from '~/ci/ci_variable_list/constants'; +import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql'; +import addGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql'; +import deleteGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql'; +import updateGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql'; const mockProvide = { glFeatures: { @@ -24,10 +33,6 @@ describe('Ci Group Variable wrapper', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('Props', () => { beforeEach(() => { createComponent(); @@ -41,8 +46,17 @@ describe('Ci Group Variable wrapper', () => { entity: 'group', fullPath: mockProvide.groupPath, hideEnvironmentScope: false, - mutationData: wrapper.vm.$options.mutationData, - queryData: wrapper.vm.$options.queryData, + mutationData: { + [ADD_MUTATION_ACTION]: addGroupVariable, + [UPDATE_MUTATION_ACTION]: updateGroupVariable, + [DELETE_MUTATION_ACTION]: deleteGroupVariable, + }, + queryData: { + ciVariables: { + lookup: expect.any(Function), + query: getGroupVariables, + }, + }, refetchAfterMutation: false, }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js index bd1e6b17d6b..69b0d4261b2 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js @@ -4,6 +4,16 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciProjectVariables from '~/ci/ci_variable_list/components/ci_project_variables.vue'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, +} from '~/ci/ci_variable_list/constants'; +import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql'; +import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql'; +import addProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql'; +import deleteProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql'; +import updateProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql'; const mockProvide = { projectFullPath: '/namespace/project', @@ -25,10 +35,6 @@ describe('Ci Project Variable wrapper', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Passes down the correct props to ci_variable_shared', () => { expect(findCiShared().props()).toEqual({ id: convertToGraphQLId(TYPENAME_PROJECT, mockProvide.projectId), @@ -37,8 +43,21 @@ describe('Ci Project Variable wrapper', () => { entity: 'project', fullPath: mockProvide.projectFullPath, hideEnvironmentScope: false, - mutationData: wrapper.vm.$options.mutationData, - queryData: wrapper.vm.$options.queryData, + mutationData: { + [ADD_MUTATION_ACTION]: addProjectVariable, + [UPDATE_MUTATION_ACTION]: updateProjectVariable, + [DELETE_MUTATION_ACTION]: deleteProjectVariable, + }, + queryData: { + ciVariables: { + lookup: expect.any(Function), + query: getProjectVariables, + }, + environments: { + lookup: expect.any(Function), + query: getProjectEnvironments, + }, + }, refetchAfterMutation: false, }); }); 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 508af964ca3..b6ffde9b33f 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 @@ -10,10 +10,12 @@ import { EVENT_LABEL, EVENT_ACTION, ENVIRONMENT_SCOPE_LINK_TITLE, + groupString, instanceString, + projectString, variableOptions, } from '~/ci/ci_variable_list/constants'; -import { mockVariablesWithScopes } from '../mocks'; +import { mockEnvs, mockVariablesWithScopes, mockVariablesWithUniqueScopes } from '../mocks'; import ModalStub from '../stubs'; describe('Ci variable modal', () => { @@ -42,12 +44,13 @@ describe('Ci variable modal', () => { }; const defaultProps = { + areEnvironmentsLoading: false, areScopedVariablesAvailable: true, environments: [], hideEnvironmentScope: false, mode: ADD_VARIABLE_ACTION, selectedVariable: {}, - variable: [], + variables: [], }; const createComponent = ({ mountFn = shallowMountExtended, props = {}, provide = {} } = {}) => { @@ -85,10 +88,6 @@ describe('Ci variable modal', () => { const findVariableTypeDropdown = () => wrapper.find('#ci-variable-type'); const findEnvironmentScopeText = () => wrapper.findByText('Environment scope'); - afterEach(() => { - wrapper.destroy(); - }); - describe('Adding a variable', () => { describe('when no key/value pair are present', () => { beforeEach(() => { @@ -96,7 +95,7 @@ describe('Ci variable modal', () => { }); it('shows the submit button as disabled', () => { - expect(findAddorUpdateButton().attributes('disabled')).toBe('true'); + expect(findAddorUpdateButton().attributes('disabled')).toBeDefined(); }); }); @@ -115,7 +114,6 @@ describe('Ci variable modal', () => { beforeEach(() => { createComponent({ props: { selectedVariable: currentVariable } }); - jest.spyOn(wrapper.vm, '$emit'); }); it('Dispatches `add-variable` action on submit', () => { @@ -156,7 +154,7 @@ describe('Ci variable modal', () => { findModal().vm.$emit('shown'); }); - it('keeps the value as false', async () => { + it('keeps the value as false', () => { expect( findProtectedVariableCheckbox().attributes('data-is-protected-checked'), ).toBeUndefined(); @@ -241,7 +239,6 @@ describe('Ci variable modal', () => { it('defaults to expanded and raw:false when adding a variable', () => { createComponent({ props: { selectedVariable: variable } }); - jest.spyOn(wrapper.vm, '$emit'); findModal().vm.$emit('shown'); @@ -266,7 +263,6 @@ describe('Ci variable modal', () => { mode: EDIT_VARIABLE_ACTION, }, }); - jest.spyOn(wrapper.vm, '$emit'); findModal().vm.$emit('shown'); await findExpandedVariableCheckbox().vm.$emit('change'); @@ -305,7 +301,6 @@ describe('Ci variable modal', () => { beforeEach(() => { createComponent({ props: { selectedVariable: variable, mode: EDIT_VARIABLE_ACTION } }); - jest.spyOn(wrapper.vm, '$emit'); }); it('button text is Update variable when updating', () => { @@ -353,6 +348,42 @@ describe('Ci variable modal', () => { expect(link.attributes('title')).toBe(ENVIRONMENT_SCOPE_LINK_TITLE); expect(link.attributes('href')).toBe(defaultProvide.environmentScopeLink); }); + + describe('when feature flag is enabled', () => { + beforeEach(() => { + createComponent({ + props: { + environments: mockEnvs, + variables: mockVariablesWithUniqueScopes(projectString), + }, + provide: { glFeatures: { ciLimitEnvironmentScope: true } }, + }); + }); + + 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', () => { @@ -476,7 +507,7 @@ describe('Ci variable modal', () => { }); it('disables the submit button', () => { - expect(findAddorUpdateButton().attributes('disabled')).toBe('disabled'); + expect(findAddorUpdateButton().attributes('disabled')).toBeDefined(); }); it('shows the correct error text', () => { 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 32af2ec4de9..12ca9a78369 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 @@ -1,4 +1,3 @@ -import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import CiVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue'; import ciVariableModal from '~/ci/ci_variable_list/components/ci_variable_modal.vue'; @@ -16,12 +15,14 @@ describe('Ci variable table', () => { let wrapper; const defaultProps = { + areEnvironmentsLoading: false, areScopedVariablesAvailable: true, entity: 'project', environments: mapEnvironmentNames(mockEnvs), hideEnvironmentScope: false, isLoading: false, maxVariableLimit: 5, + pageInfo: { after: '' }, variables: mockVariablesWithScopes(projectString), }; @@ -37,10 +38,6 @@ describe('Ci variable table', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('props passing', () => { it('passes props down correctly to the ci table', () => { createComponent(); @@ -49,6 +46,7 @@ describe('Ci variable table', () => { entity: 'project', isLoading: defaultProps.isLoading, maxVariableLimit: defaultProps.maxVariableLimit, + pageInfo: defaultProps.pageInfo, variables: defaultProps.variables, }); }); @@ -56,10 +54,10 @@ describe('Ci variable table', () => { it('passes props down correctly to the ci modal', async () => { createComponent(); - findCiVariableTable().vm.$emit('set-selected-variable'); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable'); expect(findCiVariableModal().props()).toEqual({ + areEnvironmentsLoading: defaultProps.areEnvironmentsLoading, areScopedVariablesAvailable: defaultProps.areScopedVariablesAvailable, environments: defaultProps.environments, hideEnvironmentScope: defaultProps.hideEnvironmentScope, @@ -76,15 +74,13 @@ describe('Ci variable table', () => { }); it('passes down ADD mode when receiving an empty variable', async () => { - findCiVariableTable().vm.$emit('set-selected-variable'); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable'); expect(findCiVariableModal().props('mode')).toBe(ADD_VARIABLE_ACTION); }); it('passes down EDIT mode when receiving a variable', async () => { - findCiVariableTable().vm.$emit('set-selected-variable', newVariable); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable', newVariable); expect(findCiVariableModal().props('mode')).toBe(EDIT_VARIABLE_ACTION); }); @@ -100,25 +96,21 @@ describe('Ci variable table', () => { }); it('shows modal when adding a new variable', async () => { - findCiVariableTable().vm.$emit('set-selected-variable'); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable'); expect(findCiVariableModal().exists()).toBe(true); }); it('shows modal when updating a variable', async () => { - findCiVariableTable().vm.$emit('set-selected-variable', newVariable); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable', newVariable); expect(findCiVariableModal().exists()).toBe(true); }); it('hides modal when receiving the event from the modal', async () => { - findCiVariableTable().vm.$emit('set-selected-variable'); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable'); - findCiVariableModal().vm.$emit('hideModal'); - await nextTick(); + await findCiVariableModal().vm.$emit('hideModal'); expect(findCiVariableModal().exists()).toBe(false); }); @@ -135,13 +127,42 @@ describe('Ci variable table', () => { ${'update-variable'} ${'delete-variable'} `('bubbles up the $eventName event', async ({ eventName }) => { - findCiVariableTable().vm.$emit('set-selected-variable'); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable'); - findCiVariableModal().vm.$emit(eventName, newVariable); - await nextTick(); + await findCiVariableModal().vm.$emit(eventName, newVariable); expect(wrapper.emitted(eventName)).toEqual([[newVariable]]); }); }); + + describe('pages events', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + eventName | args + ${'handle-prev-page'} | ${undefined} + ${'handle-next-page'} | ${undefined} + ${'sort-changed'} | ${{ sortDesc: true }} + `('bubbles up the $eventName event', async ({ args, eventName }) => { + await findCiVariableTable().vm.$emit(eventName, args); + + expect(wrapper.emitted(eventName)).toEqual([[args]]); + }); + }); + + describe('environment events', () => { + beforeEach(() => { + createComponent(); + }); + + it('bubbles up the search event', async () => { + await findCiVariableTable().vm.$emit('set-selected-variable'); + + await findCiVariableModal().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 c977ae773db..a25d325f7a1 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 @@ -1,13 +1,12 @@ -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { assertProps } from 'helpers/assert_props'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { resolvers } from '~/ci/ci_variable_list/graphql/settings'; -import { TYPENAME_GROUP } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; import ciVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue'; @@ -18,12 +17,11 @@ import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_varia import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql'; import { - ADD_MUTATION_ACTION, - DELETE_MUTATION_ACTION, - UPDATE_MUTATION_ACTION, + ENVIRONMENT_QUERY_LIMIT, environmentFetchErrorText, genericMutationErrorText, variableFetchErrorText, + mapMutationActionToToast, } from '~/ci/ci_variable_list/constants'; import { @@ -41,7 +39,7 @@ import { mockAdminVariables, } from '../mocks'; -jest.mock('~/flash'); +jest.mock('~/alert'); Vue.use(VueApollo); @@ -53,6 +51,7 @@ const mockProvide = { const defaultProps = { areScopedVariablesAvailable: true, + pageInfo: {}, hideEnvironmentScope: false, refetchAfterMutation: false, }; @@ -62,15 +61,22 @@ describe('Ci Variable Shared Component', () => { let mockApollo; let mockEnvironments; + let mockMutation; + let mockAddMutation; + let mockUpdateMutation; + let mockDeleteMutation; let mockVariables; + const mockToastShow = jest.fn(); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findCiTable = () => wrapper.findComponent(GlTable); const findCiSettings = () => wrapper.findComponent(ciVariableSettings); // eslint-disable-next-line consistent-return - async function createComponentWithApollo({ + function createComponentWithApollo({ customHandlers = null, + customResolvers = null, isLoading = false, props = { ...createProjectProps() }, provide = {}, @@ -80,7 +86,9 @@ describe('Ci Variable Shared Component', () => { [getProjectVariables, mockVariables], ]; - mockApollo = createMockApollo(handlers, resolvers); + const mutationResolvers = customResolvers || resolvers; + + mockApollo = createMockApollo(handlers, mutationResolvers); wrapper = shallowMount(ciVariableShared, { propsData: { @@ -93,6 +101,11 @@ describe('Ci Variable Shared Component', () => { }, apolloProvider: mockApollo, stubs: { ciVariableSettings, ciVariableTable }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, }); if (!isLoading) { @@ -103,347 +116,525 @@ describe('Ci Variable Shared Component', () => { beforeEach(() => { mockEnvironments = jest.fn(); mockVariables = jest.fn(); + mockMutation = jest.fn(); + mockAddMutation = jest.fn(); + mockUpdateMutation = jest.fn(); + mockDeleteMutation = jest.fn(); }); - describe('while queries are being fetch', () => { - beforeEach(() => { - createComponentWithApollo({ isLoading: true }); - }); + describe.each` + isVariablePagesEnabled | text + ${true} | ${'enabled'} + ${false} | ${'disabled'} + `('When Pages FF is $text', ({ isVariablePagesEnabled }) => { + const pagesFeatureFlagProvide = isVariablePagesEnabled + ? { glFeatures: { ciVariablesPages: true } } + : {}; + + describe('while queries are being fetched', () => { + beforeEach(() => { + createComponentWithApollo({ isLoading: true }); + }); - it('shows a loading icon', () => { - expect(findLoadingIcon().exists()).toBe(true); - expect(findCiTable().exists()).toBe(false); + it('shows a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findCiTable().exists()).toBe(false); + }); }); - }); - describe('when queries are resolved', () => { - describe('successfully', () => { - beforeEach(async () => { - mockEnvironments.mockResolvedValue(mockProjectEnvironments); - mockVariables.mockResolvedValue(mockProjectVariables); + describe('when queries are resolved', () => { + describe('successfully', () => { + beforeEach(async () => { + mockEnvironments.mockResolvedValue(mockProjectEnvironments); + mockVariables.mockResolvedValue(mockProjectVariables); - await createComponentWithApollo({ provide: createProjectProvide() }); - }); + await createComponentWithApollo({ + provide: { ...createProjectProvide(), ...pagesFeatureFlagProvide }, + }); + }); - it('passes down the expected max variable limit as props', () => { - expect(findCiSettings().props('maxVariableLimit')).toBe( - mockProjectVariables.data.project.ciVariables.limit, - ); - }); + it('passes down the expected max variable limit as props', () => { + expect(findCiSettings().props('maxVariableLimit')).toBe( + mockProjectVariables.data.project.ciVariables.limit, + ); + }); - it('passes down the expected environments as props', () => { - expect(findCiSettings().props('environments')).toEqual([prodName, devName]); - }); + it('passes down the expected environments as props', () => { + expect(findCiSettings().props('environments')).toEqual([prodName, devName]); + }); - it('passes down the expected variables as props', () => { - expect(findCiSettings().props('variables')).toEqual( - mockProjectVariables.data.project.ciVariables.nodes, - ); - }); + it('passes down the expected variables as props', () => { + expect(findCiSettings().props('variables')).toEqual( + mockProjectVariables.data.project.ciVariables.nodes, + ); + }); - it('createAlert was not called', () => { - expect(createAlert).not.toHaveBeenCalled(); + it('createAlert was not called', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); }); - }); - describe('with an error for variables', () => { - beforeEach(async () => { - mockEnvironments.mockResolvedValue(mockProjectEnvironments); - mockVariables.mockRejectedValue(); + describe('with an error for variables', () => { + beforeEach(async () => { + mockEnvironments.mockResolvedValue(mockProjectEnvironments); + mockVariables.mockRejectedValue(); - await createComponentWithApollo(); - }); + await createComponentWithApollo({ provide: pagesFeatureFlagProvide }); + }); - it('calls createAlert with the expected error message', () => { - expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText }); + it('calls createAlert with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText }); + }); }); - }); - describe('with an error for environments', () => { - beforeEach(async () => { - mockEnvironments.mockRejectedValue(); - mockVariables.mockResolvedValue(mockProjectVariables); + describe('with an error for environments', () => { + beforeEach(async () => { + mockEnvironments.mockRejectedValue(); + mockVariables.mockResolvedValue(mockProjectVariables); - await createComponentWithApollo(); - }); + await createComponentWithApollo({ provide: pagesFeatureFlagProvide }); + }); - it('calls createAlert with the expected error message', () => { - expect(createAlert).toHaveBeenCalledWith({ message: environmentFetchErrorText }); + it('calls createAlert with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: environmentFetchErrorText }); + }); }); }); - }); - describe('environment query', () => { - describe('when there is an environment key in queryData', () => { - beforeEach(async () => { - mockEnvironments.mockResolvedValue(mockProjectEnvironments); - mockVariables.mockResolvedValue(mockProjectVariables); + describe('environment query', () => { + describe('when there is an environment key in queryData', () => { + beforeEach(() => { + mockEnvironments.mockResolvedValue(mockProjectEnvironments); - await createComponentWithApollo({ props: { ...createProjectProps() } }); - }); + mockVariables.mockResolvedValue(mockProjectVariables); + }); - it('is executed', () => { - expect(mockVariables).toHaveBeenCalled(); - }); - }); + it('environments are fetched', async () => { + await createComponentWithApollo({ + props: { ...createProjectProps() }, + provide: pagesFeatureFlagProvide, + }); - describe('when there isnt an environment key in queryData', () => { - beforeEach(async () => { - mockVariables.mockResolvedValue(mockGroupVariables); + expect(mockEnvironments).toHaveBeenCalled(); + }); - await createComponentWithApollo({ props: { ...createGroupProps() } }); - }); + describe('when Limit Environment Scope FF is enabled', () => { + beforeEach(async () => { + await createComponentWithApollo({ + props: { ...createProjectProps() }, + provide: { + glFeatures: { + ciLimitEnvironmentScope: true, + ciVariablesPages: isVariablePagesEnabled, + }, + }, + }); + }); - it('is skipped', () => { - expect(mockVariables).not.toHaveBeenCalled(); - }); - }); - }); + it('initial query is called with the correct variables', () => { + expect(mockEnvironments).toHaveBeenCalledWith({ + first: ENVIRONMENT_QUERY_LIMIT, + fullPath: '/namespace/project/', + search: '', + }); + }); - describe('mutations', () => { - const groupProps = createGroupProps(); + it(`refetches environments when search term is present`, async () => { + expect(mockEnvironments).toHaveBeenCalledTimes(1); + expect(mockEnvironments).toHaveBeenCalledWith(expect.objectContaining({ search: '' })); - beforeEach(async () => { - mockVariables.mockResolvedValue(mockGroupVariables); + await findCiSettings().vm.$emit('search-environment-scope', 'staging'); - await createComponentWithApollo({ - customHandlers: [[getGroupVariables, mockVariables]], - props: groupProps, - }); - }); - it.each` - actionName | mutation | event - ${'add'} | ${groupProps.mutationData[ADD_MUTATION_ACTION]} | ${'add-variable'} - ${'update'} | ${groupProps.mutationData[UPDATE_MUTATION_ACTION]} | ${'update-variable'} - ${'delete'} | ${groupProps.mutationData[DELETE_MUTATION_ACTION]} | ${'delete-variable'} - `( - 'calls the right mutation from propsData when user performs $actionName variable', - async ({ event, mutation }) => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); - - await findCiSettings().vm.$emit(event, newVariable); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation, - variables: { - endpoint: mockProvide.endpoint, - fullPath: groupProps.fullPath, - id: convertToGraphQLId(TYPENAME_GROUP, groupProps.id), - variable: newVariable, - }, - }); - }, - ); - - it.each` - actionName | event - ${'add'} | ${'add-variable'} - ${'update'} | ${'update-variable'} - ${'delete'} | ${'delete-variable'} - `( - 'throws with the specific graphql error if present when user performs $actionName variable', - async ({ event }) => { - const graphQLErrorMessage = 'There is a problem with this graphQL action'; - jest - .spyOn(wrapper.vm.$apollo, 'mutate') - .mockResolvedValue({ data: { ciVariableMutation: { errors: [graphQLErrorMessage] } } }); - await findCiSettings().vm.$emit(event, newVariable); - await nextTick(); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); - expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage }); - }, - ); - - it.each` - actionName | event - ${'add'} | ${'add-variable'} - ${'update'} | ${'update-variable'} - ${'delete'} | ${'delete-variable'} - `( - 'throws generic error on failure with no graphql errors and user performs $actionName variable', - async ({ event }) => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementationOnce(() => { - throw new Error(); + expect(mockEnvironments).toHaveBeenCalledTimes(2); + expect(mockEnvironments).toHaveBeenCalledWith( + expect.objectContaining({ search: 'staging' }), + ); + }); }); - await findCiSettings().vm.$emit(event, newVariable); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); - expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText }); - }, - ); + describe('when Limit Environment Scope FF is disabled', () => { + beforeEach(async () => { + await createComponentWithApollo({ + props: { ...createProjectProps() }, + provide: pagesFeatureFlagProvide, + }); + }); + + it('initial query is called with the correct variables', () => { + expect(mockEnvironments).toHaveBeenCalledWith({ fullPath: '/namespace/project/' }); + }); - describe('without fullpath and ID props', () => { - beforeEach(async () => { - mockVariables.mockResolvedValue(mockAdminVariables); + it(`does not refetch environments when search term is present`, async () => { + expect(mockEnvironments).toHaveBeenCalledTimes(1); - await createComponentWithApollo({ - customHandlers: [[getAdminVariables, mockVariables]], - props: createInstanceProps(), + await findCiSettings().vm.$emit('search-environment-scope', 'staging'); + + expect(mockEnvironments).toHaveBeenCalledTimes(1); + }); }); }); - it('does not pass fullPath and ID to the mutation', async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + describe("when there isn't an environment key in queryData", () => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); - await findCiSettings().vm.$emit('add-variable', newVariable); + await createComponentWithApollo({ + props: { ...createGroupProps() }, + provide: pagesFeatureFlagProvide, + }); + }); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: wrapper.props().mutationData[ADD_MUTATION_ACTION], - variables: { - endpoint: mockProvide.endpoint, - variable: newVariable, - }, + it('fetching environments is skipped', () => { + expect(mockEnvironments).not.toHaveBeenCalled(); }); }); }); - }); - describe('Props', () => { - const mockGroupCiVariables = mockGroupVariables.data.group.ciVariables; - const mockProjectCiVariables = mockProjectVariables.data.project.ciVariables; + describe('mutations', () => { + const groupProps = createGroupProps(); + const instanceProps = createInstanceProps(); + const projectProps = createProjectProps(); - describe('in a specific context as', () => { - it.each` - name | mockVariablesValue | mockEnvironmentsValue | withEnvironments | expectedEnvironments | propsFn | provideFn | mutation | maxVariableLimit - ${'project'} | ${mockProjectVariables} | ${mockProjectEnvironments} | ${true} | ${['prod', 'dev']} | ${createProjectProps} | ${createProjectProvide} | ${null} | ${mockProjectCiVariables.limit} - ${'group'} | ${mockGroupVariables} | ${[]} | ${false} | ${[]} | ${createGroupProps} | ${createGroupProvide} | ${getGroupVariables} | ${mockGroupCiVariables.limit} - ${'instance'} | ${mockAdminVariables} | ${[]} | ${false} | ${[]} | ${createInstanceProps} | ${() => {}} | ${getAdminVariables} | ${0} - `( - 'passes down all the required props when its a $name component', - async ({ - mutation, - maxVariableLimit, - mockVariablesValue, - mockEnvironmentsValue, - withEnvironments, - expectedEnvironments, - propsFn, - provideFn, - }) => { - const props = propsFn(); - const provide = provideFn(); + let mockMutationMap; - mockVariables.mockResolvedValue(mockVariablesValue); + describe('error handling and feedback', () => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); + mockMutation.mockResolvedValue({ ...mockGroupVariables.data, errors: [] }); + + await createComponentWithApollo({ + customHandlers: [[getGroupVariables, mockVariables]], + customResolvers: { + Mutation: { + ...resolvers.Mutation, + addGroupVariable: mockMutation, + updateGroupVariable: mockMutation, + deleteGroupVariable: mockMutation, + }, + }, + props: groupProps, + provide: pagesFeatureFlagProvide, + }); + }); - if (withEnvironments) { - mockEnvironments.mockResolvedValue(mockEnvironmentsValue); - } + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'throws the specific graphql error if present when user performs $actionName variable', + async ({ event }) => { + const graphQLErrorMessage = 'There is a problem with this graphQL action'; + mockMutation.mockResolvedValue({ + ...mockGroupVariables.data, + errors: [graphQLErrorMessage], + }); - let customHandlers = null; + await findCiSettings().vm.$emit(event, newVariable); + await waitForPromises(); - if (mutation) { - customHandlers = [[mutation, mockVariables]]; - } + expect(mockMutation).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage }); + }, + ); - await createComponentWithApollo({ customHandlers, props, provide }); + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'throws generic error on failure with no graphql errors and user performs $actionName variable', + async ({ event }) => { + mockMutation.mockRejectedValue(); + + await findCiSettings().vm.$emit(event, newVariable); + await waitForPromises(); + + expect(mockMutation).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText }); + }, + ); - expect(findCiSettings().props()).toEqual({ - areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable, - hideEnvironmentScope: defaultProps.hideEnvironmentScope, - isLoading: false, - maxVariableLimit, - variables: wrapper.props().queryData.ciVariables.lookup(mockVariablesValue.data)?.nodes, - entity: props.entity, - environments: expectedEnvironments, + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'displays toast message after user performs $actionName variable', + async ({ actionName, event }) => { + await findCiSettings().vm.$emit(event, newVariable); + await waitForPromises(); + + expect(mockMutation).toHaveBeenCalled(); + expect(mockToastShow).toHaveBeenCalledWith( + mapMutationActionToToast[actionName](newVariable.key), + ); + }, + ); + }); + + const setupMockMutations = (mockResolvedMutation) => { + mockAddMutation.mockResolvedValue(mockResolvedMutation); + mockUpdateMutation.mockResolvedValue(mockResolvedMutation); + mockDeleteMutation.mockResolvedValue(mockResolvedMutation); + + return { + add: mockAddMutation, + update: mockUpdateMutation, + delete: mockDeleteMutation, + }; + }; + + describe.each` + scope | mockVariablesResolvedValue | getVariablesHandler | addMutationName | updateMutationName | deleteMutationName | props + ${'instance'} | ${mockVariables} | ${getAdminVariables} | ${'addAdminVariable'} | ${'updateAdminVariable'} | ${'deleteAdminVariable'} | ${instanceProps} + ${'group'} | ${mockGroupVariables} | ${getGroupVariables} | ${'addGroupVariable'} | ${'updateGroupVariable'} | ${'deleteGroupVariable'} | ${groupProps} + ${'project'} | ${mockProjectVariables} | ${getProjectVariables} | ${'addProjectVariable'} | ${'updateProjectVariable'} | ${'deleteProjectVariable'} | ${projectProps} + `( + '$scope variable mutations', + ({ + addMutationName, + deleteMutationName, + getVariablesHandler, + mockVariablesResolvedValue, + updateMutationName, + props, + }) => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockVariablesResolvedValue); + mockMutationMap = setupMockMutations({ ...mockVariables.data, errors: [] }); + + await createComponentWithApollo({ + customHandlers: [[getVariablesHandler, mockVariables]], + customResolvers: { + Mutation: { + ...resolvers.Mutation, + [addMutationName]: mockAddMutation, + [updateMutationName]: mockUpdateMutation, + [deleteMutationName]: mockDeleteMutation, + }, + }, + props, + provide: pagesFeatureFlagProvide, + }); }); + + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'calls the right mutation when user performs $actionName variable', + async ({ event, actionName }) => { + await findCiSettings().vm.$emit(event, newVariable); + await waitForPromises(); + + expect(mockMutationMap[actionName]).toHaveBeenCalledWith( + expect.anything(), + { + endpoint: mockProvide.endpoint, + fullPath: props.fullPath, + id: props.id, + variable: newVariable, + }, + expect.anything(), + expect.anything(), + ); + }, + ); }, ); - }); - describe('refetchAfterMutation', () => { - it.each` - bool | text - ${true} | ${'refetches the variables'} - ${false} | ${'does not refetch the variables'} - `('when $bool it $text', async ({ bool }) => { - await createComponentWithApollo({ - props: { ...createInstanceProps(), refetchAfterMutation: bool }, + describe('without fullpath and ID props', () => { + beforeEach(async () => { + mockMutation.mockResolvedValue({ ...mockAdminVariables.data, errors: [] }); + mockVariables.mockResolvedValue(mockAdminVariables); + + await createComponentWithApollo({ + customHandlers: [[getAdminVariables, mockVariables]], + customResolvers: { + Mutation: { + ...resolvers.Mutation, + addAdminVariable: mockMutation, + }, + }, + props: createInstanceProps(), + provide: pagesFeatureFlagProvide, + }); }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: {} }); - jest.spyOn(wrapper.vm.$apollo.queries.ciVariables, 'refetch').mockImplementation(jest.fn()); + it('does not pass fullPath and ID to the mutation', async () => { + await findCiSettings().vm.$emit('add-variable', newVariable); + await waitForPromises(); + + expect(mockMutation).toHaveBeenCalledWith( + expect.anything(), + { + endpoint: mockProvide.endpoint, + variable: newVariable, + }, + expect.anything(), + expect.anything(), + ); + }); + }); + }); - await findCiSettings().vm.$emit('add-variable', newVariable); + describe('Props', () => { + const mockGroupCiVariables = mockGroupVariables.data.group.ciVariables; + const mockProjectCiVariables = mockProjectVariables.data.project.ciVariables; + + describe('in a specific context as', () => { + it.each` + name | mockVariablesValue | mockEnvironmentsValue | withEnvironments | expectedEnvironments | propsFn | provideFn | mutation | maxVariableLimit + ${'project'} | ${mockProjectVariables} | ${mockProjectEnvironments} | ${true} | ${['prod', 'dev']} | ${createProjectProps} | ${createProjectProvide} | ${null} | ${mockProjectCiVariables.limit} + ${'group'} | ${mockGroupVariables} | ${[]} | ${false} | ${[]} | ${createGroupProps} | ${createGroupProvide} | ${getGroupVariables} | ${mockGroupCiVariables.limit} + ${'instance'} | ${mockAdminVariables} | ${[]} | ${false} | ${[]} | ${createInstanceProps} | ${() => {}} | ${getAdminVariables} | ${0} + `( + 'passes down all the required props when its a $name component', + async ({ + mutation, + maxVariableLimit, + mockVariablesValue, + mockEnvironmentsValue, + withEnvironments, + expectedEnvironments, + propsFn, + provideFn, + }) => { + const props = propsFn(); + const provide = provideFn(); - await nextTick(); + mockVariables.mockResolvedValue(mockVariablesValue); - if (bool) { - expect(wrapper.vm.$apollo.queries.ciVariables.refetch).toHaveBeenCalled(); - } else { - expect(wrapper.vm.$apollo.queries.ciVariables.refetch).not.toHaveBeenCalled(); - } - }); - }); + if (withEnvironments) { + mockEnvironments.mockResolvedValue(mockEnvironmentsValue); + } - describe('Validators', () => { - describe('queryData', () => { - let error; + let customHandlers = null; - beforeEach(async () => { - mockVariables.mockResolvedValue(mockGroupVariables); - }); + if (mutation) { + customHandlers = [[mutation, mockVariables]]; + } - it('will mount component with right data', async () => { - try { await createComponentWithApollo({ - customHandlers: [[getGroupVariables, mockVariables]], - props: { ...createGroupProps() }, + customHandlers, + props, + provide: { ...provide, ...pagesFeatureFlagProvide }, }); - } catch (e) { - error = e; - } finally { - expect(wrapper.exists()).toBe(true); - expect(error).toBeUndefined(); - } - }); - it('will not mount component with wrong data', async () => { - try { - await createComponentWithApollo({ - customHandlers: [[getGroupVariables, mockVariables]], - props: { ...createGroupProps(), queryData: { wrongKey: {} } }, + expect(findCiSettings().props()).toEqual({ + areEnvironmentsLoading: false, + areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable, + hideEnvironmentScope: defaultProps.hideEnvironmentScope, + pageInfo: defaultProps.pageInfo, + isLoading: false, + maxVariableLimit, + variables: wrapper.props().queryData.ciVariables.lookup(mockVariablesValue.data) + ?.nodes, + entity: props.entity, + environments: expectedEnvironments, }); - } catch (e) { - error = e; - } finally { - expect(wrapper.exists()).toBe(false); - expect(error.toString()).toContain('custom validator check failed for prop'); - } - }); + }, + ); }); - describe('mutationData', () => { - let error; + describe('refetchAfterMutation', () => { + it.each` + bool | text | timesQueryCalled + ${true} | ${'refetches the variables'} | ${2} + ${false} | ${'does not refetch the variables'} | ${1} + `('when $bool it $text', async ({ bool, timesQueryCalled }) => { + mockMutation.mockResolvedValue({ ...mockAdminVariables.data, errors: [] }); + mockVariables.mockResolvedValue(mockAdminVariables); + + await createComponentWithApollo({ + customHandlers: [[getAdminVariables, mockVariables]], + customResolvers: { + Mutation: { + ...resolvers.Mutation, + addAdminVariable: mockMutation, + }, + }, + props: { ...createInstanceProps(), refetchAfterMutation: bool }, + provide: pagesFeatureFlagProvide, + }); + + await findCiSettings().vm.$emit('add-variable', newVariable); + await waitForPromises(); - beforeEach(async () => { - mockVariables.mockResolvedValue(mockGroupVariables); + expect(mockVariables).toHaveBeenCalledTimes(timesQueryCalled); }); + }); - it('will mount component with right data', async () => { - try { - await createComponentWithApollo({ - props: { ...createGroupProps() }, - }); - } catch (e) { - error = e; - } finally { - expect(wrapper.exists()).toBe(true); - expect(error).toBeUndefined(); - } + describe('Validators', () => { + describe('queryData', () => { + let error; + + beforeEach(() => { + mockVariables.mockResolvedValue(mockGroupVariables); + }); + + it('will mount component with right data', async () => { + try { + await createComponentWithApollo({ + customHandlers: [[getGroupVariables, mockVariables]], + props: { ...createGroupProps() }, + provide: pagesFeatureFlagProvide, + }); + } catch (e) { + error = e; + } finally { + expect(wrapper.exists()).toBe(true); + expect(error).toBeUndefined(); + } + }); + + it('report custom validator error on wrong data', () => { + expect(() => + assertProps( + ciVariableShared, + { ...defaultProps, ...createGroupProps(), queryData: { wrongKey: {} } }, + { provide: mockProvide }, + ), + ).toThrow('custom validator check failed for prop'); + }); }); - it('will not mount component with wrong data', async () => { - try { - await createComponentWithApollo({ - props: { ...createGroupProps(), mutationData: { wrongKey: {} } }, - }); - } catch (e) { - error = e; - } finally { - expect(wrapper.exists()).toBe(false); - expect(error.toString()).toContain('custom validator check failed for prop'); - } + describe('mutationData', () => { + let error; + + beforeEach(() => { + mockVariables.mockResolvedValue(mockGroupVariables); + }); + + it('will mount component with right data', async () => { + try { + await createComponentWithApollo({ + props: { ...createGroupProps() }, + provide: pagesFeatureFlagProvide, + }); + } catch (e) { + error = e; + } finally { + expect(wrapper.exists()).toBe(true); + expect(error).toBeUndefined(); + } + }); + + it('report custom validator error on wrong data', () => { + expect(() => + assertProps( + ciVariableShared, + { ...defaultProps, ...createGroupProps(), mutationData: { wrongKey: {} } }, + { provide: { ...mockProvide, ...pagesFeatureFlagProvide } }, + ), + ).toThrow('custom validator check failed for prop'); + }); }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js index 9e2508c56ee..0b28cb06cec 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js @@ -12,18 +12,25 @@ describe('Ci variable table', () => { entity: 'project', isLoading: false, maxVariableLimit: mockVariables(projectString).length + 1, + pageInfo: {}, variables: mockVariables(projectString), }; const mockMaxVariableLimit = defaultProps.variables.length; - const createComponent = ({ props = {} } = {}) => { + const createComponent = ({ props = {}, provide = {} } = {}) => { wrapper = mountExtended(CiVariableTable, { attachTo: document.body, propsData: { ...defaultProps, ...props, }, + provide: { + glFeatures: { + ciVariablesPages: false, + }, + ...provide, + }, }); }; @@ -41,132 +48,136 @@ describe('Ci variable table', () => { return sprintf(EXCEEDS_VARIABLE_LIMIT_TEXT, { entity, currentVariableCount, maxVariableLimit }); }; - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('When table is empty', () => { - beforeEach(() => { - createComponent({ props: { variables: [] } }); - }); + describe.each` + isVariablePagesEnabled | text + ${true} | ${'enabled'} + ${false} | ${'disabled'} + `('When Pages FF is $text', ({ isVariablePagesEnabled }) => { + const provide = isVariablePagesEnabled ? { glFeatures: { ciVariablesPages: true } } : {}; - it('displays empty message', () => { - expect(findEmptyVariablesPlaceholder().exists()).toBe(true); - }); - - it('hides the reveal button', () => { - expect(findRevealButton().exists()).toBe(false); - }); - }); + describe('When table is empty', () => { + beforeEach(() => { + createComponent({ props: { variables: [] }, provide }); + }); - describe('When table has variables', () => { - beforeEach(() => { - createComponent(); - }); + it('displays empty message', () => { + expect(findEmptyVariablesPlaceholder().exists()).toBe(true); + }); - it('does not display the empty message', () => { - expect(findEmptyVariablesPlaceholder().exists()).toBe(false); + it('hides the reveal button', () => { + expect(findRevealButton().exists()).toBe(false); + }); }); - it('displays the reveal button', () => { - expect(findRevealButton().exists()).toBe(true); - }); + describe('When table has variables', () => { + beforeEach(() => { + createComponent({ provide }); + }); - it('displays the correct amount of variables', async () => { - expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(defaultProps.variables.length); - }); + it('does not display the empty message', () => { + expect(findEmptyVariablesPlaceholder().exists()).toBe(false); + }); - it('displays the correct variable options', async () => { - expect(findOptionsValues(0)).toBe('Protected, Expanded'); - expect(findOptionsValues(1)).toBe('Masked'); - }); + it('displays the reveal button', () => { + expect(findRevealButton().exists()).toBe(true); + }); - it('enables the Add Variable button', () => { - expect(findAddButton().props('disabled')).toBe(false); - }); - }); + it('displays the correct amount of variables', () => { + expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(defaultProps.variables.length); + }); - describe('When variables have exceeded the max limit', () => { - beforeEach(() => { - createComponent({ props: { maxVariableLimit: mockVariables(projectString).length } }); - }); + it('displays the correct variable options', () => { + expect(findOptionsValues(0)).toBe('Protected, Expanded'); + expect(findOptionsValues(1)).toBe('Masked'); + }); - it('disables the Add Variable button', () => { - expect(findAddButton().props('disabled')).toBe(true); + it('enables the Add Variable button', () => { + expect(findAddButton().props('disabled')).toBe(false); + }); }); - }); - describe('max limit reached alert', () => { - describe('when there is no variable limit', () => { + describe('When variables have exceeded the max limit', () => { beforeEach(() => { createComponent({ - props: { maxVariableLimit: 0 }, + props: { maxVariableLimit: mockVariables(projectString).length }, + provide, }); }); - it('hides alert', () => { - expect(findLimitReachedAlerts().length).toBe(0); + it('disables the Add Variable button', () => { + expect(findAddButton().props('disabled')).toBe(true); }); }); - describe('when variable limit exists', () => { - it('hides alert when limit has not been reached', () => { - createComponent(); + describe('max limit reached alert', () => { + describe('when there is no variable limit', () => { + beforeEach(() => { + createComponent({ + props: { maxVariableLimit: 0 }, + provide, + }); + }); - expect(findLimitReachedAlerts().length).toBe(0); + it('hides alert', () => { + expect(findLimitReachedAlerts().length).toBe(0); + }); }); - it('shows alert when limit has been reached', () => { - const exceedsVariableLimitText = generateExceedsVariableLimitText( - defaultProps.entity, - defaultProps.variables.length, - mockMaxVariableLimit, - ); + describe('when variable limit exists', () => { + it('hides alert when limit has not been reached', () => { + createComponent({ provide }); - createComponent({ - props: { maxVariableLimit: mockMaxVariableLimit }, + expect(findLimitReachedAlerts().length).toBe(0); }); - expect(findLimitReachedAlerts().length).toBe(2); + it('shows alert when limit has been reached', () => { + const exceedsVariableLimitText = generateExceedsVariableLimitText( + defaultProps.entity, + defaultProps.variables.length, + mockMaxVariableLimit, + ); + + createComponent({ + props: { maxVariableLimit: mockMaxVariableLimit }, + }); - expect(findLimitReachedAlerts().at(0).props('dismissible')).toBe(false); - expect(findLimitReachedAlerts().at(0).text()).toContain(exceedsVariableLimitText); + expect(findLimitReachedAlerts().length).toBe(2); - expect(findLimitReachedAlerts().at(1).props('dismissible')).toBe(false); - expect(findLimitReachedAlerts().at(1).text()).toContain(exceedsVariableLimitText); + expect(findLimitReachedAlerts().at(0).props('dismissible')).toBe(false); + expect(findLimitReachedAlerts().at(0).text()).toContain(exceedsVariableLimitText); + + expect(findLimitReachedAlerts().at(1).props('dismissible')).toBe(false); + expect(findLimitReachedAlerts().at(1).text()).toContain(exceedsVariableLimitText); + }); }); }); - }); - describe('Table click actions', () => { - beforeEach(() => { - createComponent(); - }); + describe('Table click actions', () => { + beforeEach(() => { + createComponent({ provide }); + }); - it('reveals secret values when button is clicked', async () => { - expect(findHiddenValues()).toHaveLength(defaultProps.variables.length); - expect(findRevealedValues()).toHaveLength(0); + it('reveals secret values when button is clicked', async () => { + expect(findHiddenValues()).toHaveLength(defaultProps.variables.length); + expect(findRevealedValues()).toHaveLength(0); - await findRevealButton().trigger('click'); + await findRevealButton().trigger('click'); - expect(findHiddenValues()).toHaveLength(0); - expect(findRevealedValues()).toHaveLength(defaultProps.variables.length); - }); + expect(findHiddenValues()).toHaveLength(0); + expect(findRevealedValues()).toHaveLength(defaultProps.variables.length); + }); - it('dispatches `setSelectedVariable` with correct variable to edit', async () => { - await findEditButton().trigger('click'); + it('dispatches `setSelectedVariable` with correct variable to edit', async () => { + await findEditButton().trigger('click'); - expect(wrapper.emitted('set-selected-variable')).toEqual([[defaultProps.variables[0]]]); - }); + expect(wrapper.emitted('set-selected-variable')).toEqual([[defaultProps.variables[0]]]); + }); - it('dispatches `setSelectedVariable` with no variable when adding a new one', async () => { - await findAddButton().trigger('click'); + it('dispatches `setSelectedVariable` with no variable when adding a new one', async () => { + await findAddButton().trigger('click'); - expect(wrapper.emitted('set-selected-variable')).toEqual([[null]]); + expect(wrapper.emitted('set-selected-variable')).toEqual([[null]]); + }); }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js index 4da4f53f69f..f9450803308 100644 --- a/spec/frontend/ci/ci_variable_list/mocks.js +++ b/spec/frontend/ci/ci_variable_list/mocks.js @@ -56,6 +56,11 @@ export const mockVariablesWithScopes = (kind) => return { ...variable, environmentScope: '*' }; }); +export const mockVariablesWithUniqueScopes = (kind) => + mockVariables(kind).map((variable) => { + return { ...variable, environmentScope: variable.value }; + }); + const createDefaultVars = ({ withScope = true, kind } = {}) => { let base = mockVariables(kind); diff --git a/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js b/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js index b00e1adab63..48a85eba433 100644 --- a/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js @@ -41,10 +41,6 @@ describe('EE - CodeSnippetAlert', () => { createWrapper(); }); - afterEach(() => { - wrapper.destroy(); - }); - it("provides a link to the feature's documentation", () => { const docsLink = findDocsLink(); diff --git a/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js index 8e1d8081dd8..4b0ddacef93 100644 --- a/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js @@ -33,12 +33,8 @@ describe('Pipeline Editor | Commit Form', () => { const findSubmitBtn = () => wrapper.find('[type="submit"]'); const findCancelBtn = () => wrapper.find('[type="reset"]'); - afterEach(() => { - wrapper.destroy(); - }); - describe('when the form is displayed', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); }); @@ -61,7 +57,7 @@ describe('Pipeline Editor | Commit Form', () => { }); describe('when buttons are clicked', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({}, mount); }); @@ -97,7 +93,7 @@ describe('Pipeline Editor | Commit Form', () => { createComponent({ props: { hasUnsavedChanges, isNewCiConfigFile } }); if (isDisabled) { - expect(findSubmitBtn().attributes('disabled')).toBe('true'); + expect(findSubmitBtn().attributes('disabled')).toBeDefined(); } else { expect(findSubmitBtn().attributes('disabled')).toBeUndefined(); } @@ -136,7 +132,7 @@ describe('Pipeline Editor | Commit Form', () => { it('when the commit message is empty, submit button is disabled', async () => { await findCommitTextarea().setValue(''); - expect(findSubmitBtn().attributes('disabled')).toBe('disabled'); + expect(findSubmitBtn().attributes('disabled')).toBeDefined(); }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js index f6e93c55bbb..8834231aaef 100644 --- a/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js @@ -4,6 +4,7 @@ import { mount } from '@vue/test-utils'; import Vue from 'vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { mockTracking } from 'helpers/tracking_helper'; import CommitForm from '~/ci/pipeline_editor/components/commit/commit_form.vue'; import CommitSection from '~/ci/pipeline_editor/components/commit/commit_section.vue'; import { @@ -11,12 +12,12 @@ import { COMMIT_ACTION_UPDATE, COMMIT_SUCCESS, COMMIT_SUCCESS_WITH_REDIRECT, + pipelineEditorTrackingOptions, } from '~/ci/pipeline_editor/constants'; import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import commitCreate from '~/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql'; import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; import updatePipelineEtag from '~/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql'; - import { mockCiConfigPath, mockCiYml, @@ -113,10 +114,6 @@ describe('Pipeline Editor | Commit section', () => { await waitForPromises(); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('when the user commits a new file', () => { beforeEach(async () => { mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse); @@ -284,4 +281,43 @@ describe('Pipeline Editor | Commit section', () => { createComponent({ props: { 'scroll-to-commit-form': true } }); expect(findCommitForm().props('scrollToCommitForm')).toBe(true); }); + + describe('tracking', () => { + let trackingSpy; + const { actions, label } = pipelineEditorTrackingOptions; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + describe('when user commit a new file', () => { + beforeEach(async () => { + mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse); + createComponentWithApollo({ props: { isNewCiConfigFile: true } }); + await submitCommit(); + }); + + it('calls tracking event with the CREATE property', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, actions.commitCiConfig, { + label, + property: COMMIT_ACTION_CREATE, + }); + }); + }); + + describe('when user commit an update to the CI file', () => { + beforeEach(async () => { + mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse); + createComponentWithApollo({ props: { isNewCiConfigFile: false } }); + await submitCommit(); + }); + + it('calls the tracking event with the UPDATE property', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, actions.commitCiConfig, { + label, + property: COMMIT_ACTION_UPDATE, + }); + }); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js index 137137ec657..0ecb77674d5 100644 --- a/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js @@ -21,10 +21,6 @@ describe('First pipeline card', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('renders the title', () => { expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); }); diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js index cdce757ce7c..417597eaf1f 100644 --- a/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js @@ -12,10 +12,6 @@ describe('Getting started card', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('renders the title', () => { expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); }); diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js index 6909916c3e6..0296ab5a65c 100644 --- a/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js @@ -33,10 +33,6 @@ describe('Pipeline config reference card', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('renders the title', () => { expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); }); @@ -72,7 +68,7 @@ describe('Pipeline config reference card', () => { }); }; - it('tracks help page links', async () => { + it('tracks help page links', () => { const { CI_EXAMPLES_LINK, CI_HELP_LINK, diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js index 0c6879020de..547ba3cbd8b 100644 --- a/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js @@ -12,10 +12,6 @@ describe('Visual and Lint card', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('renders the title', () => { expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title); }); diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js index 42e372cc1db..b07d63dd5d9 100644 --- a/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js @@ -11,10 +11,6 @@ describe('Pipeline editor drawer', () => { wrapper = shallowMount(PipelineEditorDrawer); }; - afterEach(() => { - wrapper.destroy(); - }); - it('emits close event when closing the drawer', () => { createComponent(); diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js index f510c61ee74..b0c889cfc9f 100644 --- a/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js @@ -17,10 +17,6 @@ describe('Demo job pill', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('renders the jobName', () => { expect(wrapper.text()).toContain(jobName); }); diff --git a/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js index 2a2bc2547cc..2182b6e9cc6 100644 --- a/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js @@ -34,10 +34,6 @@ describe('Text editor component', () => { const findIcon = () => wrapper.findComponent(GlIcon); const findEditor = () => wrapper.findComponent(MockSourceEditor); - afterEach(() => { - wrapper.destroy(); - }); - describe('when status is valid', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js index dc72694d26f..f1a5c4169fb 100644 --- a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js @@ -11,12 +11,25 @@ describe('CI Editor Header', () => { let wrapper; let trackingSpy = null; - const createComponent = ({ showDrawer = false, showJobAssistantDrawer = false } = {}) => { + const createComponent = ({ + showDrawer = false, + showJobAssistantDrawer = false, + showAiAssistantDrawer = false, + aiChatAvailable = false, + aiCiConfigGenerator = false, + } = {}) => { wrapper = extendedWrapper( shallowMount(CiEditorHeader, { + provide: { + aiChatAvailable, + glFeatures: { + aiCiConfigGenerator, + }, + }, propsData: { showDrawer, showJobAssistantDrawer, + showAiAssistantDrawer, }, }), ); @@ -24,9 +37,9 @@ describe('CI Editor Header', () => { const findLinkBtn = () => wrapper.findByTestId('template-repo-link'); const findHelpBtn = () => wrapper.findByTestId('drawer-toggle'); + const findAiAssistnantBtn = () => wrapper.findByTestId('ai-assistant-drawer-toggle'); afterEach(() => { - wrapper.destroy(); unmockTracking(); }); @@ -40,7 +53,29 @@ describe('CI Editor Header', () => { label, }); }; + describe('Ai Assistant toggle button', () => { + describe('when feature is unavailable', () => { + it('should not show ai button when feature toggle is off', () => { + createComponent({ aiChatAvailable: true }); + mockTracking(undefined, wrapper.element, jest.spyOn); + expect(findAiAssistnantBtn().exists()).toBe(false); + }); + + it('should not show ai button when feature is unavailable', () => { + createComponent({ aiCiConfigGenerator: true }); + mockTracking(undefined, wrapper.element, jest.spyOn); + expect(findAiAssistnantBtn().exists()).toBe(false); + }); + }); + describe('when feature is available', () => { + it('should show ai button', () => { + createComponent({ aiCiConfigGenerator: true, aiChatAvailable: true }); + mockTracking(undefined, wrapper.element, jest.spyOn); + expect(findAiAssistnantBtn().exists()).toBe(true); + }); + }); + }); describe('link button', () => { beforeEach(() => { createComponent(); @@ -59,7 +94,7 @@ describe('CI Editor Header', () => { expect(findLinkBtn().props('icon')).toBe('external-link'); }); - it('tracks the click on the browse button', async () => { + it('tracks the click on the browse button', () => { const { browseTemplates } = pipelineEditorTrackingOptions.actions; testTracker(findLinkBtn(), browseTemplates); @@ -92,7 +127,7 @@ describe('CI Editor Header', () => { expect(wrapper.emitted('open-drawer')).toHaveLength(1); }); - it('tracks open help drawer action', async () => { + it('tracks open help drawer action', () => { const { actions } = pipelineEditorTrackingOptions; testTracker(findHelpBtn(), actions.openHelpDrawer); diff --git a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js index ec987be8cb8..0be26570fbf 100644 --- a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js @@ -1,7 +1,11 @@ import { shallowMount } from '@vue/test-utils'; +import { editor as monacoEditor } from 'monaco-editor'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; import { EDITOR_READY_EVENT } from '~/editor/constants'; +import { CiSchemaExtension as MockedCiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; import { SOURCE_EDITOR_DEBOUNCE } from '~/ci/pipeline_editor/constants'; +import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub'; import TextEditor from '~/ci/pipeline_editor/components/editor/text_editor.vue'; import { mockCiConfigPath, @@ -12,19 +16,26 @@ import { mockDefaultBranch, } from '../../mock_data'; +jest.mock('monaco-editor'); +jest.mock('~/editor/extensions/source_editor_ci_schema_ext', () => { + const { createMockSourceEditorExtension } = jest.requireActual( + 'helpers/create_mock_source_editor_extension', + ); + const { CiSchemaExtension } = jest.requireActual( + '~/editor/extensions/source_editor_ci_schema_ext', + ); + + return { + CiSchemaExtension: createMockSourceEditorExtension(CiSchemaExtension), + }; +}); + describe('Pipeline Editor | Text editor component', () => { let wrapper; let editorReadyListener; - let mockUse; - let mockRegisterCiSchema; - let mockEditorInstance; - let editorInstanceDetail; - - const MockSourceEditor = { - template: '<div/>', - props: ['value', 'fileName', 'editorOptions', 'debounceValue'], - }; + + const getMonacoEditor = () => monacoEditor.create.mock.results[0].value; const createComponent = (mountFn = shallowMount) => { wrapper = mountFn(TextEditor, { @@ -44,33 +55,17 @@ describe('Pipeline Editor | Text editor component', () => { [EDITOR_READY_EVENT]: editorReadyListener, }, stubs: { - SourceEditor: MockSourceEditor, + SourceEditor, }, }); }; - const findEditor = () => wrapper.findComponent(MockSourceEditor); + const findEditor = () => wrapper.findComponent(SourceEditor); beforeEach(() => { - editorReadyListener = jest.fn(); - mockUse = jest.fn(); - mockRegisterCiSchema = jest.fn(); - mockEditorInstance = { - use: mockUse, - registerCiSchema: mockRegisterCiSchema, - }; - editorInstanceDetail = { - detail: { - instance: mockEditorInstance, - }, - }; - }); + jest.spyOn(monacoEditor, 'create'); - afterEach(() => { - wrapper.destroy(); - - mockUse.mockClear(); - mockRegisterCiSchema.mockClear(); + editorReadyListener = jest.fn(); }); describe('template', () => { @@ -99,21 +94,34 @@ describe('Pipeline Editor | Text editor component', () => { }); it('bubbles up events', () => { - findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); - expect(editorReadyListener).toHaveBeenCalled(); }); + + it('scrolls editor to bottom on scroll editor to bottom event', () => { + const setScrollTop = jest.spyOn(getMonacoEditor(), 'setScrollTop'); + + eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM); + + expect(setScrollTop).toHaveBeenCalledWith(getMonacoEditor().getScrollHeight()); + }); + + it('when destroyed, destroys scroll listener', () => { + const setScrollTop = jest.spyOn(getMonacoEditor(), 'setScrollTop'); + + wrapper.destroy(); + eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM); + + expect(setScrollTop).not.toHaveBeenCalled(); + }); }); describe('CI schema', () => { beforeEach(() => { createComponent(); - findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); }); it('configures editor with syntax highlight', () => { - expect(mockUse).toHaveBeenCalledTimes(1); - expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1); + expect(MockedCiSchemaExtension.mockedMethods.registerCiSchema).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js index a26232df58f..3a99949413b 100644 --- a/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -133,10 +133,6 @@ describe('Pipeline editor branch switcher', () => { mockAvailableBranchQuery = jest.fn(); }); - afterEach(() => { - wrapper.destroy(); - }); - const testErrorHandling = () => { expect(wrapper.emitted('showError')).toBeDefined(); expect(wrapper.emitted('showError')[0]).toEqual([ @@ -292,7 +288,7 @@ describe('Pipeline editor branch switcher', () => { }); describe('with a search term', () => { - beforeEach(async () => { + beforeEach(() => { mockAvailableBranchQuery.mockResolvedValue(mockSearchBranches); }); diff --git a/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js index 907db16913c..19c113689c2 100644 --- a/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js @@ -48,10 +48,6 @@ describe('Pipeline editor file nav', () => { const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle'); const findPopoverContainer = () => wrapper.findComponent(FileTreePopover); - afterEach(() => { - wrapper.destroy(); - }); - describe('template', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js b/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js index 11ba517e0eb..f2effcb2966 100644 --- a/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js @@ -22,7 +22,7 @@ describe('Pipeline editor file nav', () => { includes, }, directives: { - GlTooltip: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), }, stubs, }), @@ -35,7 +35,6 @@ describe('Pipeline editor file nav', () => { afterEach(() => { localStorage.clear(); - wrapper.destroy(); }); describe('template', () => { @@ -61,11 +60,11 @@ describe('Pipeline editor file nav', () => { expect(fileTreeItems().exists()).toBe(false); }); - it('renders alert tip', async () => { + it('renders alert tip', () => { expect(findTip().exists()).toBe(true); }); - it('renders learn more link', async () => { + it('renders learn more link', () => { expect(findTip().props('secondaryButtonLink')).toBe(mockIncludesHelpPagePath); }); @@ -88,7 +87,7 @@ describe('Pipeline editor file nav', () => { }); }); - it('does not render alert tip', async () => { + it('does not render alert tip', () => { expect(findTip().exists()).toBe(false); }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js b/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js index bceb741f91c..80737e9a8ab 100644 --- a/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js @@ -18,10 +18,6 @@ describe('Pipeline editor file nav', () => { const fileIcon = () => wrapper.findComponent(FileIcon); const link = () => wrapper.findComponent(GlLink); - afterEach(() => { - wrapper.destroy(); - }); - describe('template', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js index 555b9f29fbf..a651664851e 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js @@ -26,11 +26,6 @@ describe('Pipeline editor header', () => { const findPipelineStatus = () => wrapper.findComponent(PipelineStatus); const findValidationSegment = () => wrapper.findComponent(ValidationSegment); - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - describe('template', () => { it('hides the pipeline status for new projects without a CI file', () => { createComponent({ props: { isNewCiConfigFile: true } }); diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js index 7bf955012c7..b8526e569ec 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js @@ -96,7 +96,7 @@ describe('Pipeline Status', () => { await waitForPromises(); }); - it('should emit an error event when query fails', async () => { + it('should emit an error event when query fails', () => { expect(wrapper.emitted('showError')).toHaveLength(1); expect(wrapper.emitted('showError')[0]).toEqual([ { 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 a62c51ffb59..8ca88472bf1 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 @@ -48,7 +48,6 @@ describe('Pipeline Status', () => { afterEach(() => { mockPipelineQuery.mockReset(); - wrapper.destroy(); }); describe('loading icon', () => { @@ -78,7 +77,7 @@ describe('Pipeline Status', () => { await waitForPromises(); }); - it('query is called with correct variables', async () => { + it('query is called with correct variables', () => { expect(mockPipelineQuery).toHaveBeenCalledTimes(1); expect(mockPipelineQuery).toHaveBeenCalledWith({ fullPath: mockProjectFullPath, diff --git a/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js b/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js index 0853a6f4ca4..a107a626c6d 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js @@ -1,11 +1,10 @@ import VueApollo from 'vue-apollo'; -import { GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import Vue from 'vue'; import { escape } from 'lodash'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; import { sprintf } from '~/locale'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; import ValidationSegment, { i18n, } from '~/ci/pipeline_editor/components/header/validation_segment.vue'; @@ -20,8 +19,8 @@ import { } from '~/ci/pipeline_editor/constants'; import { mergeUnwrappedCiConfig, + mockCiTroubleshootingPath, mockCiYml, - mockLintUnavailableHelpPagePath, mockYmlHelpPagePath, } from '../../mock_data'; @@ -43,29 +42,27 @@ describe('Validation segment component', () => { }, }); - wrapper = extendedWrapper( - shallowMount(ValidationSegment, { - apolloProvider: mockApollo, - provide: { - ymlHelpPagePath: mockYmlHelpPagePath, - lintUnavailableHelpPagePath: mockLintUnavailableHelpPagePath, - }, - propsData: { - ciConfig: mergeUnwrappedCiConfig(), - ciFileContent: mockCiYml, - ...props, - }, - }), - ); + wrapper = shallowMountExtended(ValidationSegment, { + apolloProvider: mockApollo, + provide: { + ymlHelpPagePath: mockYmlHelpPagePath, + ciTroubleshootingPath: mockCiTroubleshootingPath, + }, + propsData: { + ciConfig: mergeUnwrappedCiConfig(), + ciFileContent: mockCiYml, + ...props, + }, + stubs: { + GlSprintf, + }, + }); }; const findIcon = () => wrapper.findComponent(GlIcon); - const findLearnMoreLink = () => wrapper.findByTestId('learnMoreLink'); - const findValidationMsg = () => wrapper.findByTestId('validationMsg'); - - afterEach(() => { - wrapper.destroy(); - }); + const findHelpLink = () => wrapper.findComponent(GlLink); + const findValidationMsg = () => wrapper.findComponent(GlSprintf); + const findValidationSegment = () => wrapper.findByTestId('validation-segment'); it('shows the loading state', () => { createComponent({ appStatus: EDITOR_APP_STATUS_LOADING }); @@ -82,8 +79,12 @@ describe('Validation segment component', () => { expect(findIcon().props('name')).toBe('check'); }); + it('does not render a link', () => { + expect(findHelpLink().exists()).toBe(false); + }); + it('shows a message for empty state', () => { - expect(findValidationMsg().text()).toBe(i18n.empty); + expect(findValidationSegment().text()).toBe(i18n.empty); }); }); @@ -97,12 +98,15 @@ describe('Validation segment component', () => { }); it('shows a message for valid state', () => { - expect(findValidationMsg().text()).toContain(i18n.valid); + expect(findValidationSegment().text()).toBe( + sprintf(i18n.valid, { linkStart: '', linkEnd: '' }), + ); }); it('shows the learn more link', () => { - expect(findLearnMoreLink().attributes('href')).toBe(mockYmlHelpPagePath); - expect(findLearnMoreLink().text()).toBe(i18n.learnMore); + expect(findValidationMsg().exists()).toBe(true); + expect(findValidationMsg().text()).toBe('Learn more'); + expect(findHelpLink().attributes('href')).toBe(mockYmlHelpPagePath); }); }); @@ -117,13 +121,16 @@ describe('Validation segment component', () => { expect(findIcon().props('name')).toBe('warning-solid'); }); - it('has message for invalid state', () => { - expect(findValidationMsg().text()).toBe(i18n.invalid); + it('shows a message for invalid state', () => { + expect(findValidationSegment().text()).toBe( + sprintf(i18n.invalid, { linkStart: '', linkEnd: '' }), + ); }); it('shows the learn more link', () => { - expect(findLearnMoreLink().attributes('href')).toBe(mockYmlHelpPagePath); - expect(findLearnMoreLink().text()).toBe('Learn more'); + expect(findValidationMsg().exists()).toBe(true); + expect(findValidationMsg().text()).toBe('Learn more'); + expect(findHelpLink().attributes('href')).toBe(mockYmlHelpPagePath); }); describe('with multiple errors', () => { @@ -140,11 +147,16 @@ describe('Validation segment component', () => { }, }); }); + + it('shows the learn more link', () => { + expect(findValidationMsg().exists()).toBe(true); + expect(findValidationMsg().text()).toBe('Learn more'); + expect(findHelpLink().attributes('href')).toBe(mockYmlHelpPagePath); + }); + it('shows an invalid state with an error', () => { - // Test the error is shown _and_ the string matches - expect(findValidationMsg().text()).toContain(firstError); - expect(findValidationMsg().text()).toBe( - sprintf(i18n.invalidWithReason, { reason: firstError }), + expect(findValidationSegment().text()).toBe( + sprintf(i18n.invalidWithReason, { reason: firstError, linkStart: '', linkEnd: '' }), ); }); }); @@ -163,10 +175,8 @@ describe('Validation segment component', () => { }); }); it('shows an invalid state with an error while preventing XSS', () => { - const { innerHTML } = findValidationMsg().element; - - expect(innerHTML).not.toContain(evilError); - expect(innerHTML).toContain(escape(evilError)); + expect(findValidationSegment().html()).not.toContain(evilError); + expect(findValidationSegment().html()).toContain(escape(evilError)); }); }); }); @@ -182,16 +192,18 @@ describe('Validation segment component', () => { }); it('show a message that the service is unavailable', () => { - expect(findValidationMsg().text()).toBe(i18n.unavailableValidation); + expect(findValidationSegment().text()).toBe( + sprintf(i18n.unavailableValidation, { linkStart: '', linkEnd: '' }), + ); }); it('shows the time-out icon', () => { expect(findIcon().props('name')).toBe('time-out'); }); - it('shows the learn more link', () => { - expect(findLearnMoreLink().attributes('href')).toBe(mockLintUnavailableHelpPagePath); - expect(findLearnMoreLink().text()).toBe(i18n.learnMore); + it('shows the link to ci troubleshooting', () => { + expect(findValidationMsg().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe(mockCiTroubleshootingPath); }); }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js new file mode 100644 index 00000000000..9046be4a45e --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js @@ -0,0 +1,127 @@ +import ArtifactsAndCacheItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; + +describe('Artifacts and cache item', () => { + let wrapper; + + const findArtifactsPathsInputByIndex = (index) => + wrapper.findByTestId(`artifacts-paths-input-${index}`); + const findArtifactsExcludeInputByIndex = (index) => + wrapper.findByTestId(`artifacts-exclude-input-${index}`); + const findCachePathsInputByIndex = (index) => wrapper.findByTestId(`cache-paths-input-${index}`); + const findCacheKeyInput = () => wrapper.findByTestId('cache-key-input'); + const findDeleteArtifactsPathsButtonByIndex = (index) => + wrapper.findByTestId(`delete-artifacts-paths-button-${index}`); + const findDeleteArtifactsExcludeButtonByIndex = (index) => + wrapper.findByTestId(`delete-artifacts-exclude-button-${index}`); + const findDeleteCachePathsButtonByIndex = (index) => + wrapper.findByTestId(`delete-cache-paths-button-${index}`); + const findAddArtifactsPathsButton = () => wrapper.findByTestId('add-artifacts-paths-button'); + const findAddArtifactsExcludeButton = () => wrapper.findByTestId('add-artifacts-exclude-button'); + const findAddCachePathsButton = () => wrapper.findByTestId('add-cache-paths-button'); + + const dummyArtifactsPath = 'dummyArtifactsPath'; + const dummyArtifactsExclude = 'dummyArtifactsExclude'; + const dummyCachePath = 'dummyCachePath'; + const dummyCacheKey = 'dummyCacheKey'; + + const createComponent = ({ job = JSON.parse(JSON.stringify(JOB_TEMPLATE)) } = {}) => { + wrapper = shallowMountExtended(ArtifactsAndCacheItem, { + propsData: { + job, + }, + }); + }; + + it('should emit update job event when filling inputs', () => { + createComponent(); + + expect(wrapper.emitted('update-job')).toBeUndefined(); + + findArtifactsPathsInputByIndex(0).vm.$emit('input', dummyArtifactsPath); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toStrictEqual([ + 'artifacts.paths[0]', + dummyArtifactsPath, + ]); + + findArtifactsExcludeInputByIndex(0).vm.$emit('input', dummyArtifactsExclude); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toStrictEqual([ + 'artifacts.exclude[0]', + dummyArtifactsExclude, + ]); + + findCachePathsInputByIndex(0).vm.$emit('input', dummyCachePath); + + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toStrictEqual(['cache.paths[0]', dummyCachePath]); + + findCacheKeyInput().vm.$emit('input', dummyCacheKey); + + expect(wrapper.emitted('update-job')).toHaveLength(4); + expect(wrapper.emitted('update-job')[3]).toStrictEqual(['cache.key', dummyCacheKey]); + }); + + it('should emit update job event when click add item button', () => { + createComponent(); + + findAddArtifactsPathsButton().vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toStrictEqual(['artifacts.paths[1]', '']); + + findAddArtifactsExcludeButton().vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toStrictEqual(['artifacts.exclude[1]', '']); + + findAddCachePathsButton().vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toStrictEqual(['cache.paths[1]', '']); + }); + + it('should emit update job event when click delete item button', () => { + createComponent({ + job: { + artifacts: { + paths: ['0', '1'], + exclude: ['0', '1'], + }, + cache: { + paths: ['0', '1'], + key: '', + }, + }, + }); + + findDeleteArtifactsPathsButtonByIndex(0).vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toStrictEqual(['artifacts.paths[0]']); + + findDeleteArtifactsExcludeButtonByIndex(0).vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toStrictEqual(['artifacts.exclude[0]']); + + findDeleteCachePathsButtonByIndex(0).vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toStrictEqual(['cache.paths[0]']); + }); + + it('should not emit update job event when click the only one delete item button', () => { + createComponent(); + + findDeleteArtifactsPathsButtonByIndex(0).vm.$emit('click'); + findDeleteArtifactsExcludeButtonByIndex(0).vm.$emit('click'); + findDeleteCachePathsButtonByIndex(0).vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toBeUndefined(); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js new file mode 100644 index 00000000000..f99d7277612 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js @@ -0,0 +1,39 @@ +import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; + +describe('Image item', () => { + let wrapper; + + const findImageNameInput = () => wrapper.findByTestId('image-name-input'); + const findImageEntrypointInput = () => wrapper.findByTestId('image-entrypoint-input'); + + const dummyImageName = 'a'; + const dummyImageEntrypoint = ['b', 'c']; + + const createComponent = ({ job = JSON.parse(JSON.stringify(JOB_TEMPLATE)) } = {}) => { + wrapper = shallowMountExtended(ImageItem, { + propsData: { + job, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should emit update job event when filling inputs', () => { + expect(wrapper.emitted('update-job')).toBeUndefined(); + + findImageNameInput().vm.$emit('input', dummyImageName); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toEqual(['image.name', dummyImageName]); + + findImageEntrypointInput().vm.$emit('input', dummyImageEntrypoint.join('\n')); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toEqual(['image.entrypoint', dummyImageEntrypoint]); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js new file mode 100644 index 00000000000..373fb1b70c7 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js @@ -0,0 +1,60 @@ +import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; + +describe('Job setup item', () => { + let wrapper; + + const findJobNameInput = () => wrapper.findByTestId('job-name-input'); + const findJobScriptInput = () => wrapper.findByTestId('job-script-input'); + const findJobTagsInput = () => wrapper.findByTestId('job-tags-input'); + const findJobStageInput = () => wrapper.findByTestId('job-stage-input'); + + const dummyJobName = 'dummyJobName'; + const dummyJobScript = 'dummyJobScript'; + const dummyJobStage = 'dummyJobStage'; + const dummyJobTags = ['tag1']; + + const createComponent = () => { + wrapper = shallowMountExtended(JobSetupItem, { + propsData: { + availableStages: ['.pre', dummyJobStage, '.post'], + tagOptions: [ + { id: 'tag1', name: 'tag1' }, + { id: 'tag2', name: 'tag2' }, + ], + isNameValid: true, + isScriptValid: true, + job: JSON.parse(JSON.stringify(JOB_TEMPLATE)), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should emit update job event when filling inputs', () => { + expect(wrapper.emitted('update-job')).toBeUndefined(); + + findJobNameInput().vm.$emit('input', dummyJobName); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toEqual(['name', dummyJobName]); + + findJobScriptInput().vm.$emit('input', dummyJobScript); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toEqual(['script', dummyJobScript]); + + findJobStageInput().vm.$emit('input', dummyJobStage); + + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toEqual(['stage', dummyJobStage]); + + findJobTagsInput().vm.$emit('input', dummyJobTags); + + expect(wrapper.emitted('update-job')).toHaveLength(4); + expect(wrapper.emitted('update-job')[3]).toEqual(['tags', dummyJobTags]); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js new file mode 100644 index 00000000000..659ccb25996 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js @@ -0,0 +1,70 @@ +import RulesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + JOB_TEMPLATE, + JOB_RULES_WHEN, + JOB_RULES_START_IN, +} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; + +describe('Rules item', () => { + let wrapper; + + const findRulesWhenSelect = () => wrapper.findByTestId('rules-when-select'); + const findRulesStartInNumberInput = () => wrapper.findByTestId('rules-start-in-number-input'); + const findRulesStartInUnitSelect = () => wrapper.findByTestId('rules-start-in-unit-select'); + const findRulesAllowFailureCheckBox = () => wrapper.findByTestId('rules-allow-failure-checkbox'); + + const dummyRulesWhen = JOB_RULES_WHEN.delayed.value; + const dummyRulesStartInNumber = 2; + const dummyRulesStartInUnit = JOB_RULES_START_IN.week.value; + const dummyRulesAllowFailure = true; + + const createComponent = () => { + wrapper = shallowMountExtended(RulesItem, { + propsData: { + isStartValid: true, + job: JSON.parse(JSON.stringify(JOB_TEMPLATE)), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should emit update job event when filling inputs', () => { + expect(wrapper.emitted('update-job')).toBeUndefined(); + + findRulesWhenSelect().vm.$emit('input', dummyRulesWhen); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toEqual([ + 'rules[0].when', + JOB_RULES_WHEN.delayed.value, + ]); + + findRulesStartInNumberInput().vm.$emit('input', dummyRulesStartInNumber); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toEqual([ + 'rules[0].start_in', + `2 ${JOB_RULES_START_IN.second.value}s`, + ]); + + findRulesStartInUnitSelect().vm.$emit('input', dummyRulesStartInUnit); + + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toEqual([ + 'rules[0].start_in', + `2 ${dummyRulesStartInUnit}s`, + ]); + + findRulesAllowFailureCheckBox().vm.$emit('input', dummyRulesAllowFailure); + + expect(wrapper.emitted('update-job')).toHaveLength(4); + expect(wrapper.emitted('update-job')[3]).toEqual([ + 'rules[0].allow_failure', + dummyRulesAllowFailure, + ]); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js new file mode 100644 index 00000000000..284d639c77f --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js @@ -0,0 +1,79 @@ +import ServicesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; + +describe('Services item', () => { + let wrapper; + + const findServiceNameInputByIndex = (index) => + wrapper.findByTestId(`service-name-input-${index}`); + const findServiceEntrypointInputByIndex = (index) => + wrapper.findByTestId(`service-entrypoint-input-${index}`); + const findDeleteItemButtonByIndex = (index) => + wrapper.findByTestId(`delete-job-service-button-${index}`); + const findAddItemButton = () => wrapper.findByTestId('add-job-service-button'); + + const dummyServiceName = 'a'; + const dummyServiceEntrypoint = ['b', 'c']; + + const createComponent = ({ job = JSON.parse(JSON.stringify(JOB_TEMPLATE)) } = {}) => { + wrapper = shallowMountExtended(ServicesItem, { + propsData: { + job, + }, + }); + }; + + it('should emit update job event when filling inputs', () => { + createComponent(); + + expect(wrapper.emitted('update-job')).toBeUndefined(); + + findServiceNameInputByIndex(0).vm.$emit('input', dummyServiceName); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toEqual(['services[0].name', dummyServiceName]); + + findServiceEntrypointInputByIndex(0).vm.$emit('input', dummyServiceEntrypoint.join('\n')); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toEqual([ + 'services[0].entrypoint', + dummyServiceEntrypoint, + ]); + }); + + it('should emit update job event when click add item button', () => { + createComponent(); + + findAddItemButton().vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toEqual([ + 'services[1]', + { name: '', entrypoint: [''] }, + ]); + }); + + it('should emit update job event when click delete item button', () => { + createComponent({ + job: { + services: [ + { name: 'a', entrypoint: ['a'] }, + { name: 'b', entrypoint: ['b'] }, + ], + }, + }); + + findDeleteItemButtonByIndex(0).vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toEqual(['services[0]']); + }); + + it('should not show delete item button when there is only one service', () => { + createComponent(); + + expect(findDeleteItemButtonByIndex(0).exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js index 79200d92598..0258a1a8c7f 100644 --- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js @@ -1,24 +1,64 @@ import { GlDrawer } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; +import { stringify } from 'yaml'; import JobAssistantDrawer from '~/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue'; +import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue'; +import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue'; +import ServicesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue'; +import ArtifactsAndCacheItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue'; +import RulesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue'; +import { JOB_RULES_WHEN } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; +import getRunnerTags from '~/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; + import { mountExtended } from 'helpers/vue_test_utils_helper'; +import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub'; +import { mockRunnersTagsQueryResponse, mockLintResponse, mockCiYml } from '../../mock_data'; Vue.use(VueApollo); describe('Job assistant drawer', () => { let wrapper; + let mockApollo; + + const dummyJobName = 'dummyJobName'; + const dummyJobScript = 'dummyJobScript'; + const dummyImageName = 'dummyImageName'; + const dummyImageEntrypoint = 'dummyImageEntrypoint'; + const dummyServicesName = 'dummyServicesName'; + const dummyServicesEntrypoint = 'dummyServicesEntrypoint'; + const dummyArtifactsPath = 'dummyArtifactsPath'; + const dummyArtifactsExclude = 'dummyArtifactsExclude'; + const dummyCachePath = 'dummyCachePath'; + const dummyCacheKey = 'dummyCacheKey'; + const dummyRulesWhen = JOB_RULES_WHEN.delayed.value; + const dummyRulesStartIn = '1 second'; + const dummyRulesAllowFailure = true; const findDrawer = () => wrapper.findComponent(GlDrawer); + const findJobSetupItem = () => wrapper.findComponent(JobSetupItem); + const findImageItem = () => wrapper.findComponent(ImageItem); + const findServicesItem = () => wrapper.findComponent(ServicesItem); + const findArtifactsAndCacheItem = () => wrapper.findComponent(ArtifactsAndCacheItem); + const findRulesItem = () => wrapper.findComponent(RulesItem); + const findConfirmButton = () => wrapper.findByTestId('confirm-button'); const findCancelButton = () => wrapper.findByTestId('cancel-button'); const createComponent = () => { + mockApollo = createMockApollo([ + [getRunnerTags, jest.fn().mockResolvedValue(mockRunnersTagsQueryResponse)], + ]); + wrapper = mountExtended(JobAssistantDrawer, { propsData: { + ciConfigData: mockLintResponse, + ciFileContent: mockCiYml, isVisible: true, }, + apolloProvider: mockApollo, }); }; @@ -27,6 +67,35 @@ describe('Job assistant drawer', () => { await waitForPromises(); }); + it('should contain job setup accordion', () => { + expect(findJobSetupItem().exists()).toBe(true); + }); + + it('job setup item should have tag options', () => { + expect(findJobSetupItem().props('tagOptions')).toEqual([ + { id: 'tag1', name: 'tag1' }, + { id: 'tag2', name: 'tag2' }, + { id: 'tag3', name: 'tag3' }, + { id: 'tag4', name: 'tag4' }, + ]); + }); + + it('should contain image accordion', () => { + expect(findImageItem().exists()).toBe(true); + }); + + it('should contain services accordion', () => { + expect(findServicesItem().exists()).toBe(true); + }); + + it('should contain artifacts and cache item accordion', () => { + expect(findArtifactsAndCacheItem().exists()).toBe(true); + }); + + it('should contain rules accordion', () => { + expect(findRulesItem().exists()).toBe(true); + }); + it('should emit close job assistant drawer event when closing the drawer', () => { expect(wrapper.emitted('close-job-assistant-drawer')).toBeUndefined(); @@ -42,4 +111,185 @@ describe('Job assistant drawer', () => { expect(wrapper.emitted('close-job-assistant-drawer')).toHaveLength(1); }); + + it('should block submit if job name is empty', async () => { + findJobSetupItem().vm.$emit('update-job', 'script', 'b'); + findConfirmButton().trigger('click'); + + await nextTick(); + + expect(findJobSetupItem().props('isNameValid')).toBe(false); + expect(findJobSetupItem().props('isScriptValid')).toBe(true); + expect(wrapper.emitted('updateCiConfig')).toBeUndefined(); + }); + + it('should block submit if rules when is delayed and start in is out of range', async () => { + findRulesItem().vm.$emit('update-job', 'rules[0].when', JOB_RULES_WHEN.delayed.value); + findRulesItem().vm.$emit('update-job', 'rules[0].start_in', '2 weeks'); + findConfirmButton().trigger('click'); + + await nextTick(); + + expect(wrapper.emitted('updateCiConfig')).toBeUndefined(); + }); + + describe('when enter valid input', () => { + beforeEach(() => { + findJobSetupItem().vm.$emit('update-job', 'name', dummyJobName); + findJobSetupItem().vm.$emit('update-job', 'script', dummyJobScript); + findImageItem().vm.$emit('update-job', 'image.name', dummyImageName); + findImageItem().vm.$emit('update-job', 'image.entrypoint', [dummyImageEntrypoint]); + findServicesItem().vm.$emit('update-job', 'services[0].name', dummyServicesName); + findServicesItem().vm.$emit('update-job', 'services[0].entrypoint', [ + dummyServicesEntrypoint, + ]); + findArtifactsAndCacheItem().vm.$emit('update-job', 'artifacts.paths', [dummyArtifactsPath]); + findArtifactsAndCacheItem().vm.$emit('update-job', 'artifacts.exclude', [ + dummyArtifactsExclude, + ]); + findArtifactsAndCacheItem().vm.$emit('update-job', 'cache.paths', [dummyCachePath]); + findArtifactsAndCacheItem().vm.$emit('update-job', 'cache.key', dummyCacheKey); + findRulesItem().vm.$emit('update-job', 'rules[0].allow_failure', dummyRulesAllowFailure); + findRulesItem().vm.$emit('update-job', 'rules[0].when', dummyRulesWhen); + findRulesItem().vm.$emit('update-job', 'rules[0].start_in', dummyRulesStartIn); + }); + + it('passes correct prop to accordions', () => { + const accordions = [ + findJobSetupItem(), + findImageItem(), + findServicesItem(), + findArtifactsAndCacheItem(), + findRulesItem(), + ]; + accordions.forEach((accordion) => { + expect(accordion.props('job')).toMatchObject({ + name: dummyJobName, + script: dummyJobScript, + image: { + name: dummyImageName, + entrypoint: [dummyImageEntrypoint], + }, + services: [ + { + name: dummyServicesName, + entrypoint: [dummyServicesEntrypoint], + }, + ], + artifacts: { + paths: [dummyArtifactsPath], + exclude: [dummyArtifactsExclude], + }, + cache: { + paths: [dummyCachePath], + key: dummyCacheKey, + }, + rules: [ + { + allow_failure: dummyRulesAllowFailure, + when: dummyRulesWhen, + start_in: dummyRulesStartIn, + }, + ], + }); + }); + }); + + it('job name and script state should be valid', () => { + expect(findJobSetupItem().props('isNameValid')).toBe(true); + expect(findJobSetupItem().props('isScriptValid')).toBe(true); + }); + + it('should clear job data when click confirm button', async () => { + findConfirmButton().trigger('click'); + + await nextTick(); + + expect(findJobSetupItem().props('job')).toMatchObject({ name: '', script: '' }); + }); + + it('should clear job data when click cancel button', async () => { + findCancelButton().trigger('click'); + + await nextTick(); + + expect(findJobSetupItem().props('job')).toMatchObject({ name: '', script: '' }); + }); + + it('should omit keys with default value when click add button', () => { + findRulesItem().vm.$emit('update-job', 'rules[0].allow_failure', false); + findRulesItem().vm.$emit('update-job', 'rules[0].when', JOB_RULES_WHEN.onSuccess.value); + findRulesItem().vm.$emit('update-job', 'rules[0].start_in', dummyRulesStartIn); + findConfirmButton().trigger('click'); + + expect(wrapper.emitted('updateCiConfig')).toStrictEqual([ + [ + `${wrapper.props('ciFileContent')}\n${stringify({ + [dummyJobName]: { + script: dummyJobScript, + image: { name: dummyImageName, entrypoint: [dummyImageEntrypoint] }, + services: [ + { + name: dummyServicesName, + entrypoint: [dummyServicesEntrypoint], + }, + ], + artifacts: { + paths: [dummyArtifactsPath], + exclude: [dummyArtifactsExclude], + }, + cache: { + paths: [dummyCachePath], + key: dummyCacheKey, + }, + }, + })}`, + ], + ]); + }); + + it('should update correct ci content when click add button', () => { + findConfirmButton().trigger('click'); + + expect(wrapper.emitted('updateCiConfig')).toStrictEqual([ + [ + `${wrapper.props('ciFileContent')}\n${stringify({ + [dummyJobName]: { + script: dummyJobScript, + image: { name: dummyImageName, entrypoint: [dummyImageEntrypoint] }, + services: [ + { + name: dummyServicesName, + entrypoint: [dummyServicesEntrypoint], + }, + ], + artifacts: { + paths: [dummyArtifactsPath], + exclude: [dummyArtifactsExclude], + }, + cache: { + paths: [dummyCachePath], + key: dummyCacheKey, + }, + rules: [ + { + allow_failure: dummyRulesAllowFailure, + when: dummyRulesWhen, + start_in: dummyRulesStartIn, + }, + ], + }, + })}`, + ], + ]); + }); + + it('should emit scroll editor to button event when click add button', () => { + const eventHubSpy = jest.spyOn(eventHub, '$emit'); + + findConfirmButton().trigger('click'); + + expect(eventHubSpy).toHaveBeenCalledWith(SCROLL_EDITOR_TO_BOTTOM); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js index d43bdec3a33..cc9a77ae525 100644 --- a/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js @@ -40,10 +40,6 @@ describe('CI Lint Results', () => { const findAfterScripts = findAllByTestId('after-script'); const filterEmptyScripts = (property) => mockJobs.filter((job) => job[property].length !== 0); - afterEach(() => { - wrapper.destroy(); - }); - describe('Empty results', () => { it('renders with no jobs, errors or warnings defined', () => { createComponent({ jobs: undefined, errors: undefined, warnings: undefined }, shallowMount); diff --git a/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js index b5e3ea06c2c..d09e22898cd 100644 --- a/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js @@ -21,11 +21,6 @@ describe('CI lint warnings', () => { const findWarnings = () => wrapper.findAll('[data-testid="ci-lint-warning"]'); const findWarningMessage = () => trimText(wrapper.findComponent(GlSprintf).text()); - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - it('displays the warning alert', () => { createComponent(); diff --git a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js index f40db50aab7..471b033913b 100644 --- a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -57,6 +57,7 @@ describe('Pipeline editor tabs component', () => { isNewCiConfigFile: true, showDrawer: false, showJobAssistantDrawer: false, + showAiAssistantDrawer: false, ...props, }, data() { @@ -65,6 +66,7 @@ describe('Pipeline editor tabs component', () => { }; }, provide: { + aiChatAvailable: false, ciConfigPath: '/path/to/ci-config', ciLintPath: mockCiLintPath, currentBranch: 'main', @@ -119,6 +121,7 @@ describe('Pipeline editor tabs component', () => { }); afterEach(() => { + // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy wrapper.destroy(); }); @@ -313,13 +316,13 @@ describe('Pipeline editor tabs component', () => { createComponent(); }); - it('shows walkthrough popover', async () => { + it('shows walkthrough popover', () => { expect(findWalkthroughPopover().exists()).toBe(true); }); }); describe('when isNewCiConfigFile prop is false', () => { - it('does not show walkthrough popover', async () => { + it('does not show walkthrough popover', () => { createComponent({ props: { isNewCiConfigFile: false } }); expect(findWalkthroughPopover().exists()).toBe(false); }); diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js index 63ebfc0559d..3d84f06967a 100644 --- a/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js @@ -22,11 +22,10 @@ describe('FileTreePopover component', () => { afterEach(() => { localStorage.clear(); - wrapper.destroy(); }); describe('default', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({ stubs: { GlSprintf } }); }); @@ -46,7 +45,7 @@ describe('FileTreePopover component', () => { }); describe('when popover has already been dismissed before', () => { - it('does not render popover', async () => { + it('does not render popover', () => { localStorage.setItem(FILE_TREE_POPOVER_DISMISSED_KEY, 'true'); createComponent(); diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js index cf0b974081e..18eec48ad83 100644 --- a/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js @@ -19,12 +19,8 @@ describe('ValidatePopover component', () => { const findHelpLink = () => wrapper.findByTestId('help-link'); const findFeedbackLink = () => wrapper.findByTestId('feedback-link'); - afterEach(() => { - wrapper.destroy(); - }); - describe('template', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({ stubs: { GlLink, GlSprintf }, }); diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js index ca6033f2ff5..37339b1c422 100644 --- a/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js @@ -12,17 +12,13 @@ describe('WalkthroughPopover component', () => { return extendedWrapper(mountFn(WalkthroughPopover)); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('CTA button clicked', () => { beforeEach(async () => { wrapper = createComponent(mount); await wrapper.findByTestId('ctaBtn').trigger('click'); }); - it('emits "walkthrough-popover-cta-clicked" event', async () => { + it('emits "walkthrough-popover-cta-clicked" event', () => { expect(wrapper.emitted()['walkthrough-popover-cta-clicked']).toHaveLength(1); }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js index b22c98e5544..8b8dd4d22c2 100644 --- a/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js @@ -4,10 +4,9 @@ import ConfirmDialog from '~/ci/pipeline_editor/components/ui/confirm_unsaved_ch describe('pipeline_editor/components/ui/confirm_unsaved_changes_dialog', () => { let beforeUnloadEvent; let setDialogContent; - let wrapper; const createComponent = (propsData = {}) => { - wrapper = shallowMount(ConfirmDialog, { + shallowMount(ConfirmDialog, { propsData, }); }; @@ -21,7 +20,6 @@ describe('pipeline_editor/components/ui/confirm_unsaved_changes_dialog', () => { afterEach(() => { beforeUnloadEvent.preventDefault.mockRestore(); setDialogContent.mockRestore(); - wrapper.destroy(); }); it('shows confirmation dialog when there are unsaved changes', () => { diff --git a/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js index a4e7abba7b0..f02b1f5efbc 100644 --- a/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js @@ -64,7 +64,7 @@ describe('~/ci/pipeline_editor/components/ui/editor_tab.vue', () => { mockChildMounted = jest.fn(); }); - it('tabs are mounted lazily', async () => { + it('tabs are mounted lazily', () => { createMockedWrapper(); expect(mockChildMounted).toHaveBeenCalledTimes(0); @@ -192,7 +192,7 @@ describe('~/ci/pipeline_editor/components/ui/editor_tab.vue', () => { createMockedWrapper(); }); - it('renders correct number of badges', async () => { + it('renders correct number of badges', () => { expect(findBadges()).toHaveLength(1); expect(findBadges().at(0).text()).toBe('NEW'); }); diff --git a/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js index 3c68f74af43..e636a89c6d9 100644 --- a/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js @@ -23,10 +23,6 @@ describe('Pipeline editor empty state', () => { const findConfirmButton = () => wrapper.findComponent(GlButton); const findDescription = () => wrapper.findComponent(GlSprintf); - afterEach(() => { - wrapper.destroy(); - }); - describe('when project uses an external CI config', () => { beforeEach(() => { createComponent({ diff --git a/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js index ae25142b455..2349816fa86 100644 --- a/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui'; +import { GlAlert, GlDisclosureDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui'; import { nextTick } from 'vue'; import { createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; @@ -90,7 +90,7 @@ describe('Pipeline Editor Validate Tab', () => { const findHelpIcon = () => wrapper.findComponent(GlIcon); const findIllustration = () => wrapper.findByRole('img'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findPipelineSource = () => wrapper.findComponent(GlDropdown); + const findPipelineSource = () => wrapper.findComponent(GlDisclosureDropdown); const findPopover = () => wrapper.findComponent(GlPopover); const findCiLintResults = () => wrapper.findComponent(CiLintResults); const findResultsCta = () => wrapper.findByTestId('resimulate-pipeline-button'); @@ -99,10 +99,6 @@ describe('Pipeline Editor Validate Tab', () => { mockBlobContentData = jest.fn(); }); - afterEach(() => { - wrapper.destroy(); - }); - describe('while initial CI content is loading', () => { beforeEach(() => { createComponent({ isBlobLoading: true }); @@ -122,7 +118,7 @@ describe('Pipeline Editor Validate Tab', () => { it('renders disabled pipeline source dropdown', () => { expect(findPipelineSource().exists()).toBe(true); - expect(findPipelineSource().attributes('text')).toBe(i18n.pipelineSourceDefault); + expect(findPipelineSource().attributes('toggletext')).toBe(i18n.pipelineSourceDefault); expect(findPipelineSource().props('disabled')).toBe(true); }); diff --git a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js index 6a6cc3a14de..893f6775ac5 100644 --- a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js @@ -34,7 +34,7 @@ describe('~/ci/pipeline_editor/graphql/resolvers', () => { }); /* eslint-disable no-underscore-dangle */ - it('lint data has correct type names', async () => { + it('lint data has correct type names', () => { expect(result.__typename).toBe('CiLintContent'); expect(result.jobs[0].__typename).toBe('CiLintJob'); diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js index 541123d7efc..865dd34fbfe 100644 --- a/spec/frontend/ci/pipeline_editor/mock_data.js +++ b/spec/frontend/ci/pipeline_editor/mock_data.js @@ -12,7 +12,7 @@ export const mockCommitSha = 'aabbccdd'; export const mockCommitNextSha = 'eeffgghh'; export const mockIncludesHelpPagePath = '/-/includes/help'; export const mockLintHelpPagePath = '/-/lint-help'; -export const mockLintUnavailableHelpPagePath = '/-/pipeline-editor/troubleshoot'; +export const mockCiTroubleshootingPath = '/-/pipeline-editor/troubleshoot'; export const mockSimulatePipelineHelpPagePath = '/-/simulate-pipeline-help'; export const mockYmlHelpPagePath = '/-/yml-help'; export const mockCommitMessage = 'My commit message'; @@ -583,6 +583,36 @@ export const mockCommitCreateResponse = { }, }; +export const mockRunnersTagsQueryResponse = { + data: { + runners: { + nodes: [ + { + id: 'gid://gitlab/Ci::Runner/1', + tagList: ['tag1', 'tag2'], + __typename: 'CiRunner', + }, + { + id: 'gid://gitlab/Ci::Runner/2', + tagList: ['tag2', 'tag3'], + __typename: 'CiRunner', + }, + { + id: 'gid://gitlab/Ci::Runner/3', + tagList: ['tag2', 'tag4'], + __typename: 'CiRunner', + }, + { + id: 'gid://gitlab/Ci::Runner/4', + tagList: [], + __typename: 'CiRunner', + }, + ], + __typename: 'CiRunnerConnection', + }, + }, +}; + export const mockCommitCreateResponseNewEtag = { data: { commitCreate: { diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js index a103acb33bc..cc4a022c2df 100644 --- a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlButton, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -6,7 +6,7 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; -import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; +import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue'; import PipelineEditorEmptyState from '~/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; @@ -96,7 +96,7 @@ describe('Pipeline editor app component', () => { }); }; - const createComponentWithApollo = async ({ + const createComponentWithApollo = ({ provide = {}, stubs = {}, withUndefinedBranch = false, @@ -162,10 +162,6 @@ describe('Pipeline editor app component', () => { mockPipelineQuery = jest.fn(); }); - afterEach(() => { - wrapper.destroy(); - }); - describe('loading state', () => { it('displays a loading icon if the blob query is loading', () => { createComponent({ blobLoading: true }); @@ -264,7 +260,7 @@ describe('Pipeline editor app component', () => { expect(findAlert().exists()).toBe(false); }); - it('ci config query is called with correct variables', async () => { + it('ci config query is called with correct variables', () => { expect(mockCiConfigData).toHaveBeenCalledWith({ content: mockCiYml, projectPath: mockProjectFullPath, @@ -291,7 +287,7 @@ describe('Pipeline editor app component', () => { .mockImplementation(jest.fn()); }); - it('shows an empty state and does not show editor home component', async () => { + it('shows an empty state and does not show editor home component', () => { expect(findEmptyState().exists()).toBe(true); expect(findAlert().exists()).toBe(false); expect(findEditorHome().exists()).toBe(false); @@ -351,7 +347,9 @@ describe('Pipeline editor app component', () => { }); it('shows that the lint service is down', () => { - expect(findValidationSegment().text()).toContain( + const validationMessage = findValidationSegment().findComponent(GlSprintf); + + expect(validationMessage.attributes('message')).toContain( validationSegmenti18n.unavailableValidation, ); }); @@ -436,7 +434,7 @@ describe('Pipeline editor app component', () => { 'merge_request[target_branch]': mockDefaultBranch, }); - expect(redirectTo).toHaveBeenCalledWith(`${mockNewMergeRequestPath}?${branchesQuery}`); + expect(redirectTo).toHaveBeenCalledWith(`${mockNewMergeRequestPath}?${branchesQuery}`); // eslint-disable-line import/no-deprecated }); }); diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js index 4f8f2112abe..576263d5418 100644 --- a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js @@ -41,6 +41,7 @@ describe('Pipeline editor home wrapper', () => { ...props, }, provide: { + aiChatAvailable: false, projectFullPath: '', totalBranches: 19, glFeatures: { @@ -67,7 +68,6 @@ describe('Pipeline editor home wrapper', () => { afterEach(() => { localStorage.clear(); - wrapper.destroy(); }); describe('renders', () => { 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 6f18899ebac..1d4ae33c667 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 @@ -4,7 +4,7 @@ import { GlForm, GlDropdownItem, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import CreditCardValidationRequiredAlert from 'ee_component/billings/components/cc_validation_required_alert.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; @@ -13,7 +13,7 @@ import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK, } from '~/lib/utils/http_status'; -import { redirectTo } from '~/lib/utils/url_utility'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import PipelineNewForm, { POLLING_INTERVAL, } from '~/ci/pipeline_new/components/pipeline_new_form.vue'; @@ -30,8 +30,8 @@ import { mockQueryParams, mockPostParams, mockProjectId, - mockRefs, mockYamlVariables, + mockPipelineConfigButtonText, } from '../mock_data'; Vue.use(VueApollo); @@ -40,8 +40,8 @@ jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), })); -const projectRefsEndpoint = '/root/project/refs'; const pipelinesPath = '/root/project/-/pipelines'; +const pipelinesEditorPath = '/root/project/-/ci/editor'; const projectPath = '/root/project/-/pipelines/config_variables'; const newPipelinePostResponse = { id: 1 }; const defaultBranch = 'main'; @@ -65,6 +65,7 @@ describe('Pipeline New Form', () => { wrapper.findAllByTestId('pipeline-form-ci-variable-value-dropdown'); const findValueDropdownItems = (dropdown) => dropdown.findAllComponents(GlDropdownItem); const findErrorAlert = () => wrapper.findByTestId('run-pipeline-error-alert'); + const findPipelineConfigButton = () => wrapper.findByTestId('ci-cd-pipeline-configuration'); const findWarningAlert = () => wrapper.findByTestId('run-pipeline-warning-alert'); const findWarningAlertSummary = () => findWarningAlert().findComponent(GlSprintf); const findWarnings = () => wrapper.findAllByTestId('run-pipeline-warning'); @@ -88,24 +89,23 @@ describe('Pipeline New Form', () => { const changeKeyInputValue = async (keyInputIndex, value) => { const input = findKeyInputs().at(keyInputIndex); - input.element.value = value; - input.trigger('change'); + input.vm.$emit('input', value); + input.vm.$emit('change'); await nextTick(); }; - const createComponentWithApollo = ({ method = shallowMountExtended, props = {} } = {}) => { + const createComponentWithApollo = ({ props = {} } = {}) => { const handlers = [[ciConfigVariablesQuery, mockCiConfigVariables]]; mockApollo = createMockApollo(handlers, resolvers); - wrapper = method(PipelineNewForm, { + wrapper = shallowMountExtended(PipelineNewForm, { apolloProvider: mockApollo, - provide: { - projectRefsEndpoint, - }, propsData: { projectId: mockProjectId, pipelinesPath, + pipelinesEditorPath, + canViewPipelineEditor: true, projectPath, defaultBranch, refParam: defaultBranch, @@ -119,7 +119,6 @@ describe('Pipeline New Form', () => { beforeEach(() => { mock = new MockAdapter(axios); mockCiConfigVariables = jest.fn(); - mock.onGet(projectRefsEndpoint).reply(HTTP_STATUS_OK, mockRefs); dummySubmitEvent = { preventDefault: jest.fn(), @@ -128,17 +127,16 @@ describe('Pipeline New Form', () => { afterEach(() => { mock.restore(); - wrapper.destroy(); }); describe('Form', () => { beforeEach(async () => { mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); - createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); + createComponentWithApollo({ props: mockQueryParams }); await waitForPromises(); }); - it('displays the correct values for the provided query params', async () => { + it('displays the correct values for the provided query params', () => { expect(findVariableTypes().at(0).props('text')).toBe('Variable'); expect(findVariableTypes().at(1).props('text')).toBe('File'); expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' }); @@ -146,13 +144,13 @@ describe('Pipeline New Form', () => { }); it('displays a variable from provided query params', () => { - expect(findKeyInputs().at(0).element.value).toBe('test_var'); - expect(findValueInputs().at(0).element.value).toBe('test_var_val'); + expect(findKeyInputs().at(0).attributes('value')).toBe('test_var'); + expect(findValueInputs().at(0).attributes('value')).toBe('test_var_val'); }); - it('displays an empty variable for the user to fill out', async () => { - expect(findKeyInputs().at(2).element.value).toBe(''); - expect(findValueInputs().at(2).element.value).toBe(''); + it('displays an empty variable for the user to fill out', () => { + expect(findKeyInputs().at(2).attributes('value')).toBe(''); + expect(findValueInputs().at(2).attributes('value')).toBe(''); expect(findVariableTypes().at(2).props('text')).toBe('Variable'); }); @@ -161,7 +159,7 @@ describe('Pipeline New Form', () => { }); it('removes ci variable row on remove icon button click', async () => { - findRemoveIcons().at(1).trigger('click'); + findRemoveIcons().at(1).vm.$emit('click'); await nextTick(); @@ -170,24 +168,25 @@ describe('Pipeline New Form', () => { it('creates blank variable on input change event', async () => { const input = findKeyInputs().at(2); - input.element.value = 'test_var_2'; - input.trigger('change'); + + input.vm.$emit('input', 'test_var_2'); + input.vm.$emit('change'); await nextTick(); expect(findVariableRows()).toHaveLength(4); - expect(findKeyInputs().at(3).element.value).toBe(''); - expect(findValueInputs().at(3).element.value).toBe(''); + expect(findKeyInputs().at(3).attributes('value')).toBe(''); + expect(findValueInputs().at(3).attributes('value')).toBe(''); }); }); describe('Pipeline creation', () => { - beforeEach(async () => { + beforeEach(() => { mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); mock.onPost(pipelinesPath).reply(HTTP_STATUS_OK, newPipelinePostResponse); }); - it('does not submit the native HTML form', async () => { + it('does not submit the native HTML form', () => { createComponentWithApollo(); findForm().vm.$emit('submit', dummySubmitEvent); @@ -213,7 +212,7 @@ describe('Pipeline New Form', () => { await waitForPromises(); expect(getFormPostParams().ref).toEqual(`refs/heads/${defaultBranch}`); - expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); // eslint-disable-line import/no-deprecated }); it('creates a pipeline with short ref and variables from the query params', async () => { @@ -226,14 +225,14 @@ describe('Pipeline New Form', () => { await waitForPromises(); expect(getFormPostParams()).toEqual(mockPostParams); - expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); // eslint-disable-line import/no-deprecated }); }); describe('When the ref has been changed', () => { beforeEach(async () => { mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); - createComponentWithApollo({ method: mountExtended }); + createComponentWithApollo(); await waitForPromises(); }); @@ -247,12 +246,12 @@ describe('Pipeline New Form', () => { await selectBranch('main'); - expect(findKeyInputs().at(0).element.value).toBe('build_var'); + expect(findKeyInputs().at(0).attributes('value')).toBe('build_var'); expect(findVariableRows().length).toBe(2); await selectBranch('branch-1'); - expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); + expect(findKeyInputs().at(0).attributes('value')).toBe('deploy_var'); expect(findVariableRows().length).toBe(2); }); @@ -276,7 +275,7 @@ describe('Pipeline New Form', () => { describe('When there are no variables in the API cache', () => { beforeEach(async () => { mockCiConfigVariables.mockResolvedValue(mockNoCachedCiConfigVariablesResponse); - createComponentWithApollo({ method: mountExtended }); + createComponentWithApollo(); await waitForPromises(); }); @@ -324,9 +323,9 @@ describe('Pipeline New Form', () => { }); const testBehaviorWhenCacheIsPopulated = (queryResponse) => { - beforeEach(async () => { + beforeEach(() => { mockCiConfigVariables.mockResolvedValue(queryResponse); - createComponentWithApollo({ method: mountExtended }); + createComponentWithApollo(); }); it('does not poll for new values', async () => { @@ -341,6 +340,9 @@ describe('Pipeline New Form', () => { }); it('loading icon is shown when content is requested and hidden when received', async () => { + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams }); + expect(findLoadingIcon().exists()).toBe(true); await waitForPromises(); @@ -354,11 +356,11 @@ describe('Pipeline New Form', () => { it('displays an empty form', async () => { mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); - createComponentWithApollo({ method: mountExtended }); + createComponentWithApollo(); await waitForPromises(); - expect(findKeyInputs().at(0).element.value).toBe(''); - expect(findValueInputs().at(0).element.value).toBe(''); + expect(findKeyInputs().at(0).attributes('value')).toBe(''); + expect(findValueInputs().at(0).attributes('value')).toBe(''); expect(findVariableTypes().at(0).props('text')).toBe('Variable'); }); }); @@ -369,12 +371,12 @@ describe('Pipeline New Form', () => { describe('with different predefined values', () => { beforeEach(async () => { mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); - createComponentWithApollo({ method: mountExtended }); + createComponentWithApollo(); await waitForPromises(); }); it('multi-line strings are added to the value field without removing line breaks', () => { - expect(findValueInputs().at(1).element.value).toBe(mockYamlVariables[1].value); + expect(findValueInputs().at(1).attributes('value')).toBe(mockYamlVariables[1].value); }); it('multiple predefined values are rendered as a dropdown', () => { @@ -398,24 +400,24 @@ describe('Pipeline New Form', () => { describe('with description', () => { beforeEach(async () => { mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); - createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); + createComponentWithApollo({ props: mockQueryParams }); await waitForPromises(); }); - it('displays all the variables', async () => { + it('displays all the variables', () => { expect(findVariableRows()).toHaveLength(6); }); it('displays a variable from yml', () => { - expect(findKeyInputs().at(0).element.value).toBe(mockYamlVariables[0].key); - expect(findValueInputs().at(0).element.value).toBe(mockYamlVariables[0].value); + expect(findKeyInputs().at(0).attributes('value')).toBe(mockYamlVariables[0].key); + expect(findValueInputs().at(0).attributes('value')).toBe(mockYamlVariables[0].value); }); it('displays a variable from provided query params', () => { - expect(findKeyInputs().at(3).element.value).toBe( + expect(findKeyInputs().at(3).attributes('value')).toBe( Object.keys(mockQueryParams.variableParams)[0], ); - expect(findValueInputs().at(3).element.value).toBe( + expect(findValueInputs().at(3).attributes('value')).toBe( Object.values(mockQueryParams.fileParams)[0], ); }); @@ -425,7 +427,7 @@ describe('Pipeline New Form', () => { }); it('removes the description when a variable key changes', async () => { - findKeyInputs().at(0).element.value = 'yml_var_modified'; + findKeyInputs().at(0).vm.$emit('input', 'yml_var_modified'); findKeyInputs().at(0).trigger('change'); await nextTick(); @@ -437,11 +439,11 @@ describe('Pipeline New Form', () => { describe('without description', () => { beforeEach(async () => { mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponseWithoutDesc); - createComponentWithApollo({ method: mountExtended }); + createComponentWithApollo(); await waitForPromises(); }); - it('displays variables with description only', async () => { + it('displays variables with description only', () => { expect(findVariableRows()).toHaveLength(2); // extra empty variable is added at the end }); }); @@ -456,7 +458,7 @@ describe('Pipeline New Form', () => { describe('when the refs cannot be loaded', () => { beforeEach(() => { mock - .onGet(projectRefsEndpoint, { params: { search: '' } }) + .onGet('/api/v4/projects/8/repository/branches', { params: { search: '' } }) .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); findRefsDropdown().vm.$emit('loadingError'); @@ -500,6 +502,17 @@ describe('Pipeline New Form', () => { expect(findSubmitButton().props('disabled')).toBe(false); }); + it('shows pipeline configuration button for user who can view', () => { + expect(findPipelineConfigButton().exists()).toBe(true); + expect(findPipelineConfigButton().text()).toBe(mockPipelineConfigButtonText); + }); + + it('does not show pipeline configuration button for user who can not view', () => { + createComponentWithApollo({ props: { canViewPipelineEditor: false } }); + + expect(findPipelineConfigButton().exists()).toBe(false); + }); + it('does not show the credit card validation required alert', () => { expect(findCCAlert().exists()).toBe(false); }); diff --git a/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js b/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js index cf8009e388f..01c7dd7eb84 100644 --- a/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js +++ b/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js @@ -1,35 +1,22 @@ -import { GlListbox, GlListboxItem } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; -import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { shallowMount } from '@vue/test-utils'; +import RefSelector from '~/ref/components/ref_selector.vue'; import RefsDropdown from '~/ci/pipeline_new/components/refs_dropdown.vue'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; -import { mockBranches, mockRefs, mockFilteredRefs, mockTags } from '../mock_data'; - -const projectRefsEndpoint = '/root/project/refs'; +const projectId = '8'; const refShortName = 'main'; const refFullName = 'refs/heads/main'; -jest.mock('~/flash'); - describe('Pipeline New Form', () => { let wrapper; - let mock; - const findDropdown = () => wrapper.findComponent(GlListbox); - const findRefsDropdownItems = () => wrapper.findAllComponents(GlListboxItem); - const findSearchBox = () => wrapper.findByTestId('listbox-search-input'); - const findListboxGroups = () => wrapper.findAll('ul[role="group"]'); + const findRefSelector = () => wrapper.findComponent(RefSelector); - const createComponent = (props = {}, mountFn = shallowMountExtended) => { - wrapper = mountFn(RefsDropdown, { - provide: { - projectRefsEndpoint, - }, + const createComponent = (props = {}) => { + wrapper = shallowMount(RefsDropdown, { propsData: { + projectId, value: { shortName: refShortName, fullName: refFullName, @@ -39,163 +26,54 @@ describe('Pipeline New Form', () => { }); }; - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(HTTP_STATUS_OK, mockRefs); - }); - - beforeEach(() => { - createComponent(); - }); - - it('displays empty dropdown initially', () => { - findDropdown().vm.$emit('shown'); - - expect(findRefsDropdownItems()).toHaveLength(0); - }); - - it('does not make requests immediately', async () => { - expect(mock.history.get).toHaveLength(0); - }); - describe('when user opens dropdown', () => { - beforeEach(async () => { - createComponent({}, mountExtended); - findDropdown().vm.$emit('shown'); - await waitForPromises(); + beforeEach(() => { + createComponent(); }); - it('requests unfiltered tags and branches', () => { - expect(mock.history.get).toHaveLength(1); - expect(mock.history.get[0].url).toBe(projectRefsEndpoint); - expect(mock.history.get[0].params).toEqual({ search: '' }); + it('has default selected branch', () => { + expect(findRefSelector().props('value')).toBe('main'); }); - it('displays dropdown with branches and tags', () => { - const refLength = mockRefs.Tags.length + mockRefs.Branches.length; - expect(findRefsDropdownItems()).toHaveLength(refLength); - }); - - it('displays the names of refs', () => { - // Branches - expect(findRefsDropdownItems().at(0).text()).toBe(mockRefs.Branches[0]); - - // Tags (appear after branches) - const firstTag = mockRefs.Branches.length; - expect(findRefsDropdownItems().at(firstTag).text()).toBe(mockRefs.Tags[0]); - }); - - it('when user shows dropdown a second time, only one request is done', () => { - expect(mock.history.get).toHaveLength(1); + it('has ref selector for branches and tags', () => { + expect(findRefSelector().props('enabledRefTypes')).toEqual([ + REF_TYPE_BRANCHES, + REF_TYPE_TAGS, + ]); }); describe('when user selects a value', () => { - const selectedIndex = 1; - - beforeEach(async () => { - findRefsDropdownItems().at(selectedIndex).vm.$emit('select', 'refs/heads/branch-1'); - await waitForPromises(); - }); + const fullName = `refs/heads/conflict-contains-conflict-markers`; it('component emits @input', () => { + findRefSelector().vm.$emit('input', fullName); + const inputs = wrapper.emitted('input'); expect(inputs).toHaveLength(1); - expect(inputs[0]).toEqual([{ shortName: 'branch-1', fullName: 'refs/heads/branch-1' }]); - }); - }); - - describe('when user types searches for a tag', () => { - const mockSearchTerm = 'my-search'; - - beforeEach(async () => { - mock - .onGet(projectRefsEndpoint, { params: { search: mockSearchTerm } }) - .reply(HTTP_STATUS_OK, mockFilteredRefs); - - await findSearchBox().vm.$emit('input', mockSearchTerm); - await waitForPromises(); - }); - - it('requests filtered tags and branches', async () => { - expect(mock.history.get).toHaveLength(2); - expect(mock.history.get[1].params).toEqual({ - search: mockSearchTerm, - }); - }); - - it('displays dropdown with branches and tags', async () => { - const filteredRefLength = mockFilteredRefs.Tags.length + mockFilteredRefs.Branches.length; - - expect(findRefsDropdownItems()).toHaveLength(filteredRefLength); + expect(inputs[0]).toEqual([ + { + shortName: 'conflict-contains-conflict-markers', + fullName: 'refs/heads/conflict-contains-conflict-markers', + }, + ]); }); }); }); describe('when user has selected a value', () => { - const selectedIndex = 1; - const mockShortName = mockRefs.Branches[selectedIndex]; + const mockShortName = 'conflict-contains-conflict-markers'; const mockFullName = `refs/heads/${mockShortName}`; - beforeEach(async () => { - mock - .onGet(projectRefsEndpoint, { - params: { ref: mockFullName }, - }) - .reply(HTTP_STATUS_OK, mockRefs); - - createComponent( - { - value: { - shortName: mockShortName, - fullName: mockFullName, - }, - }, - mountExtended, - ); - findDropdown().vm.$emit('shown'); - await waitForPromises(); - }); - it('branch is checked', () => { - expect(findRefsDropdownItems().at(selectedIndex).props('isSelected')).toBe(true); - }); - }); - - describe('when server returns an error', () => { - beforeEach(async () => { - mock - .onGet(projectRefsEndpoint, { params: { search: '' } }) - .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - findDropdown().vm.$emit('shown'); - await waitForPromises(); - }); + createComponent({ + value: { + shortName: mockShortName, + fullName: mockFullName, + }, + }); - it('loading error event is emitted', () => { - expect(wrapper.emitted('loadingError')).toHaveLength(1); - expect(wrapper.emitted('loadingError')[0]).toEqual([expect.any(Error)]); + expect(findRefSelector().props('value')).toBe(mockShortName); }); }); - - describe('should display branches and tags based on its length', () => { - it.each` - mockData | expectedGroupLength | expectedListboxItemsLength - ${{ ...mockBranches, Tags: [] }} | ${1} | ${mockBranches.Branches.length} - ${{ Branches: [], ...mockTags }} | ${1} | ${mockTags.Tags.length} - ${{ ...mockRefs }} | ${2} | ${mockBranches.Branches.length + mockTags.Tags.length} - ${{ Branches: undefined, Tags: undefined }} | ${0} | ${0} - `( - 'should render branches and tags based on presence', - async ({ mockData, expectedGroupLength, expectedListboxItemsLength }) => { - mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(HTTP_STATUS_OK, mockData); - createComponent({}, mountExtended); - findDropdown().vm.$emit('shown'); - await waitForPromises(); - - expect(findListboxGroups()).toHaveLength(expectedGroupLength); - expect(findRefsDropdownItems()).toHaveLength(expectedListboxItemsLength); - }, - ); - }); }); diff --git a/spec/frontend/ci/pipeline_new/mock_data.js b/spec/frontend/ci/pipeline_new/mock_data.js index 5b935c0c819..76a88f63298 100644 --- a/spec/frontend/ci/pipeline_new/mock_data.js +++ b/spec/frontend/ci/pipeline_new/mock_data.js @@ -1,16 +1,3 @@ -export const mockBranches = { - Branches: ['main', 'branch-1', 'branch-2'], -}; - -export const mockTags = { - Tags: ['1.0.0', '1.1.0', '1.2.0'], -}; - -export const mockRefs = { - ...mockBranches, - ...mockTags, -}; - export const mockFilteredRefs = { Branches: ['branch-1'], Tags: ['1.0.0', '1.1.0'], @@ -133,3 +120,5 @@ export const mockCiConfigVariablesResponseWithoutDesc = mockCiConfigVariablesQue mockYamlVariablesWithoutDesc, ); export const mockNoCachedCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse(null); + +export const mockPipelineConfigButtonText = 'Go to the pipeline editor'; diff --git a/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js b/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js index ba948f12b33..e48f556c246 100644 --- a/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js @@ -20,17 +20,13 @@ describe('Delete pipeline schedule modal', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - - it('emits the deleteSchedule event', async () => { + it('emits the deleteSchedule event', () => { findModal().vm.$emit('primary'); expect(wrapper.emitted()).toEqual({ deleteSchedule: [[]] }); }); - it('emits the hideModal event', async () => { + it('emits the hideModal event', () => { findModal().vm.$emit('hide'); expect(wrapper.emitted()).toEqual({ hideModal: [[]] }); 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 611993556e3..50008cedd9c 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js @@ -16,6 +16,7 @@ import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/g import { mockGetPipelineSchedulesGraphQLResponse, mockPipelineScheduleNodes, + mockPipelineScheduleCurrentUser, deleteMutationResponse, playMutationResponse, takeOwnershipMutationResponse, @@ -79,10 +80,6 @@ describe('Pipeline schedules app', () => { const findSchedulesCharacteristics = () => wrapper.findByTestId('pipeline-schedules-characteristics'); - afterEach(() => { - wrapper.destroy(); - }); - describe('default', () => { beforeEach(() => { createComponent(); @@ -115,6 +112,7 @@ describe('Pipeline schedules app', () => { await waitForPromises(); expect(findTable().props('schedules')).toEqual(mockPipelineScheduleNodes); + expect(findTable().props('currentUser')).toEqual(mockPipelineScheduleCurrentUser); }); it('shows query error alert', async () => { diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js index 6fb6a8bc33b..be0052fc7cf 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js @@ -3,6 +3,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PipelineScheduleActions from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue'; import { mockPipelineScheduleNodes, + mockPipelineScheduleCurrentUser, mockPipelineScheduleAsGuestNodes, mockTakeOwnershipNodes, } from '../../../mock_data'; @@ -12,6 +13,7 @@ describe('Pipeline schedule actions', () => { const defaultProps = { schedule: mockPipelineScheduleNodes[0], + currentUser: mockPipelineScheduleCurrentUser, }; const createComponent = (props = defaultProps) => { @@ -27,18 +29,17 @@ describe('Pipeline schedule actions', () => { const findTakeOwnershipBtn = () => wrapper.findByTestId('take-ownership-pipeline-schedule-btn'); const findPlayScheduleBtn = () => wrapper.findByTestId('play-pipeline-schedule-btn'); - afterEach(() => { - wrapper.destroy(); - }); - - it('displays action buttons', () => { + it('displays buttons when user is the owner of schedule and has adminPipelineSchedule permissions', () => { createComponent(); expect(findAllButtons()).toHaveLength(3); }); - it('does not display action buttons', () => { - createComponent({ schedule: mockPipelineScheduleAsGuestNodes[0] }); + it('does not display action buttons when user is not owner and does not have adminPipelineSchedule permission', () => { + createComponent({ + schedule: mockPipelineScheduleAsGuestNodes[0], + currentUser: mockPipelineScheduleCurrentUser, + }); expect(findAllButtons()).toHaveLength(0); }); @@ -54,7 +55,10 @@ describe('Pipeline schedule actions', () => { }); it('take ownership button emits showTakeOwnershipModal event and schedule id', () => { - createComponent({ schedule: mockTakeOwnershipNodes[0] }); + createComponent({ + schedule: mockTakeOwnershipNodes[0], + currentUser: mockPipelineScheduleCurrentUser, + }); findTakeOwnershipBtn().vm.$emit('click'); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js index 0821c59c8a0..ae069145292 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js @@ -21,10 +21,6 @@ describe('Pipeline schedule last pipeline', () => { const findCIBadgeLink = () => wrapper.findComponent(CiBadgeLink); const findStatusText = () => wrapper.findByTestId('pipeline-schedule-status-text'); - afterEach(() => { - wrapper.destroy(); - }); - it('displays pipeline status', () => { createComponent(); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js index 1c06c411097..3bdbb371ddc 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js @@ -21,10 +21,6 @@ describe('Pipeline schedule next run', () => { const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip); const findInactive = () => wrapper.findByTestId('pipeline-schedule-inactive'); - afterEach(() => { - wrapper.destroy(); - }); - it('displays time ago', () => { createComponent(); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js index 6c1991cb4ac..849bef80f42 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js @@ -25,10 +25,6 @@ describe('Pipeline schedule owner', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('displays avatar', () => { expect(findAvatar().exists()).toBe(true); expect(findAvatar().props('src')).toBe(defaultProps.schedule.owner.avatarUrl); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js index f531f04a736..5cc3829efbd 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js @@ -25,10 +25,6 @@ describe('Pipeline schedule target', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('displays icon', () => { expect(findIcon().exists()).toBe(true); expect(findIcon().props('name')).toBe('fork'); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js index 316b3bcf926..e488a36f3dc 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js @@ -1,13 +1,14 @@ import { GlTableLite } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import PipelineSchedulesTable from '~/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue'; -import { mockPipelineScheduleNodes } from '../../mock_data'; +import { mockPipelineScheduleNodes, mockPipelineScheduleCurrentUser } from '../../mock_data'; describe('Pipeline schedules table', () => { let wrapper; const defaultProps = { schedules: mockPipelineScheduleNodes, + currentUser: mockPipelineScheduleCurrentUser, }; const createComponent = (props = defaultProps) => { @@ -25,10 +26,6 @@ describe('Pipeline schedules table', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('displays table', () => { expect(findTable().exists()).toBe(true); }); diff --git a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js index 7e6d4ec4bf8..e4ff9a0545b 100644 --- a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js @@ -25,14 +25,12 @@ describe('Take ownership modal', () => { const actionPrimary = findModal().props('actionPrimary'); expect(actionPrimary.attributes).toEqual( - expect.objectContaining([ - { - category: 'primary', - variant: 'confirm', - href: url, - 'data-method': 'post', - }, - ]), + expect.objectContaining({ + category: 'primary', + variant: 'confirm', + href: url, + 'data-method': 'post', + }), ); }); diff --git a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js index e3965d13c19..7cc254b7653 100644 --- a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js @@ -26,13 +26,13 @@ describe('Take ownership modal', () => { ); }); - it('emits the takeOwnership event', async () => { + it('emits the takeOwnership event', () => { findModal().vm.$emit('primary'); expect(wrapper.emitted()).toEqual({ takeOwnership: [[]] }); }); - it('emits the hideModal event', async () => { + it('emits the hideModal event', () => { findModal().vm.$emit('hide'); expect(wrapper.emitted()).toEqual({ hideModal: [[]] }); diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js index 2826c054249..1485f6beea4 100644 --- a/spec/frontend/ci/pipeline_schedules/mock_data.js +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -5,6 +5,7 @@ import mockGetPipelineSchedulesTakeOwnershipGraphQLResponse from 'test_fixtures/ const { data: { + currentUser, project: { pipelineSchedules: { nodes }, }, @@ -28,6 +29,7 @@ const { } = mockGetPipelineSchedulesTakeOwnershipGraphQLResponse; export const mockPipelineScheduleNodes = nodes; +export const mockPipelineScheduleCurrentUser = currentUser; export const mockPipelineScheduleAsGuestNodes = guestNodes; diff --git a/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js index 90ca2a07266..847862be183 100644 --- a/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js +++ b/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js @@ -30,22 +30,17 @@ describe('code quality issue body issue body', () => { ); }; - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - describe('severity rating', () => { it.each` severity | iconClass | iconName ${'INFO'} | ${'gl-text-blue-400'} | ${'severity-info'} - ${'MINOR'} | ${'gl-text-orange-200'} | ${'severity-low'} + ${'MINOR'} | ${'gl-text-orange-300'} | ${'severity-low'} ${'CRITICAL'} | ${'gl-text-red-600'} | ${'severity-high'} ${'BLOCKER'} | ${'gl-text-red-800'} | ${'severity-critical'} ${'UNKNOWN'} | ${'gl-text-gray-400'} | ${'severity-unknown'} ${'INVALID'} | ${'gl-text-gray-400'} | ${'severity-unknown'} ${'info'} | ${'gl-text-blue-400'} | ${'severity-info'} - ${'minor'} | ${'gl-text-orange-200'} | ${'severity-low'} + ${'minor'} | ${'gl-text-orange-300'} | ${'severity-low'} ${'major'} | ${'gl-text-orange-400'} | ${'severity-medium'} ${'critical'} | ${'gl-text-red-600'} | ${'severity-high'} ${'blocker'} | ${'gl-text-red-800'} | ${'severity-critical'} diff --git a/spec/frontend/ci/reports/components/grouped_issues_list_spec.js b/spec/frontend/ci/reports/components/grouped_issues_list_spec.js index 3e4adfc7794..8beec220802 100644 --- a/spec/frontend/ci/reports/components/grouped_issues_list_spec.js +++ b/spec/frontend/ci/reports/components/grouped_issues_list_spec.js @@ -15,10 +15,6 @@ describe('Grouped Issues List', () => { const findHeading = (groupName) => wrapper.find(`[data-testid="${groupName}Heading"`); - afterEach(() => { - wrapper.destroy(); - }); - it('renders a smart virtual list with the correct props', () => { createComponent({ propsData: { diff --git a/spec/frontend/ci/reports/components/issue_status_icon_spec.js b/spec/frontend/ci/reports/components/issue_status_icon_spec.js index fb13d4407e2..82b655dd598 100644 --- a/spec/frontend/ci/reports/components/issue_status_icon_spec.js +++ b/spec/frontend/ci/reports/components/issue_status_icon_spec.js @@ -13,11 +13,6 @@ describe('IssueStatusIcon', () => { }); }; - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - it.each([STATUS_SUCCESS, STATUS_NEUTRAL, STATUS_FAILED])( 'renders "%s" state correctly', (status) => { diff --git a/spec/frontend/ci/reports/components/report_link_spec.js b/spec/frontend/ci/reports/components/report_link_spec.js index ba541ba0303..4a97afd77df 100644 --- a/spec/frontend/ci/reports/components/report_link_spec.js +++ b/spec/frontend/ci/reports/components/report_link_spec.js @@ -4,10 +4,6 @@ import ReportLink from '~/ci/reports/components/report_link.vue'; describe('app/assets/javascripts/ci/reports/components/report_link.vue', () => { let wrapper; - afterEach(() => { - wrapper.destroy(); - }); - const defaultProps = { issue: {}, }; diff --git a/spec/frontend/ci/reports/components/report_section_spec.js b/spec/frontend/ci/reports/components/report_section_spec.js index f032b210184..f4012fe0215 100644 --- a/spec/frontend/ci/reports/components/report_section_spec.js +++ b/spec/frontend/ci/reports/components/report_section_spec.js @@ -49,10 +49,6 @@ describe('ReportSection component', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('computed', () => { describe('isCollapsible', () => { const testMatrix = [ diff --git a/spec/frontend/ci/reports/components/summary_row_spec.js b/spec/frontend/ci/reports/components/summary_row_spec.js index fb2ae5371d5..b1ae9e26b5b 100644 --- a/spec/frontend/ci/reports/components/summary_row_spec.js +++ b/spec/frontend/ci/reports/components/summary_row_spec.js @@ -31,11 +31,6 @@ describe('Summary row', () => { const findStatusIcon = () => wrapper.findByTestId('summary-row-icon'); const findHelpPopover = () => wrapper.findComponent(HelpPopover); - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - it('renders provided summary', () => { createComponent(); expect(findSummary().text()).toContain(summary); diff --git a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js index edf3d1706cc..4c56dd74f1a 100644 --- a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js +++ b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js @@ -1,40 +1,47 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; import { GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; import AdminNewRunnerApp from '~/ci/runner/admin_new_runner/admin_new_runner_app.vue'; -import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue'; +import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue'; +import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; -import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; -import { DEFAULT_PLATFORM } from '~/ci/runner/constants'; +import { + PARAM_KEY_PLATFORM, + INSTANCE_TYPE, + DEFAULT_PLATFORM, + WINDOWS_PLATFORM, +} from '~/ci/runner/constants'; +import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { runnerCreateResult } from '../mock_data'; -const mockLegacyRegistrationToken = 'LEGACY_REGISTRATION_TOKEN'; +jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); +jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + redirectTo: jest.fn(), +})); -Vue.use(VueApollo); +const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner; describe('AdminNewRunnerApp', () => { let wrapper; - const findLegacyInstructionsLink = () => wrapper.findByTestId('legacy-instructions-link'); - const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); const findRunnerPlatformsRadioGroup = () => wrapper.findComponent(RunnerPlatformsRadioGroup); - const findRunnerFormFields = () => wrapper.findComponent(RunnerFormFields); + const findRegistrationFeedbackBanner = () => wrapper.findComponent(RegistrationFeedbackBanner); + const findRegistrationCompatibilityAlert = () => + wrapper.findComponent(RegistrationCompatibilityAlert); + const findRunnerCreateForm = () => wrapper.findComponent(RunnerCreateForm); - const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { - wrapper = mountFn(AdminNewRunnerApp, { - propsData: { - legacyRegistrationToken: mockLegacyRegistrationToken, - ...props, - }, - directives: { - GlModal: createMockDirective(), - }, + const createComponent = () => { + wrapper = shallowMountExtended(AdminNewRunnerApp, { stubs: { GlSprintf, }, - ...options, }); }; @@ -42,39 +49,71 @@ describe('AdminNewRunnerApp', () => { createComponent(); }); - describe('Shows legacy modal', () => { - it('passes legacy registration to modal', () => { - expect(findRunnerInstructionsModal().props('registrationToken')).toEqual( - mockLegacyRegistrationToken, - ); - }); + it('shows a registration feedback banner', () => { + expect(findRegistrationFeedbackBanner().exists()).toBe(true); + }); - it('opens a modal with the legacy instructions', () => { - const modalId = getBinding(findLegacyInstructionsLink().element, 'gl-modal').value; + it('shows a registration compatibility alert', () => { + expect(findRegistrationCompatibilityAlert().props('alertKey')).toBe(INSTANCE_TYPE); + }); - expect(findRunnerInstructionsModal().props('modalId')).toBe(modalId); + describe('Platform', () => { + it('shows the platforms radio group', () => { + expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM); }); }); - describe('New runner form fields', () => { - describe('Platform', () => { - it('shows the platforms radio group', () => { - expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM); + describe('Runner form', () => { + it('shows the runner create form for an instance runner', () => { + expect(findRunnerCreateForm().props()).toEqual({ + runnerType: INSTANCE_TYPE, + groupId: null, + projectId: null, }); }); - describe('Runner', () => { - it('shows the runners fields', () => { - expect(findRunnerFormFields().props('value')).toEqual({ - accessLevel: 'NOT_PROTECTED', - paused: false, - description: '', - maintenanceNote: '', - maximumTimeout: ' ', - runUntagged: false, - tagList: '', + describe('When a runner is saved', () => { + beforeEach(() => { + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + }); + + it('pushes an alert to be shown after redirection', () => { + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: s__('Runners|Runner created.'), + variant: VARIANT_SUCCESS, }); }); + + it('redirects to the registration page', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; + + expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + }); + }); + + describe('When another platform is selected and a runner is saved', () => { + beforeEach(() => { + findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM); + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + }); + + it('redirects to the registration page with the platform', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; + + expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + }); + }); + + describe('When runner fails to save', () => { + const ERROR_MSG = 'Cannot save!'; + + beforeEach(() => { + findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG)); + }); + + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG }); + }); }); }); }); diff --git a/spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js b/spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js new file mode 100644 index 00000000000..60244ba5bc2 --- /dev/null +++ b/spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js @@ -0,0 +1,122 @@ +import { nextTick } from 'vue'; +import { GlButton } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; + +import { updateHistory } from '~/lib/utils/url_utility'; +import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM, WINDOWS_PLATFORM } from '~/ci/runner/constants'; +import AdminRegisterRunnerApp from '~/ci/runner/admin_register_runner/admin_register_runner_app.vue'; +import RegistrationInstructions from '~/ci/runner/components/registration/registration_instructions.vue'; +import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawer.vue'; +import { runnerForRegistration } from '../mock_data'; + +const mockRunnerId = runnerForRegistration.data.runner.id; +const mockRunnersPath = '/admin/runners'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +describe('AdminRegisterRunnerApp', () => { + let wrapper; + + const findRegistrationInstructions = () => wrapper.findComponent(RegistrationInstructions); + const findPlatformsDrawer = () => wrapper.findComponent(PlatformsDrawer); + const findBtn = () => wrapper.findComponent(GlButton); + + const createComponent = () => { + wrapper = shallowMountExtended(AdminRegisterRunnerApp, { + propsData: { + runnerId: mockRunnerId, + runnersPath: mockRunnersPath, + }, + }); + }; + + describe('When showing runner details', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when runner token is available', () => { + it('shows registration instructions', () => { + expect(findRegistrationInstructions().props()).toEqual({ + platform: DEFAULT_PLATFORM, + runnerId: mockRunnerId, + }); + }); + + it('configures platform drawer', () => { + expect(findPlatformsDrawer().props()).toEqual({ + open: false, + platform: DEFAULT_PLATFORM, + }); + }); + + it('shows runner list button', () => { + expect(findBtn().attributes('href')).toBe(mockRunnersPath); + expect(findBtn().props('variant')).toBe('confirm'); + }); + }); + }); + + describe('When another platform has been selected', () => { + beforeEach(() => { + setWindowLocation(`?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`); + + createComponent(); + }); + + it('shows registration instructions for the platform', () => { + expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM); + }); + }); + + describe('When opening install instructions', () => { + beforeEach(async () => { + createComponent(); + + findRegistrationInstructions().vm.$emit('toggleDrawer'); + await nextTick(); + }); + + it('opens platform drawer', () => { + expect(findPlatformsDrawer().props('open')).toBe(true); + }); + + it('closes platform drawer', async () => { + findRegistrationInstructions().vm.$emit('toggleDrawer'); + await nextTick(); + + expect(findPlatformsDrawer().props('open')).toBe(false); + }); + + it('closes platform drawer from drawer', async () => { + findPlatformsDrawer().vm.$emit('close'); + await nextTick(); + + expect(findPlatformsDrawer().props('open')).toBe(false); + }); + + describe('when selecting a platform', () => { + beforeEach(async () => { + findPlatformsDrawer().vm.$emit('selectPlatform', WINDOWS_PLATFORM); + await nextTick(); + }); + + it('updates the url', () => { + expect(updateHistory).toHaveBeenCalledTimes(1); + expect(updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`, + }); + }); + + it('updates the registration instructions', () => { + expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM); + }); + }); + }); +}); 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 ed4f43c12d8..9787b1ef83f 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 @@ -4,8 +4,8 @@ import VueApollo from 'vue-apollo'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert, VARIANT_SUCCESS } from '~/flash'; -import { redirectTo } from '~/lib/utils/url_utility'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/ci/runner/components/runner_header.vue'; @@ -24,7 +24,7 @@ import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_al import { runnerData } from '../mock_data'; jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); jest.mock('~/lib/utils/url_utility'); @@ -72,7 +72,6 @@ describe('AdminRunnerShowApp', () => { afterEach(() => { mockRunnerQuery.mockReset(); - wrapper.destroy(); }); describe('When showing runner details', () => { @@ -82,7 +81,7 @@ describe('AdminRunnerShowApp', () => { await createComponent({ mountFn: mountExtended }); }); - it('expect GraphQL ID to be requested', async () => { + it('expect GraphQL ID to be requested', () => { expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); }); @@ -90,7 +89,7 @@ describe('AdminRunnerShowApp', () => { expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); }); - it('displays the runner edit and pause buttons', async () => { + it('displays the runner edit and pause buttons', () => { expect(findRunnerEditButton().attributes('href')).toBe(mockRunner.editAdminUrl); expect(findRunnerPauseButton().exists()).toBe(true); expect(findRunnerDeleteButton().exists()).toBe(true); @@ -100,7 +99,7 @@ describe('AdminRunnerShowApp', () => { expect(findRunnerDetailsTabs().props('runner')).toEqual(mockRunner); }); - it('shows basic runner details', async () => { + it('shows basic runner details', () => { const expected = `Description My Runner Last contact Never contacted Version 1.0.0 @@ -181,7 +180,7 @@ describe('AdminRunnerShowApp', () => { message: 'Runner deleted', variant: VARIANT_SUCCESS, }); - expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); + expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); // eslint-disable-line import/no-deprecated }); }); 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 7fc240e520b..c3d33c88422 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 @@ -9,7 +9,7 @@ import { mountExtended, } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -57,20 +57,18 @@ import { allRunnersDataPaginated, onlineContactTimeoutSecs, staleTimeoutSecs, + mockRegistrationToken, newRunnerPath, emptyPageInfo, - emptyStateSvgPath, - emptyStateFilteredSvgPath, } from '../mock_data'; -const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; const mockRunners = allRunnersData.data.runners.nodes; const mockRunnersCount = runnersCountData.data.runners.count; const mockRunnersHandler = jest.fn(); const mockRunnersCountHandler = jest.fn(); -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), @@ -122,8 +120,6 @@ describe('AdminRunnersApp', () => { localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, - emptyStateSvgPath, - emptyStateFilteredSvgPath, ...provide, }, ...options, @@ -143,7 +139,6 @@ describe('AdminRunnersApp', () => { mockRunnersHandler.mockReset(); mockRunnersCountHandler.mockReset(); showToast.mockReset(); - wrapper.destroy(); }); it('shows the runner setup instructions', () => { @@ -209,13 +204,13 @@ describe('AdminRunnersApp', () => { it('runner item links to the runner admin page', async () => { await createComponent({ mountFn: mountExtended }); - const { id, shortSha } = mockRunners[0]; + const { id, shortSha, adminUrl } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink); expect(runnerLink.text()).toBe(`#${numericId} (${shortSha})`); - expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`); + expect(runnerLink.attributes('href')).toBe(adminUrl); }); it('renders runner actions for each runner', async () => { @@ -265,7 +260,7 @@ describe('AdminRunnersApp', () => { }); describe('Single runner row', () => { - const { id: graphqlId, shortSha } = mockRunners[0]; + const { id: graphqlId, shortSha, adminUrl } = mockRunners[0]; const id = getIdFromGraphQLId(graphqlId); beforeEach(async () => { @@ -274,11 +269,11 @@ describe('AdminRunnersApp', () => { await createComponent({ mountFn: mountExtended }); }); - it('Links to the runner page', async () => { + it('Links to the runner page', () => { const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink); expect(runnerLink.text()).toBe(`#${id} (${shortSha})`); - expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`); + expect(runnerLink.attributes('href')).toBe(adminUrl); }); it('Shows job status and links to jobs', () => { @@ -287,13 +282,10 @@ describe('AdminRunnersApp', () => { .findComponent(RunnerJobStatusBadge); expect(badge.props('jobStatus')).toBe(mockRunners[0].jobExecutionStatus); - - const badgeHref = new URL(badge.attributes('href')); - expect(badgeHref.pathname).toBe(`/admin/runners/${id}`); - expect(badgeHref.hash).toBe(`#${JOBS_ROUTE_PATH}`); + expect(badge.attributes('href')).toBe(`${adminUrl}#${JOBS_ROUTE_PATH}`); }); - it('When runner is paused or unpaused, some data is refetched', async () => { + it('When runner is paused or unpaused, some data is refetched', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); findRunnerActionsCell().vm.$emit('toggledPaused'); @@ -302,7 +294,7 @@ describe('AdminRunnersApp', () => { expect(showToast).toHaveBeenCalledTimes(0); }); - it('When runner is deleted, data is refetched and a toast message is shown', async () => { + it('When runner is deleted, data is refetched and a toast message is shown', () => { findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); expect(showToast).toHaveBeenCalledTimes(1); @@ -325,7 +317,7 @@ describe('AdminRunnersApp', () => { { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, { type: PARAM_KEY_PAUSED, value: { data: 'true', operator: '=' } }, ], - sort: 'CREATED_DESC', + sort: DEFAULT_SORT, pagination: {}, }); }); @@ -392,7 +384,7 @@ describe('AdminRunnersApp', () => { it('when runners have not loaded, shows a loading state', () => { createComponent(); expect(findRunnerList().props('loading')).toBe(true); - expect(findRunnerPagination().attributes('disabled')).toBe('true'); + expect(findRunnerPagination().attributes('disabled')).toBeDefined(); }); describe('Bulk delete', () => { @@ -411,7 +403,7 @@ describe('AdminRunnersApp', () => { await createComponent({ mountFn: mountExtended }); }); - it('count data is refetched', async () => { + it('count data is refetched', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); @@ -419,7 +411,7 @@ describe('AdminRunnersApp', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); }); - it('toast is shown', async () => { + it('toast is shown', () => { expect(showToast).toHaveBeenCalledTimes(0); findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); @@ -452,9 +444,7 @@ describe('AdminRunnersApp', () => { expect(findRunnerListEmptyState().props()).toEqual({ newRunnerPath, isSearchFiltered: false, - filteredSvgPath: emptyStateFilteredSvgPath, registrationToken: mockRegistrationToken, - svgPath: emptyStateSvgPath, }); }); @@ -481,11 +471,11 @@ describe('AdminRunnersApp', () => { await createComponent(); }); - it('error is shown to the user', async () => { + it('error is shown to the user', () => { expect(createAlert).toHaveBeenCalledTimes(1); }); - it('error is reported to sentry', async () => { + it('error is reported to sentry', () => { expect(captureException).toHaveBeenCalledWith({ error: new Error('Error!'), component: 'AdminRunnersApp', diff --git a/spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js index 82e262d1b73..8ac0c5a61f8 100644 --- a/spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js @@ -31,10 +31,6 @@ describe('RunnerActionsCell', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('Edit Action', () => { it('Displays the runner edit link with the correct href', () => { createComponent(); diff --git a/spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js index 3097e43e583..03f1ace3897 100644 --- a/spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js @@ -16,7 +16,7 @@ describe('RunnerOwnerCell', () => { const createComponent = ({ runner } = {}) => { wrapper = shallowMount(RunnerOwnerCell, { directives: { - GlTooltip: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), }, propsData: { runner, @@ -24,10 +24,6 @@ describe('RunnerOwnerCell', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('When its an instance runner', () => { beforeEach(() => { createComponent({ diff --git a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js index 1ff60ff1a9d..c435dd57de2 100644 --- a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import RunnerStatusCell from '~/ci/runner/components/cells/runner_status_cell.vue'; import RunnerStatusBadge from '~/ci/runner/components/runner_status_badge.vue'; @@ -20,7 +20,7 @@ describe('RunnerStatusCell', () => { const findPausedBadge = () => wrapper.findComponent(RunnerPausedBadge); const createComponent = ({ runner = {}, ...options } = {}) => { - wrapper = mount(RunnerStatusCell, { + wrapper = shallowMount(RunnerStatusCell, { propsData: { runner: { runnerType: INSTANCE_TYPE, @@ -30,14 +30,14 @@ describe('RunnerStatusCell', () => { ...runner, }, }, + stubs: { + RunnerStatusBadge, + RunnerPausedBadge, + }, ...options, }); }; - afterEach(() => { - wrapper.destroy(); - }); - it('Displays online status', () => { createComponent(); diff --git a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js index 1711df42491..64e9c11a584 100644 --- a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js @@ -1,5 +1,6 @@ -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; @@ -11,11 +12,13 @@ import { I18N_INSTANCE_TYPE, PROJECT_TYPE, I18N_NO_DESCRIPTION, + I18N_CREATED_AT_LABEL, + I18N_CREATED_AT_BY_LABEL, } from '~/ci/runner/constants'; -import { allRunnersData } from '../../mock_data'; +import { allRunnersWithCreatorData } from '../../mock_data'; -const mockRunner = allRunnersData.data.runners.nodes[0]; +const mockRunner = allRunnersWithCreatorData.data.runners.nodes[0]; describe('RunnerTypeCell', () => { let wrapper; @@ -45,10 +48,6 @@ describe('RunnerTypeCell', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Displays the runner name as id and short token', () => { expect(wrapper.text()).toContain( `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`, @@ -83,14 +82,15 @@ describe('RunnerTypeCell', () => { it('Displays the runner description', () => { expect(wrapper.text()).toContain(mockRunner.description); + expect(wrapper.findByText(I18N_NO_DESCRIPTION).exists()).toBe(false); }); - it('Displays the no runner description', () => { + it('Displays "No description" for missing runner description', () => { createComponent({ description: null, }); - expect(wrapper.text()).toContain(I18N_NO_DESCRIPTION); + expect(wrapper.findByText(I18N_NO_DESCRIPTION).classes()).toContain('gl-text-secondary'); }); it('Displays last contact', () => { @@ -146,10 +146,42 @@ describe('RunnerTypeCell', () => { expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+'); }); - it('Displays created at', () => { - expect(findRunnerSummaryField('calendar').findComponent(TimeAgo).props('time')).toBe( - mockRunner.createdAt, - ); + describe('Displays creation info', () => { + const findCreatedTime = () => findRunnerSummaryField('calendar').findComponent(TimeAgo); + + it('Displays created at ...', () => { + createComponent({ + createdBy: null, + }); + + expect(findRunnerSummaryField('calendar').text()).toMatchInterpolatedText( + sprintf(I18N_CREATED_AT_LABEL, { + timeAgo: findCreatedTime().text(), + }), + ); + expect(findCreatedTime().props('time')).toBe(mockRunner.createdAt); + }); + + it('Displays created at ... by ...', () => { + expect(findRunnerSummaryField('calendar').text()).toMatchInterpolatedText( + sprintf(I18N_CREATED_AT_BY_LABEL, { + timeAgo: findCreatedTime().text(), + avatar: mockRunner.createdBy.username, + }), + ); + expect(findCreatedTime().props('time')).toBe(mockRunner.createdAt); + }); + + it('Displays creator avatar', () => { + const { name, avatarUrl, webUrl, username } = mockRunner.createdBy; + + expect(wrapper.findComponent(UserAvatarLink).props()).toMatchObject({ + imgAlt: expect.stringContaining(name), + imgSrc: avatarUrl, + linkHref: webUrl, + tooltipText: username, + }); + }); }); it('Displays tag list', () => { diff --git a/spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js index f536e0dcbcf..7748890cf77 100644 --- a/spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js @@ -17,16 +17,12 @@ describe('RunnerSummaryField', () => { ...props, }, directives: { - GlTooltip: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), }, ...options, }); }; - afterEach(() => { - wrapper.destroy(); - }); - it('shows content in slot', () => { createComponent({ slots: { default: 'content' }, diff --git a/spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap b/spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap new file mode 100644 index 00000000000..5eb7ffaacd6 --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap @@ -0,0 +1,201 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`registration utils for "linux" platform commandPrompt is correct 1`] = `"$"`; + +exports[`registration utils for "linux" platform installScript is correct for "386" architecture 1`] = ` +"# Download the binary for your system +sudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-386 + +# Give it permission to execute +sudo chmod +x /usr/local/bin/gitlab-runner + +# Create a GitLab Runner user +sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash + +# Install and run as a service +sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner +sudo gitlab-runner start" +`; + +exports[`registration utils for "linux" platform installScript is correct for "amd64" architecture 1`] = ` +"# Download the binary for your system +sudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64 + +# Give it permission to execute +sudo chmod +x /usr/local/bin/gitlab-runner + +# Create a GitLab Runner user +sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash + +# Install and run as a service +sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner +sudo gitlab-runner start" +`; + +exports[`registration utils for "linux" platform installScript is correct for "arm" architecture 1`] = ` +"# Download the binary for your system +sudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm + +# Give it permission to execute +sudo chmod +x /usr/local/bin/gitlab-runner + +# Create a GitLab Runner user +sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash + +# Install and run as a service +sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner +sudo gitlab-runner start" +`; + +exports[`registration utils for "linux" platform installScript is correct for "arm64" architecture 1`] = ` +"# Download the binary for your system +sudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm64 + +# Give it permission to execute +sudo chmod +x /usr/local/bin/gitlab-runner + +# Create a GitLab Runner user +sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash + +# Install and run as a service +sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner +sudo gitlab-runner start" +`; + +exports[`registration utils for "linux" platform platformArchitectures returns correct list of architectures 1`] = ` +Array [ + "amd64", + "386", + "arm", + "arm64", +] +`; + +exports[`registration utils for "linux" platform registerCommand is correct 1`] = ` +Array [ + "gitlab-runner register", + " --url http://test.host", + " --token MOCK_AUTHENTICATION_TOKEN", +] +`; + +exports[`registration utils for "linux" platform registerCommand is correct 2`] = ` +Array [ + "gitlab-runner register", + " --url http://test.host", +] +`; + +exports[`registration utils for "linux" platform runCommand is correct 1`] = `"gitlab-runner run"`; + +exports[`registration utils for "osx" platform commandPrompt is correct 1`] = `"$"`; + +exports[`registration utils for "osx" platform installScript is correct for "amd64" architecture 1`] = ` +"# Download the binary for your system +sudo curl --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64 + +# Give it permission to execute +sudo chmod +x /usr/local/bin/gitlab-runner + +# The rest of the commands execute as the user who will run the runner +# Register the runner (steps below), then run +cd ~ +gitlab-runner install +gitlab-runner start" +`; + +exports[`registration utils for "osx" platform installScript is correct for "arm64" architecture 1`] = ` +"# Download the binary for your system +sudo curl --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-arm64 + +# Give it permission to execute +sudo chmod +x /usr/local/bin/gitlab-runner + +# The rest of the commands execute as the user who will run the runner +# Register the runner (steps below), then run +cd ~ +gitlab-runner install +gitlab-runner start" +`; + +exports[`registration utils for "osx" platform platformArchitectures returns correct list of architectures 1`] = ` +Array [ + "amd64", + "arm64", +] +`; + +exports[`registration utils for "osx" platform registerCommand is correct 1`] = ` +Array [ + "gitlab-runner register", + " --url http://test.host", + " --token MOCK_AUTHENTICATION_TOKEN", +] +`; + +exports[`registration utils for "osx" platform registerCommand is correct 2`] = ` +Array [ + "gitlab-runner register", + " --url http://test.host", +] +`; + +exports[`registration utils for "osx" platform runCommand is correct 1`] = `"gitlab-runner run"`; + +exports[`registration utils for "windows" platform commandPrompt is correct 1`] = `">"`; + +exports[`registration utils for "windows" platform installScript is correct for "386" architecture 1`] = ` +"# Run PowerShell: https://docs.microsoft.com/en-us/powershell/scripting/windows-powershell/starting-windows-powershell?view=powershell-7#with-administrative-privileges-run-as-administrator +# Create a folder somewhere on your system, for example: C:\\\\GitLab-Runner +New-Item -Path 'C:\\\\GitLab-Runner' -ItemType Directory + +# Change to the folder +cd 'C:\\\\GitLab-Runner' + +# Download binary +Invoke-WebRequest -Uri \\"https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-386.exe\\" -OutFile \\"gitlab-runner.exe\\" + +# Register the runner (steps below), then run +.\\\\gitlab-runner.exe install +.\\\\gitlab-runner.exe start" +`; + +exports[`registration utils for "windows" platform installScript is correct for "amd64" architecture 1`] = ` +"# Run PowerShell: https://docs.microsoft.com/en-us/powershell/scripting/windows-powershell/starting-windows-powershell?view=powershell-7#with-administrative-privileges-run-as-administrator +# Create a folder somewhere on your system, for example: C:\\\\GitLab-Runner +New-Item -Path 'C:\\\\GitLab-Runner' -ItemType Directory + +# Change to the folder +cd 'C:\\\\GitLab-Runner' + +# Download binary +Invoke-WebRequest -Uri \\"https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-amd64.exe\\" -OutFile \\"gitlab-runner.exe\\" + +# Register the runner (steps below), then run +.\\\\gitlab-runner.exe install +.\\\\gitlab-runner.exe start" +`; + +exports[`registration utils for "windows" platform platformArchitectures returns correct list of architectures 1`] = ` +Array [ + "amd64", + "386", +] +`; + +exports[`registration utils for "windows" platform registerCommand is correct 1`] = ` +Array [ + ".\\\\gitlab-runner.exe register", + " --url http://test.host", + " --token MOCK_AUTHENTICATION_TOKEN", +] +`; + +exports[`registration utils for "windows" platform registerCommand is correct 2`] = ` +Array [ + ".\\\\gitlab-runner.exe register", + " --url http://test.host", +] +`; + +exports[`registration utils for "windows" platform runCommand is correct 1`] = `".\\\\gitlab-runner.exe run"`; diff --git a/spec/frontend/ci/runner/components/registration/cli_command_spec.js b/spec/frontend/ci/runner/components/registration/cli_command_spec.js new file mode 100644 index 00000000000..78c2b94c3ea --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/cli_command_spec.js @@ -0,0 +1,39 @@ +import CliCommand from '~/ci/runner/components/registration/cli_command.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('CliCommand', () => { + let wrapper; + + // use .textContent instead of .text() to capture whitespace that's visible in <pre> + const getPreTextContent = () => wrapper.find('pre').element.textContent; + const getClipboardText = () => wrapper.findComponent(ClipboardButton).props('text'); + + const createComponent = (props) => { + wrapper = shallowMountExtended(CliCommand, { + propsData: { + ...props, + }, + }); + }; + + it('when rendering a command', () => { + createComponent({ + prompt: '#', + command: 'echo hi', + }); + + expect(getPreTextContent()).toBe('# echo hi'); + expect(getClipboardText()).toBe('echo hi'); + }); + + it('when rendering a multi-line command', () => { + createComponent({ + prompt: '#', + command: ['git', ' --version'], + }); + + expect(getPreTextContent()).toBe('# git --version'); + expect(getClipboardText()).toBe('git --version'); + }); +}); diff --git a/spec/frontend/ci/runner/components/registration/platforms_drawer_spec.js b/spec/frontend/ci/runner/components/registration/platforms_drawer_spec.js new file mode 100644 index 00000000000..0b438455b5b --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/platforms_drawer_spec.js @@ -0,0 +1,108 @@ +import { nextTick } from 'vue'; +import { GlDrawer, GlLink, GlIcon, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; + +import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawer.vue'; +import CliCommand from '~/ci/runner/components/registration/cli_command.vue'; +import { + LINUX_PLATFORM, + MACOS_PLATFORM, + WINDOWS_PLATFORM, + INSTALL_HELP_URL, +} from '~/ci/runner/constants'; +import { installScript, platformArchitectures } from '~/ci/runner/components/registration/utils'; + +const MOCK_WRAPPER_HEIGHT = '99px'; +const LINUX_ARCHS = platformArchitectures({ platform: LINUX_PLATFORM }); +const MACOS_ARCHS = platformArchitectures({ platform: MACOS_PLATFORM }); + +jest.mock('~/lib/utils/dom_utils', () => ({ + getContentWrapperHeight: () => MOCK_WRAPPER_HEIGHT, +})); + +describe('RegistrationInstructions', () => { + let wrapper; + + const findDrawer = () => wrapper.findComponent(GlDrawer); + const findEnvironmentOptions = () => + wrapper.findByLabelText(s__('Runners|Environment')).findAll('option'); + const findArchitectureOptions = () => + wrapper.findByLabelText(s__('Runners|Architecture')).findAll('option'); + const findCliCommand = () => wrapper.findComponent(CliCommand); + const findLink = () => wrapper.findComponent(GlLink); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(PlatformsDrawer, { + propsData: { + open: true, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + }; + + it('shows drawer', () => { + createComponent(); + + expect(findDrawer().props()).toMatchObject({ + open: true, + headerHeight: MOCK_WRAPPER_HEIGHT, + }); + }); + + it('closes drawer', () => { + createComponent(); + findDrawer().vm.$emit('close'); + + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('shows selection options', () => { + createComponent({ mountFn: mountExtended }); + + expect(findEnvironmentOptions().wrappers.map((w) => w.attributes('value'))).toEqual([ + LINUX_PLATFORM, + MACOS_PLATFORM, + WINDOWS_PLATFORM, + ]); + + expect(findArchitectureOptions().wrappers.map((w) => w.attributes('value'))).toEqual( + LINUX_ARCHS, + ); + }); + + it('shows script', () => { + createComponent(); + + expect(findCliCommand().props('command')).toBe( + installScript({ platform: LINUX_PLATFORM, architecture: LINUX_ARCHS[0] }), + ); + }); + + it('shows selection options for another platform', async () => { + createComponent({ mountFn: mountExtended }); + + findEnvironmentOptions().at(1).setSelected(); // macos + await nextTick(); + + expect(wrapper.emitted('selectPlatform')).toEqual([[MACOS_PLATFORM]]); + + expect(findArchitectureOptions().wrappers.map((w) => w.attributes('value'))).toEqual( + MACOS_ARCHS, + ); + + expect(findCliCommand().props('command')).toBe( + installScript({ platform: MACOS_PLATFORM, architecture: MACOS_ARCHS[0] }), + ); + }); + + it('shows external link for more information', () => { + createComponent(); + + expect(findLink().attributes('href')).toBe(INSTALL_HELP_URL); + expect(findLink().findComponent(GlIcon).props('name')).toBe('external-link'); + }); +}); diff --git a/spec/frontend/ci/runner/components/registration/registration_compatibility_alert_spec.js b/spec/frontend/ci/runner/components/registration/registration_compatibility_alert_spec.js new file mode 100644 index 00000000000..75658270104 --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/registration_compatibility_alert_spec.js @@ -0,0 +1,53 @@ +import { GlAlert, GlLink } from '@gitlab/ui'; +import DismissibleFeedbackAlert from '~/vue_shared/components/dismissible_feedback_alert.vue'; +import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue'; +import { CHANGELOG_URL } from '~/ci/runner/constants'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; + +const ALERT_KEY = 'ALERT_KEY'; + +describe('RegistrationCompatibilityAlert', () => { + let wrapper; + + const findDismissibleFeedbackAlert = () => wrapper.findComponent(DismissibleFeedbackAlert); + const findAlert = () => wrapper.findComponent(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + + const createComponent = ({ mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(RegistrationCompatibilityAlert, { + propsData: { + alertKey: ALERT_KEY, + }, + ...options, + }); + }; + + it('configures a featureName', () => { + createComponent(); + + expect(findDismissibleFeedbackAlert().props('featureName')).toBe( + `new_runner_compatibility_${ALERT_KEY}`, + ); + }); + + it('alert has warning appearance', () => { + createComponent({ + stubs: { + DismissibleFeedbackAlert, + }, + }); + + expect(findAlert().props()).toMatchObject({ + dismissible: true, + variant: 'warning', + title: expect.any(String), + }); + }); + + it('shows alert content and link', () => { + createComponent({ mountFn: mountExtended }); + + expect(findAlert().text()).not.toBe(''); + expect(findLink().attributes('href')).toBe(CHANGELOG_URL); + }); +}); diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js index 0daaca9c4ff..e564cf49ca0 100644 --- a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js @@ -1,10 +1,10 @@ -import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; -import { mount, shallowMount, createWrapper } from '@vue/test-utils'; +import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm, GlIcon } from '@gitlab/ui'; +import { createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { s__ } from '~/locale'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -12,7 +12,14 @@ import RegistrationDropdown from '~/ci/runner/components/registration/registrati import RegistrationToken from '~/ci/runner/components/registration/registration_token.vue'; import RegistrationTokenResetDropdownItem from '~/ci/runner/components/registration/registration_token_reset_dropdown_item.vue'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/ci/runner/constants'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_REGISTER_INSTANCE_TYPE, + I18N_REGISTER_GROUP_TYPE, + I18N_REGISTER_PROJECT_TYPE, +} from '~/ci/runner/constants'; import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql'; import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql'; @@ -21,9 +28,7 @@ import { mockRunnerPlatforms, mockInstructions, } from 'jest/vue_shared/components/runner_instructions/mock_data'; - -const mockToken = '0123456789'; -const maskToken = '**********'; +import { mockRegistrationToken } from '../../mock_data'; Vue.use(VueApollo); @@ -31,7 +36,7 @@ describe('RegistrationDropdown', () => { let wrapper; const findDropdown = () => wrapper.findComponent(GlDropdown); - + const findDropdownBtn = () => findDropdown().find('button'); const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); @@ -53,17 +58,15 @@ describe('RegistrationDropdown', () => { await waitForPromises(); }; - const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { - wrapper = extendedWrapper( - mountFn(RegistrationDropdown, { - propsData: { - registrationToken: mockToken, - type: INSTANCE_TYPE, - ...props, - }, - ...options, - }), - ); + const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(RegistrationDropdown, { + propsData: { + registrationToken: mockRegistrationToken, + type: INSTANCE_TYPE, + ...props, + }, + ...options, + }); }; const createComponentWithModal = () => { @@ -79,27 +82,40 @@ describe('RegistrationDropdown', () => { // Use `attachTo` to find the modal attachTo: document.body, }, - mount, + mountExtended, ); }; it.each` type | text - ${INSTANCE_TYPE} | ${s__('Runners|Register an instance runner')} - ${GROUP_TYPE} | ${s__('Runners|Register a group runner')} - ${PROJECT_TYPE} | ${s__('Runners|Register a project runner')} - `('Dropdown text for type $type is "$text"', () => { - createComponent({ props: { type: INSTANCE_TYPE } }, mount); + ${INSTANCE_TYPE} | ${I18N_REGISTER_INSTANCE_TYPE} + ${GROUP_TYPE} | ${I18N_REGISTER_GROUP_TYPE} + ${PROJECT_TYPE} | ${I18N_REGISTER_PROJECT_TYPE} + `('Dropdown text for type $type is "$text"', ({ type, text }) => { + createComponent({ props: { type } }, mountExtended); - expect(wrapper.text()).toContain('Register an instance runner'); + expect(wrapper.text()).toContain(text); }); - it('Passes attributes to the dropdown component', () => { + it('Passes attributes to dropdown', () => { createComponent({ attrs: { right: true } }); expect(findDropdown().attributes()).toMatchObject({ right: 'true' }); }); + it('Passes default props and attributes to dropdown', () => { + createComponent(); + + expect(findDropdown().props()).toMatchObject({ + category: 'primary', + variant: 'confirm', + }); + + expect(findDropdown().attributes()).toMatchObject({ + toggleclass: '', + }); + }); + describe('Instructions dropdown item', () => { it('Displays "Show runner" dropdown item', () => { createComponent(); @@ -111,15 +127,11 @@ describe('RegistrationDropdown', () => { describe('When the dropdown item is clicked', () => { beforeEach(async () => { - createComponentWithModal({}, mount); + createComponentWithModal({}, mountExtended); await openModal(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('opens the modal with contents', () => { const modalText = findModalContent(); @@ -146,7 +158,15 @@ describe('RegistrationDropdown', () => { }); it('Displays masked value by default', () => { - createComponent({}, mount); + const mockToken = '0123456789'; + const maskToken = '**********'; + + createComponent( + { + props: { registrationToken: mockToken }, + }, + mountExtended, + ); expect(findRegistrationTokenInput().element.value).toBe(maskToken); }); @@ -175,7 +195,7 @@ describe('RegistrationDropdown', () => { }; it('Updates token input', async () => { - createComponent({}, mount); + createComponent({}, mountExtended); expect(findRegistrationToken().props('value')).not.toBe(newToken); @@ -185,15 +205,72 @@ describe('RegistrationDropdown', () => { }); it('Updates token in modal', async () => { - createComponentWithModal({}, mount); + createComponentWithModal({}, mountExtended); await openModal(); - expect(findModalContent()).toContain(mockToken); + expect(findModalContent()).toContain(mockRegistrationToken); await resetToken(); expect(findModalContent()).toContain(newToken); }); }); + + describe.each([ + { createRunnerWorkflowForAdmin: true }, + { createRunnerWorkflowForNamespace: true }, + ])('When showing a "deprecated" warning', (glFeatures) => { + it('passes deprecated variant props and attributes to dropdown', () => { + createComponent({ + provide: { glFeatures }, + }); + + expect(findDropdown().props()).toMatchObject({ + category: 'tertiary', + variant: 'default', + text: '', + }); + + expect(findDropdown().attributes()).toMatchObject({ + toggleclass: 'gl-px-3!', + }); + }); + + it.each` + type | text + ${INSTANCE_TYPE} | ${I18N_REGISTER_INSTANCE_TYPE} + ${GROUP_TYPE} | ${I18N_REGISTER_GROUP_TYPE} + ${PROJECT_TYPE} | ${I18N_REGISTER_PROJECT_TYPE} + `('dropdown text for type $type is "$text"', ({ type, text }) => { + createComponent({ props: { type } }, mountExtended); + + expect(wrapper.text()).toContain(text); + }); + + it('shows warning text', () => { + createComponent( + { + provide: { glFeatures }, + }, + mountExtended, + ); + + const text = wrapper.findByText(s__('Runners|Support for registration tokens is deprecated')); + + expect(text.exists()).toBe(true); + }); + + it('button shows ellipsis icon', () => { + createComponent( + { + provide: { glFeatures }, + }, + mountExtended, + ); + + expect(findDropdownBtn().findComponent(GlIcon).props('name')).toBe('ellipsis_v'); + expect(findDropdownBtn().findAllComponents(GlIcon)).toHaveLength(1); + }); + }); }); diff --git a/spec/frontend/ci/runner/components/registration/registration_feedback_banner_spec.js b/spec/frontend/ci/runner/components/registration/registration_feedback_banner_spec.js new file mode 100644 index 00000000000..fa6b7ad7c63 --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/registration_feedback_banner_spec.js @@ -0,0 +1,52 @@ +import { GlBanner } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; + +describe('Runner registration feeback banner', () => { + let wrapper; + let userCalloutDismissSpy; + + const findUserCalloutDismisser = () => wrapper.findComponent(UserCalloutDismisser); + const findBanner = () => wrapper.findComponent(GlBanner); + + const createComponent = ({ shouldShowCallout = true } = {}) => { + userCalloutDismissSpy = jest.fn(); + + wrapper = shallowMount(RegistrationFeedbackBanner, { + stubs: { + UserCalloutDismisser: makeMockUserCalloutDismisser({ + dismiss: userCalloutDismissSpy, + shouldShowCallout, + }), + }, + }); + }; + + it('banner is shown', () => { + createComponent(); + + expect(findBanner().exists()).toBe(true); + }); + + it('dismisses the callout when closed', () => { + createComponent(); + + findBanner().vm.$emit('close'); + + expect(userCalloutDismissSpy).toHaveBeenCalled(); + }); + + it('sets feature name to create_runner_workflow_banner', () => { + createComponent(); + + expect(findUserCalloutDismisser().props('featureName')).toBe('create_runner_workflow_banner'); + }); + + it('is not displayed once it has been dismissed', () => { + createComponent({ shouldShowCallout: false }); + + expect(findBanner().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/runner/components/registration/registration_instructions_spec.js b/spec/frontend/ci/runner/components/registration/registration_instructions_spec.js new file mode 100644 index 00000000000..8c196d7b5e3 --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/registration_instructions_spec.js @@ -0,0 +1,326 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; + +import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { extendedWrapper, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { TEST_HOST } from 'helpers/test_constants'; + +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import RegistrationInstructions from '~/ci/runner/components/registration/registration_instructions.vue'; +import runnerForRegistrationQuery from '~/ci/runner/graphql/register/runner_for_registration.query.graphql'; +import CliCommand from '~/ci/runner/components/registration/cli_command.vue'; +import { + DEFAULT_PLATFORM, + EXECUTORS_HELP_URL, + SERVICE_COMMANDS_HELP_URL, + STATUS_NEVER_CONTACTED, + STATUS_ONLINE, + RUNNER_REGISTRATION_POLLING_INTERVAL_MS, + I18N_REGISTRATION_SUCCESS, +} from '~/ci/runner/constants'; +import { runnerForRegistration, mockAuthenticationToken } from '../../mock_data'; + +Vue.use(VueApollo); + +const mockRunner = { + ...runnerForRegistration.data.runner, + ephemeralAuthenticationToken: mockAuthenticationToken, +}; +const mockRunnerWithoutToken = { + ...runnerForRegistration.data.runner, + ephemeralAuthenticationToken: null, +}; + +const mockRunnerId = `${getIdFromGraphQLId(mockRunner.id)}`; + +describe('RegistrationInstructions', () => { + let wrapper; + let mockRunnerQuery; + + const findHeading = () => wrapper.find('h1'); + const findStepAt = (i) => extendedWrapper(wrapper.findAll('section').at(i)); + const findByText = (text, container = wrapper) => container.findByText(text); + + const waitForPolling = async () => { + jest.advanceTimersByTime(RUNNER_REGISTRATION_POLLING_INTERVAL_MS); + await waitForPromises(); + }; + + const mockBeforeunload = () => { + const event = new Event('beforeunload'); + const preventDefault = jest.spyOn(event, 'preventDefault'); + const returnValueSetter = jest.spyOn(event, 'returnValue', 'set'); + + return { + event, + preventDefault, + returnValueSetter, + }; + }; + + const mockResolvedRunner = (runner = mockRunner) => { + mockRunnerQuery.mockResolvedValue({ + data: { + runner, + }, + }); + }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(RegistrationInstructions, { + apolloProvider: createMockApollo([[runnerForRegistrationQuery, mockRunnerQuery]]), + propsData: { + runnerId: mockRunnerId, + platform: DEFAULT_PLATFORM, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + mockRunnerQuery = jest.fn(); + mockResolvedRunner(); + }); + + beforeEach(() => { + window.gon.gitlab_url = TEST_HOST; + }); + + it('loads runner with id', () => { + createComponent(); + + expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunner.id }); + }); + + describe('heading', () => { + it('when runner is loaded, shows heading', async () => { + createComponent(); + await waitForPromises(); + + expect(findHeading().text()).toContain(mockRunner.description); + }); + + it('when runner is loaded, shows heading safely', async () => { + mockResolvedRunner({ + ...mockRunner, + description: '<script>hacked();</script>', + }); + + createComponent(); + await waitForPromises(); + + expect(findHeading().text()).toBe('Register "<script>hacked();</script>" runner'); + expect(findHeading().element.innerHTML).toBe( + 'Register "<script>hacked();</script>" runner', + ); + }); + + it('when runner is loading, shows default heading', () => { + createComponent(); + + expect(findHeading().text()).toBe(s__('Runners|Register runner')); + }); + }); + + it('renders legacy instructions', () => { + createComponent(); + + findByText('How do I install GitLab Runner?').vm.$emit('click'); + + expect(wrapper.emitted('toggleDrawer')).toHaveLength(1); + }); + + describe('step 1', () => { + it('renders step 1', async () => { + createComponent(); + await waitForPromises(); + + const step1 = findStepAt(0); + + expect(step1.findComponent(CliCommand).props()).toEqual({ + command: [ + 'gitlab-runner register', + ` --url ${TEST_HOST}`, + ` --token ${mockAuthenticationToken}`, + ], + prompt: '$', + }); + expect(step1.findByTestId('runner-token').text()).toBe(mockAuthenticationToken); + expect(step1.findComponent(ClipboardButton).props('text')).toBe(mockAuthenticationToken); + }); + + it('renders step 1 in loading state', () => { + createComponent(); + + const step1 = findStepAt(0); + + expect(step1.findComponent(GlSkeletonLoader).exists()).toBe(true); + expect(step1.find('code').exists()).toBe(false); + expect(step1.findComponent(ClipboardButton).exists()).toBe(false); + }); + + it('render step 1 after token is not visible', async () => { + mockResolvedRunner(mockRunnerWithoutToken); + + createComponent(); + await waitForPromises(); + + const step1 = findStepAt(0); + + expect(step1.findComponent(CliCommand).props('command')).toEqual([ + 'gitlab-runner register', + ` --url ${TEST_HOST}`, + ]); + expect(step1.findByTestId('runner-token').exists()).toBe(false); + expect(step1.findComponent(ClipboardButton).exists()).toBe(false); + }); + + describe('polling for changes', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('fetches data', () => { + expect(mockRunnerQuery).toHaveBeenCalledTimes(1); + }); + + it('polls', async () => { + await waitForPolling(); + expect(mockRunnerQuery).toHaveBeenCalledTimes(2); + + await waitForPolling(); + expect(mockRunnerQuery).toHaveBeenCalledTimes(3); + }); + + it('when runner is online, stops polling', async () => { + mockResolvedRunner({ ...mockRunner, status: STATUS_ONLINE }); + await waitForPolling(); + + expect(mockRunnerQuery).toHaveBeenCalledTimes(2); + await waitForPolling(); + + expect(mockRunnerQuery).toHaveBeenCalledTimes(2); + }); + + it('when token is no longer visible in the API, it is still visible in the UI', async () => { + mockResolvedRunner(mockRunnerWithoutToken); + await waitForPolling(); + + const step1 = findStepAt(0); + expect(step1.findComponent(CliCommand).props('command')).toEqual([ + 'gitlab-runner register', + ` --url ${TEST_HOST}`, + ` --token ${mockAuthenticationToken}`, + ]); + expect(step1.findByTestId('runner-token').text()).toBe(mockAuthenticationToken); + expect(step1.findComponent(ClipboardButton).props('text')).toBe(mockAuthenticationToken); + }); + + it('when runner is not available (e.g. deleted), the UI does not update', async () => { + mockResolvedRunner(null); + await waitForPolling(); + + const step1 = findStepAt(0); + expect(step1.findComponent(CliCommand).props('command')).toEqual([ + 'gitlab-runner register', + ` --url ${TEST_HOST}`, + ` --token ${mockAuthenticationToken}`, + ]); + expect(step1.findByTestId('runner-token').text()).toBe(mockAuthenticationToken); + expect(step1.findComponent(ClipboardButton).props('text')).toBe(mockAuthenticationToken); + }); + }); + }); + + it('renders step 2', () => { + createComponent(); + const step2 = findStepAt(1); + + expect(findByText('Not sure which one to select?', step2).attributes('href')).toBe( + EXECUTORS_HELP_URL, + ); + }); + + it('renders step 3', () => { + createComponent(); + const step3 = findStepAt(2); + + expect(step3.findComponent(CliCommand).props()).toEqual({ + command: 'gitlab-runner run', + prompt: '$', + }); + + expect(findByText('system or user service', step3).attributes('href')).toBe( + SERVICE_COMMANDS_HELP_URL, + ); + }); + + describe('success state', () => { + describe('when the runner has not been registered', () => { + beforeEach(async () => { + createComponent(); + + await waitForPolling(); + + mockResolvedRunner({ ...mockRunner, status: STATUS_NEVER_CONTACTED }); + + await waitForPolling(); + }); + + it('does not show success message', () => { + expect(wrapper.text()).not.toContain(I18N_REGISTRATION_SUCCESS); + }); + + describe('when the page is closing', () => { + it('warns the user against closing', () => { + const { event, preventDefault, returnValueSetter } = mockBeforeunload(); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(returnValueSetter).not.toHaveBeenCalled(); + + window.dispatchEvent(event); + + expect(preventDefault).toHaveBeenCalledWith(); + expect(returnValueSetter).toHaveBeenCalledWith(expect.any(String)); + }); + }); + }); + + describe('when the runner has been registered', () => { + beforeEach(async () => { + createComponent(); + await waitForPolling(); + + mockResolvedRunner({ ...mockRunner, status: STATUS_ONLINE }); + await waitForPolling(); + }); + + it('shows success message', () => { + expect(wrapper.text()).toContain('🎉'); + expect(wrapper.text()).toContain(I18N_REGISTRATION_SUCCESS); + }); + + describe('when the page is closing', () => { + it('does not warn the user against closing', () => { + const { event, preventDefault, returnValueSetter } = mockBeforeunload(); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(returnValueSetter).not.toHaveBeenCalled(); + + window.dispatchEvent(event); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(returnValueSetter).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js index 783a4d9252a..bfdde922e17 100644 --- a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -5,20 +5,20 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import RegistrationTokenResetDropdownItem from '~/ci/runner/components/registration/registration_token_reset_dropdown_item.vue'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/ci/runner/constants'; import runnersRegistrationTokenResetMutation from '~/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql'; import { captureException } from '~/ci/runner/sentry_utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); Vue.use(VueApollo); Vue.use(GlToast); -const mockNewToken = 'NEW_TOKEN'; +const mockNewRegistrationToken = 'MOCK_NEW_REGISTRATION_TOKEN'; const modalID = 'token-reset-modal'; describe('RegistrationTokenResetDropdownItem', () => { @@ -43,7 +43,7 @@ describe('RegistrationTokenResetDropdownItem', () => { [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler], ]), directives: { - GlModal: createMockDirective(), + GlModal: createMockDirective('gl-modal'), }, }); @@ -54,7 +54,7 @@ describe('RegistrationTokenResetDropdownItem', () => { runnersRegistrationTokenResetMutationHandler = jest.fn().mockResolvedValue({ data: { runnersRegistrationTokenReset: { - token: mockNewToken, + token: mockNewRegistrationToken, errors: [], }, }, @@ -63,10 +63,6 @@ describe('RegistrationTokenResetDropdownItem', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Displays reset button', () => { expect(findDropdownItem().exists()).toBe(true); }); @@ -113,7 +109,7 @@ describe('RegistrationTokenResetDropdownItem', () => { it('emits result', () => { expect(wrapper.emitted('tokenReset')).toHaveLength(1); - expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]); + expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewRegistrationToken]); }); it('does not show a loading state', () => { diff --git a/spec/frontend/ci/runner/components/registration/registration_token_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_spec.js index d2a51c0d910..869c032c0b5 100644 --- a/spec/frontend/ci/runner/components/registration/registration_token_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_token_spec.js @@ -3,9 +3,7 @@ import Vue from 'vue'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RegistrationToken from '~/ci/runner/components/registration/registration_token.vue'; import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; - -const mockToken = '01234567890'; -const mockMasked = '***********'; +import { mockRegistrationToken } from '../../mock_data'; describe('RegistrationToken', () => { let wrapper; @@ -15,26 +13,23 @@ describe('RegistrationToken', () => { const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility); - const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { wrapper = mountFn(RegistrationToken, { propsData: { - value: mockToken, + value: mockRegistrationToken, inputId: 'token-value', ...props, }, + ...options, }); showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; }; - afterEach(() => { - wrapper.destroy(); - }); - it('Displays value and copy button', () => { createComponent(); - expect(findInputCopyToggleVisibility().props('value')).toBe(mockToken); + expect(findInputCopyToggleVisibility().props('value')).toBe(mockRegistrationToken); expect(findInputCopyToggleVisibility().props('copyButtonTitle')).toBe( 'Copy registration token', ); @@ -42,9 +37,17 @@ describe('RegistrationToken', () => { // Component integration test to ensure secure masking it('Displays masked value by default', () => { - createComponent({ mountFn: mountExtended }); + const mockToken = '0123456789'; + const maskToken = '**********'; + + createComponent({ + props: { + value: mockToken, + }, + mountFn: mountExtended, + }); - expect(wrapper.find('input').element.value).toBe(mockMasked); + expect(wrapper.find('input').element.value).toBe(maskToken); }); describe('When the copy to clipboard button is clicked', () => { @@ -59,4 +62,23 @@ describe('RegistrationToken', () => { expect(showToast).toHaveBeenCalledWith('Registration token copied!'); }); }); + + describe('When slots are used', () => { + const slotName = 'label-description'; + const slotContent = 'Label Description'; + + beforeEach(() => { + createComponent({ + slots: { + [slotName]: slotContent, + }, + }); + }); + + it('passes slots to the input component', () => { + const slot = findInputCopyToggleVisibility().vm.$scopedSlots[slotName]; + + expect(slot()[0].text).toBe(slotContent); + }); + }); }); diff --git a/spec/frontend/ci/runner/components/registration/utils_spec.js b/spec/frontend/ci/runner/components/registration/utils_spec.js new file mode 100644 index 00000000000..997cc5769ee --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/utils_spec.js @@ -0,0 +1,94 @@ +import { TEST_HOST } from 'helpers/test_constants'; +import { + DEFAULT_PLATFORM, + LINUX_PLATFORM, + MACOS_PLATFORM, + WINDOWS_PLATFORM, +} from '~/ci/runner/constants'; + +import { + commandPrompt, + registerCommand, + runCommand, + installScript, + platformArchitectures, +} from '~/ci/runner/components/registration/utils'; + +import { mockAuthenticationToken } from '../../mock_data'; + +describe('registration utils', () => { + beforeEach(() => { + window.gon.gitlab_url = TEST_HOST; + }); + + describe.each([LINUX_PLATFORM, MACOS_PLATFORM, WINDOWS_PLATFORM])( + 'for "%s" platform', + (platform) => { + it('commandPrompt is correct', () => { + expect(commandPrompt({ platform })).toMatchSnapshot(); + }); + + it('registerCommand is correct', () => { + expect( + registerCommand({ + platform, + token: mockAuthenticationToken, + }), + ).toMatchSnapshot(); + + expect(registerCommand({ platform })).toMatchSnapshot(); + }); + + it('runCommand is correct', () => { + expect(runCommand({ platform })).toMatchSnapshot(); + }); + }, + ); + + describe('for missing platform', () => { + it('commandPrompt uses the default', () => { + const expected = commandPrompt({ platform: DEFAULT_PLATFORM }); + + expect(commandPrompt({ platform: null })).toEqual(expected); + expect(commandPrompt({ platform: undefined })).toEqual(expected); + }); + + it('registerCommand uses the default', () => { + const expected = registerCommand({ + platform: DEFAULT_PLATFORM, + token: mockAuthenticationToken, + }); + + expect(registerCommand({ platform: null, token: mockAuthenticationToken })).toEqual(expected); + expect(registerCommand({ platform: undefined, token: mockAuthenticationToken })).toEqual( + expected, + ); + }); + + it('runCommand uses the default', () => { + const expected = runCommand({ platform: DEFAULT_PLATFORM }); + + expect(runCommand({ platform: null })).toEqual(expected); + expect(runCommand({ platform: undefined })).toEqual(expected); + }); + }); + + describe.each([LINUX_PLATFORM, MACOS_PLATFORM, WINDOWS_PLATFORM])( + 'for "%s" platform', + (platform) => { + describe('platformArchitectures', () => { + it('returns correct list of architectures', () => { + expect(platformArchitectures({ platform })).toMatchSnapshot(); + }); + }); + + describe('installScript', () => { + const architectures = platformArchitectures({ platform }); + + it.each(architectures)('is correct for "%s" architecture', (architecture) => { + expect(installScript({ platform, architecture })).toMatchSnapshot(); + }); + }); + }, + ); +}); diff --git a/spec/frontend/ci/runner/components/runner_assigned_item_spec.js b/spec/frontend/ci/runner/components/runner_assigned_item_spec.js index 5df2e04c340..a1fd9e4c1aa 100644 --- a/spec/frontend/ci/runner/components/runner_assigned_item_spec.js +++ b/spec/frontend/ci/runner/components/runner_assigned_item_spec.js @@ -33,10 +33,6 @@ describe('RunnerAssignedItem', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Shows an avatar', () => { const avatar = findAvatar(); diff --git a/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js b/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js index 0dc5a90fb83..7bd4b701002 100644 --- a/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js +++ b/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js @@ -2,12 +2,13 @@ import Vue from 'vue'; import { makeVar } from '@apollo/client/core'; import { GlModal, GlSprintf } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { s__ } from '~/locale'; import RunnerBulkDelete from '~/ci/runner/components/runner_bulk_delete.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import BulkRunnerDeleteMutation from '~/ci/runner/graphql/list/bulk_runner_delete.mutation.graphql'; import { createLocalState } from '~/ci/runner/graphql/list/local_state'; import waitForPromises from 'helpers/wait_for_promises'; @@ -15,7 +16,7 @@ import { allRunnersData } from '../mock_data'; Vue.use(VueApollo); -jest.mock('~/flash'); +jest.mock('~/alert'); describe('RunnerBulkDelete', () => { let wrapper; @@ -34,7 +35,7 @@ describe('RunnerBulkDelete', () => { const bulkRunnerDeleteHandler = jest.fn(); - const createComponent = () => { + const createComponent = ({ stubs } = {}) => { const { cacheConfig, localMutations } = mockState; const apolloProvider = createMockApollo( [[BulkRunnerDeleteMutation, bulkRunnerDeleteHandler]], @@ -51,11 +52,12 @@ describe('RunnerBulkDelete', () => { runners: mockRunners, }, directives: { - GlTooltip: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), }, stubs: { GlSprintf, GlModal, + ...stubs, }, }); @@ -135,11 +137,15 @@ describe('RunnerBulkDelete', () => { beforeEach(() => { mockCheckedRunnerIds([mockId1, mockId2]); + mockHideModal = jest.fn(); - createComponent(); + createComponent({ + stubs: { + GlModal: stubComponent(GlModal, { methods: { hide: mockHideModal } }), + }, + }); jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {}); - mockHideModal = jest.spyOn(findModal().vm, 'hide').mockImplementation(() => {}); }); describe('when deletion is confirmed', () => { diff --git a/spec/frontend/ci/runner/components/runner_create_form_spec.js b/spec/frontend/ci/runner/components/runner_create_form_spec.js new file mode 100644 index 00000000000..329dd2f73ee --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_create_form_spec.js @@ -0,0 +1,189 @@ +import Vue from 'vue'; +import { GlForm } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; +import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; +import { + DEFAULT_ACCESS_LEVEL, + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, +} from '~/ci/runner/constants'; +import runnerCreateMutation from '~/ci/runner/graphql/new/runner_create.mutation.graphql'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { runnerCreateResult } from '../mock_data'; + +jest.mock('~/ci/runner/sentry_utils'); + +const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner; + +const defaultRunnerModel = { + description: '', + accessLevel: DEFAULT_ACCESS_LEVEL, + paused: false, + maintenanceNote: '', + maximumTimeout: '', + runUntagged: false, + tagList: '', +}; + +Vue.use(VueApollo); + +describe('RunnerCreateForm', () => { + let wrapper; + let runnerCreateHandler; + + const findForm = () => wrapper.findComponent(GlForm); + const findRunnerFormFields = () => wrapper.findComponent(RunnerFormFields); + const findSubmitBtn = () => wrapper.find('[type="submit"]'); + + const createComponent = ({ props } = {}) => { + wrapper = shallowMountExtended(RunnerCreateForm, { + propsData: { + runnerType: INSTANCE_TYPE, + ...props, + }, + apolloProvider: createMockApollo([[runnerCreateMutation, runnerCreateHandler]]), + }); + }; + + beforeEach(() => { + runnerCreateHandler = jest.fn().mockResolvedValue(runnerCreateResult); + }); + + it('shows default runner values', () => { + createComponent(); + + expect(findRunnerFormFields().props('value')).toEqual(defaultRunnerModel); + }); + + it('shows a submit button', () => { + createComponent(); + + expect(findSubmitBtn().exists()).toBe(true); + }); + + describe.each` + typeName | props | scopeData + ${'an instance runner'} | ${{ runnerType: INSTANCE_TYPE }} | ${{ runnerType: INSTANCE_TYPE }} + ${'a group runner'} | ${{ runnerType: GROUP_TYPE, groupId: 'gid://gitlab/Group/72' }} | ${{ runnerType: GROUP_TYPE, groupId: 'gid://gitlab/Group/72' }} + ${'a project runner'} | ${{ runnerType: PROJECT_TYPE, projectId: 'gid://gitlab/Project/42' }} | ${{ runnerType: PROJECT_TYPE, projectId: 'gid://gitlab/Project/42' }} + `('when user submits $typeName', ({ props, scopeData }) => { + let preventDefault; + + beforeEach(() => { + createComponent({ props }); + + preventDefault = jest.fn(); + + findRunnerFormFields().vm.$emit('input', { + ...defaultRunnerModel, + description: 'My runner', + maximumTimeout: 0, + tagList: 'tag1, tag2', + }); + }); + + describe('immediately after submit', () => { + beforeEach(() => { + findForm().vm.$emit('submit', { preventDefault }); + }); + + it('prevents default form submission', () => { + expect(preventDefault).toHaveBeenCalledTimes(1); + }); + + it('shows a saving state', () => { + expect(findSubmitBtn().props('loading')).toBe(true); + }); + + it('saves runner', () => { + expect(runnerCreateHandler).toHaveBeenCalledWith({ + input: { + ...defaultRunnerModel, + ...scopeData, + description: 'My runner', + maximumTimeout: 0, + tagList: ['tag1', 'tag2'], + }, + }); + }); + }); + + describe('when saved successfully', () => { + beforeEach(async () => { + findForm().vm.$emit('submit', { preventDefault }); + await waitForPromises(); + }); + + it('emits "saved" result', () => { + expect(wrapper.emitted('saved')[0]).toEqual([mockCreatedRunner]); + }); + + it('does not show a saving state', () => { + expect(findSubmitBtn().props('loading')).toBe(false); + }); + }); + + describe('when a server error occurs', () => { + const error = new Error('Error!'); + + beforeEach(async () => { + runnerCreateHandler.mockRejectedValue(error); + + findForm().vm.$emit('submit', { preventDefault }); + await waitForPromises(); + }); + + it('emits "error" result', () => { + expect(wrapper.emitted('error')[0]).toEqual([error]); + }); + + it('does not show a saving state', () => { + expect(findSubmitBtn().props('loading')).toBe(false); + }); + + it('reports error', () => { + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerCreateForm', + error, + }); + }); + }); + + describe('when a validation error occurs', () => { + const errorMsg1 = 'Issue1!'; + const errorMsg2 = 'Issue2!'; + + beforeEach(async () => { + runnerCreateHandler.mockResolvedValue({ + data: { + runnerCreate: { + errors: [errorMsg1, errorMsg2], + runner: null, + }, + }, + }); + + findForm().vm.$emit('submit', { preventDefault }); + await waitForPromises(); + }); + + it('emits "error" results', () => { + expect(wrapper.emitted('error')[0]).toEqual([new Error(`${errorMsg1} ${errorMsg2}`)]); + }); + + it('does not show a saving state', () => { + expect(findSubmitBtn().props('loading')).toBe(false); + }); + + it('does not report error', () => { + expect(captureException).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_delete_button_spec.js b/spec/frontend/ci/runner/components/runner_delete_button_spec.js index 02960ad427e..3123f2894fb 100644 --- a/spec/frontend/ci/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/ci/runner/components/runner_delete_button_spec.js @@ -8,7 +8,7 @@ import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutat import waitForPromises from 'helpers/wait_for_promises'; import { captureException } from '~/ci/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { I18N_DELETE_RUNNER } from '~/ci/runner/constants'; import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; @@ -21,7 +21,7 @@ const mockRunnerName = `#${mockRunnerId} (${mockRunner.shortSha})`; Vue.use(VueApollo); -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); describe('RunnerDeleteButton', () => { @@ -53,8 +53,8 @@ describe('RunnerDeleteButton', () => { }, apolloProvider, directives: { - GlTooltip: createMockDirective(), - GlModal: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), + GlModal: createMockDirective('gl-modal'), }, }); }; @@ -83,10 +83,6 @@ describe('RunnerDeleteButton', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Displays a delete button without an icon', () => { expect(findBtn().props()).toMatchObject({ loading: false, @@ -128,15 +124,15 @@ describe('RunnerDeleteButton', () => { }); describe('Immediately after the delete button is clicked', () => { - beforeEach(async () => { + beforeEach(() => { findModal().vm.$emit('primary'); }); - it('The button has a loading state', async () => { + it('The button has a loading state', () => { expect(findBtn().props('loading')).toBe(true); }); - it('The stale tooltip is removed', async () => { + it('The stale tooltip is removed', () => { expect(getTooltip()).toBe(''); }); }); @@ -259,15 +255,15 @@ describe('RunnerDeleteButton', () => { }); describe('Immediately after the button is clicked', () => { - beforeEach(async () => { + beforeEach(() => { findModal().vm.$emit('primary'); }); - it('The button has a loading state', async () => { + it('The button has a loading state', () => { expect(findBtn().props('loading')).toBe(true); }); - it('The stale tooltip is removed', async () => { + it('The stale tooltip is removed', () => { expect(getTooltip()).toBe(''); }); }); diff --git a/spec/frontend/ci/runner/components/runner_details_spec.js b/spec/frontend/ci/runner/components/runner_details_spec.js index 65a81973869..c2d9e86aa91 100644 --- a/spec/frontend/ci/runner/components/runner_details_spec.js +++ b/spec/frontend/ci/runner/components/runner_details_spec.js @@ -37,10 +37,6 @@ describe('RunnerDetails', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('Details tab', () => { describe.each` field | runner | expectedValue diff --git a/spec/frontend/ci/runner/components/runner_edit_button_spec.js b/spec/frontend/ci/runner/components/runner_edit_button_spec.js index 907cdc90100..5cc1ee049f4 100644 --- a/spec/frontend/ci/runner/components/runner_edit_button_spec.js +++ b/spec/frontend/ci/runner/components/runner_edit_button_spec.js @@ -11,7 +11,7 @@ describe('RunnerEditButton', () => { wrapper = mountFn(RunnerEditButton, { attrs, directives: { - GlTooltip: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), }, }); }; @@ -20,10 +20,6 @@ describe('RunnerEditButton', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Displays Edit text', () => { expect(wrapper.attributes('aria-label')).toBe('Edit'); }); diff --git a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js index 408750e646f..7572122a5f3 100644 --- a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js @@ -1,5 +1,6 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { assertProps } from 'helpers/assert_props'; import RunnerFilteredSearchBar from '~/ci/runner/components/runner_filtered_search_bar.vue'; import { statusTokenConfig } from '~/ci/runner/components/search_tokens/status_token_config'; import TagToken from '~/ci/runner/components/search_tokens/tag_token.vue'; @@ -43,12 +44,12 @@ describe('RunnerList', () => { expect(inputs[inputs.length - 1][0]).toEqual(value); }; + const defaultProps = { namespace: 'runners', tokens: [], value: mockSearch }; + const createComponent = ({ props = {}, options = {} } = {}) => { wrapper = shallowMountExtended(RunnerFilteredSearchBar, { propsData: { - namespace: 'runners', - tokens: [], - value: mockSearch, + ...defaultProps, ...props, }, stubs: { @@ -65,10 +66,6 @@ describe('RunnerList', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('binds a namespace to the filtered search', () => { expect(findFilteredSearch().props('namespace')).toBe('runners'); }); @@ -113,11 +110,14 @@ describe('RunnerList', () => { it('fails validation for v-model with the wrong shape', () => { expect(() => { - createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } }); + assertProps(RunnerFilteredSearchBar, { + ...defaultProps, + value: { filters: 'wrong_filters', sort: 'sort' }, + }); }).toThrow('Invalid prop: custom validator check failed'); expect(() => { - createComponent({ props: { value: { sort: 'sort' } } }); + assertProps(RunnerFilteredSearchBar, { ...defaultProps, value: { sort: 'sort' } }); }).toThrow('Invalid prop: custom validator check failed'); }); diff --git a/spec/frontend/ci/runner/components/runner_groups_spec.js b/spec/frontend/ci/runner/components/runner_groups_spec.js index 0991feb2e55..e4f5f55ab4b 100644 --- a/spec/frontend/ci/runner/components/runner_groups_spec.js +++ b/spec/frontend/ci/runner/components/runner_groups_spec.js @@ -23,10 +23,6 @@ describe('RunnerGroups', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - it('Shows a heading', () => { createComponent(); diff --git a/spec/frontend/ci/runner/components/runner_header_spec.js b/spec/frontend/ci/runner/components/runner_header_spec.js index abe3b47767e..c851966431d 100644 --- a/spec/frontend/ci/runner/components/runner_header_spec.js +++ b/spec/frontend/ci/runner/components/runner_header_spec.js @@ -42,10 +42,6 @@ describe('RunnerHeader', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - it('displays the runner status', () => { createComponent({ mountFn: mountExtended, diff --git a/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js new file mode 100644 index 00000000000..59c9383cb31 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js @@ -0,0 +1,35 @@ +import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url'; + +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; +import RunnerJobsEmptyState from '~/ci/runner/components/runner_jobs_empty_state.vue'; + +const DEFAULT_PROPS = { + emptyTitle: 'This runner has not run any jobs', + emptyDescription: + 'Make sure the runner is online and available to run jobs (not paused). Jobs display here when the runner picks them up.', +}; + +describe('RunnerJobsEmptyStateComponent', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMount(RunnerJobsEmptyState); + }; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + beforeEach(() => { + mountComponent(); + }); + + describe('empty', () => { + it('should show an empty state if it is empty', () => { + const emptyState = findEmptyState(); + + expect(emptyState.props('svgPath')).toBe(EMPTY_STATE_SVG_URL); + expect(emptyState.props('title')).toBe(DEFAULT_PROPS.emptyTitle); + expect(emptyState.text()).toContain(DEFAULT_PROPS.emptyDescription); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_jobs_spec.js b/spec/frontend/ci/runner/components/runner_jobs_spec.js index bdb8a4a31a3..179b37cfa21 100644 --- a/spec/frontend/ci/runner/components/runner_jobs_spec.js +++ b/spec/frontend/ci/runner/components/runner_jobs_spec.js @@ -4,18 +4,19 @@ import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import RunnerJobs from '~/ci/runner/components/runner_jobs.vue'; import RunnerJobsTable from '~/ci/runner/components/runner_jobs_table.vue'; import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; +import RunnerJobsEmptyState from '~/ci/runner/components/runner_jobs_empty_state.vue'; import { captureException } from '~/ci/runner/sentry_utils'; -import { I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '~/ci/runner/constants'; +import { RUNNER_DETAILS_JOBS_PAGE_SIZE } from '~/ci/runner/constants'; import runnerJobsQuery from '~/ci/runner/graphql/show/runner_jobs.query.graphql'; import { runnerData, runnerJobsData } from '../mock_data'; -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); const mockRunner = runnerData.data.runner; @@ -31,7 +32,7 @@ describe('RunnerJobs', () => { const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoader); const findRunnerJobsTable = () => wrapper.findComponent(RunnerJobsTable); const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); - + const findEmptyState = () => wrapper.findComponent(RunnerJobsEmptyState); const createComponent = ({ mountFn = shallowMountExtended } = {}) => { wrapper = mountFn(RunnerJobs, { apolloProvider: createMockApollo([[runnerJobsQuery, mockRunnerJobsQuery]]), @@ -47,7 +48,6 @@ describe('RunnerJobs', () => { afterEach(() => { mockRunnerJobsQuery.mockReset(); - wrapper.destroy(); }); it('Requests runner jobs', async () => { @@ -100,7 +100,7 @@ describe('RunnerJobs', () => { expect(findGlSkeletonLoading().exists()).toBe(true); expect(findRunnerJobsTable().exists()).toBe(false); - expect(findRunnerPagination().attributes('disabled')).toBe('true'); + expect(findRunnerPagination().attributes('disabled')).toBeDefined(); }); }); @@ -128,8 +128,8 @@ describe('RunnerJobs', () => { await waitForPromises(); }); - it('Shows a "None" label', () => { - expect(wrapper.text()).toBe(I18N_NO_JOBS_FOUND); + it('should render empty state', () => { + expect(findEmptyState().exists()).toBe(true); }); }); diff --git a/spec/frontend/ci/runner/components/runner_jobs_table_spec.js b/spec/frontend/ci/runner/components/runner_jobs_table_spec.js index 281aa1aeb77..694c5a6ed17 100644 --- a/spec/frontend/ci/runner/components/runner_jobs_table_spec.js +++ b/spec/frontend/ci/runner/components/runner_jobs_table_spec.js @@ -37,10 +37,6 @@ describe('RunnerJobsTable', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - it('Sets job id as a row key', () => { createComponent(); diff --git a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js index 6aea3ddf58c..0de2759ea8a 100644 --- a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js @@ -1,19 +1,15 @@ +import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url'; +import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/magnifying-glass.svg?url'; import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; -import { - newRunnerPath, - emptyStateSvgPath, - emptyStateFilteredSvgPath, -} from 'jest/ci/runner/mock_data'; +import { mockRegistrationToken, newRunnerPath } from 'jest/ci/runner/mock_data'; import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue'; -const mockRegistrationToken = 'REGISTRATION_TOKEN'; - describe('RunnerListEmptyState', () => { let wrapper; @@ -24,14 +20,12 @@ describe('RunnerListEmptyState', () => { const createComponent = ({ props, mountFn = shallowMountExtended, ...options } = {}) => { wrapper = mountFn(RunnerListEmptyState, { propsData: { - svgPath: emptyStateSvgPath, - filteredSvgPath: emptyStateFilteredSvgPath, registrationToken: mockRegistrationToken, newRunnerPath, ...props, }, directives: { - GlModal: createMockDirective(), + GlModal: createMockDirective('gl-modal'), }, stubs: { GlEmptyState, @@ -51,7 +45,7 @@ describe('RunnerListEmptyState', () => { }); it('renders an illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(emptyStateSvgPath); + expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); }); it('displays "no results" text with instructions', () => { @@ -62,44 +56,52 @@ describe('RunnerListEmptyState', () => { expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); }); - describe('when create_runner_workflow is enabled', () => { - beforeEach(() => { - createComponent({ - provide: { - glFeatures: { createRunnerWorkflow: true }, - }, + describe.each([ + { createRunnerWorkflowForAdmin: true }, + { createRunnerWorkflowForNamespace: true }, + ])('when %o', (glFeatures) => { + describe('when newRunnerPath is defined', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures, + }, + }); }); - }); - it('shows a link to the new runner page', () => { - expect(findLink().attributes('href')).toBe(newRunnerPath); + it('shows a link to the new runner page', () => { + expect(findLink().attributes('href')).toBe(newRunnerPath); + }); }); - }); - describe('when create_runner_workflow is enabled and newRunnerPath not defined', () => { - beforeEach(() => { - createComponent({ - props: { - newRunnerPath: null, - }, - provide: { - glFeatures: { createRunnerWorkflow: true }, - }, + describe('when newRunnerPath not defined', () => { + beforeEach(() => { + createComponent({ + props: { + newRunnerPath: null, + }, + provide: { + glFeatures, + }, + }); }); - }); - it('opens a runner registration instructions modal with a link', () => { - const { value } = getBinding(findLink().element, 'gl-modal'); + it('opens a runner registration instructions modal with a link', () => { + const { value } = getBinding(findLink().element, 'gl-modal'); - expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + }); }); }); - describe('when create_runner_workflow is disabled', () => { + describe.each([ + { createRunnerWorkflowForAdmin: false }, + { createRunnerWorkflowForNamespace: false }, + ])('when %o', (glFeatures) => { beforeEach(() => { createComponent({ provide: { - glFeatures: { createRunnerWorkflow: false }, + glFeatures, }, }); }); @@ -118,7 +120,7 @@ describe('RunnerListEmptyState', () => { }); it('renders an illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(emptyStateSvgPath); + expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL); }); it('displays "no results" text', () => { @@ -141,7 +143,7 @@ describe('RunnerListEmptyState', () => { }); it('renders a "filtered search" illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(emptyStateFilteredSvgPath); + expect(findEmptyState().props('svgPath')).toBe(FILTERED_SVG_URL); }); it('displays "no filtered results" text', () => { diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js index 2e5d1dbd063..0f4ec717c3e 100644 --- a/spec/frontend/ci/runner/components/runner_list_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_spec.js @@ -57,10 +57,6 @@ describe('RunnerList', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - it('Displays headers', () => { createComponent( { @@ -168,7 +164,7 @@ describe('RunnerList', () => { }); }); - it('Emits a deleted event', async () => { + it('Emits a deleted event', () => { const event = { message: 'Deleted!' }; findRunnerBulkDelete().vm.$emit('deleted', event); diff --git a/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js b/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js index f089becd400..7ff3ec92042 100644 --- a/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js +++ b/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js @@ -18,10 +18,6 @@ describe('RunnerMembershipToggle', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - it('Displays text', () => { createComponent({ mountFn: mount }); diff --git a/spec/frontend/ci/runner/components/runner_pagination_spec.js b/spec/frontend/ci/runner/components/runner_pagination_spec.js index f835ee4514d..6d84eb810f8 100644 --- a/spec/frontend/ci/runner/components/runner_pagination_spec.js +++ b/spec/frontend/ci/runner/components/runner_pagination_spec.js @@ -16,10 +16,6 @@ describe('RunnerPagination', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('When in between pages', () => { const mockPageInfo = { startCursor: mockStartCursor, diff --git a/spec/frontend/ci/runner/components/runner_pause_button_spec.js b/spec/frontend/ci/runner/components/runner_pause_button_spec.js index 12680e01b98..350d029f3fc 100644 --- a/spec/frontend/ci/runner/components/runner_pause_button_spec.js +++ b/spec/frontend/ci/runner/components/runner_pause_button_spec.js @@ -7,7 +7,7 @@ import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_help import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql'; import waitForPromises from 'helpers/wait_for_promises'; import { captureException } from '~/ci/runner/sentry_utils'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, @@ -22,7 +22,7 @@ const mockRunner = allRunnersData.data.runners.nodes[0]; Vue.use(VueApollo); -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); describe('RunnerPauseButton', () => { @@ -46,7 +46,7 @@ describe('RunnerPauseButton', () => { }, apolloProvider: createMockApollo([[runnerToggleActiveMutation, runnerToggleActiveHandler]]), directives: { - GlTooltip: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), }, }); }; @@ -74,10 +74,6 @@ describe('RunnerPauseButton', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - describe('Pause/Resume action', () => { describe.each` runnerState | icon | content | tooltip | isActive | newActiveValue @@ -138,7 +134,7 @@ describe('RunnerPauseButton', () => { await clickAndWait(); }); - it(`The mutation to that sets active to ${newActiveValue} is called`, async () => { + it(`The mutation to that sets active to ${newActiveValue} is called`, () => { expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(1); expect(runnerToggleActiveHandler).toHaveBeenCalledWith({ input: { diff --git a/spec/frontend/ci/runner/components/runner_paused_badge_spec.js b/spec/frontend/ci/runner/components/runner_paused_badge_spec.js index b051ebe99a7..54768ea50da 100644 --- a/spec/frontend/ci/runner/components/runner_paused_badge_spec.js +++ b/spec/frontend/ci/runner/components/runner_paused_badge_spec.js @@ -16,7 +16,7 @@ describe('RunnerTypeBadge', () => { ...props, }, directives: { - GlTooltip: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), }, }); }; @@ -25,10 +25,6 @@ describe('RunnerTypeBadge', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('renders paused state', () => { expect(wrapper.text()).toBe(I18N_PAUSED); expect(findBadge().props('variant')).toBe('warning'); diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js index db6fd2c369b..eddc1438fff 100644 --- a/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js +++ b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js @@ -6,19 +6,12 @@ import { LINUX_PLATFORM, MACOS_PLATFORM, WINDOWS_PLATFORM, - AWS_PLATFORM, DOCKER_HELP_URL, KUBERNETES_HELP_URL, } from '~/ci/runner/constants'; import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; -const mockProvide = { - awsImgPath: 'awsLogo.svg', - dockerImgPath: 'dockerLogo.svg', - kubernetesImgPath: 'kubernetesLogo.svg', -}; - describe('RunnerPlatformsRadioGroup', () => { let wrapper; @@ -35,7 +28,6 @@ describe('RunnerPlatformsRadioGroup', () => { value: null, ...props, }, - provide: mockProvide, ...options, }); }; @@ -48,10 +40,9 @@ describe('RunnerPlatformsRadioGroup', () => { const labels = findFormRadios().map((w) => [w.text(), w.props('image')]); expect(labels).toEqual([ - ['Linux', null], + ['Linux', expect.any(String)], ['macOS', null], ['Windows', null], - ['AWS', expect.any(String)], ['Docker', expect.any(String)], ['Kubernetes', expect.any(String)], ]); @@ -69,7 +60,6 @@ describe('RunnerPlatformsRadioGroup', () => { ${'Linux'} | ${LINUX_PLATFORM} ${'macOS'} | ${MACOS_PLATFORM} ${'Windows'} | ${WINDOWS_PLATFORM} - ${'AWS'} | ${AWS_PLATFORM} `('user can select "$text"', async ({ text, value }) => { const radio = findFormRadioByText(text); expect(radio.props('value')).toBe(value); @@ -84,7 +74,7 @@ describe('RunnerPlatformsRadioGroup', () => { text | href ${'Docker'} | ${DOCKER_HELP_URL} ${'Kubernetes'} | ${KUBERNETES_HELP_URL} - `('provides link to "$text" docs', async ({ text, href }) => { + `('provides link to "$text" docs', ({ text, href }) => { const radio = findFormRadioByText(text); expect(radio.findComponent(GlLink).attributes()).toEqual({ diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js index fb81edd1ae2..340b04637f8 100644 --- a/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js +++ b/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js @@ -41,7 +41,7 @@ describe('RunnerPlatformsRadio', () => { expect(findFormRadio().attributes('value')).toBe(mockValue); }); - it('emits when item is clicked', async () => { + it('emits when item is clicked', () => { findDiv().trigger('click'); expect(wrapper.emitted('input')).toEqual([[mockValue]]); @@ -94,7 +94,7 @@ describe('RunnerPlatformsRadio', () => { expect(wrapper.classes('gl-cursor-pointer')).toBe(false); }); - it('does not emit when item is clicked', async () => { + it('does not emit when item is clicked', () => { findDiv().trigger('click'); expect(wrapper.emitted('input')).toBe(undefined); diff --git a/spec/frontend/ci/runner/components/runner_projects_spec.js b/spec/frontend/ci/runner/components/runner_projects_spec.js index 17517c4db66..736a1f7d3ce 100644 --- a/spec/frontend/ci/runner/components/runner_projects_spec.js +++ b/spec/frontend/ci/runner/components/runner_projects_spec.js @@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { sprintf } from '~/locale'; import { I18N_ASSIGNED_PROJECTS, @@ -22,7 +22,7 @@ import runnerProjectsQuery from '~/ci/runner/graphql/show/runner_projects.query. import { runnerData, runnerProjectsData } from '../mock_data'; -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); const mockRunner = runnerData.data.runner; @@ -56,7 +56,6 @@ describe('RunnerProjects', () => { afterEach(() => { mockRunnerProjectsQuery.mockReset(); - wrapper.destroy(); }); it('Requests runner projects', async () => { @@ -90,7 +89,7 @@ describe('RunnerProjects', () => { await waitForPromises(); }); - it('Shows a heading', async () => { + it('Shows a heading', () => { const expected = sprintf(I18N_ASSIGNED_PROJECTS, { projectCount: mockProjects.length }); expect(findHeading().text()).toBe(expected); @@ -195,7 +194,7 @@ describe('RunnerProjects', () => { expect(wrapper.findByText(I18N_NO_PROJECTS_FOUND).exists()).toBe(false); expect(findRunnerAssignedItems().length).toBe(0); - expect(findRunnerPagination().attributes('disabled')).toBe('true'); + expect(findRunnerPagination().attributes('disabled')).toBeDefined(); expect(findGlSearchBoxByType().props('isLoading')).toBe(true); }); }); diff --git a/spec/frontend/ci/runner/components/runner_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_status_badge_spec.js index 45b410df2d4..e1eb81f2d23 100644 --- a/spec/frontend/ci/runner/components/runner_status_badge_spec.js +++ b/spec/frontend/ci/runner/components/runner_status_badge_spec.js @@ -31,7 +31,7 @@ describe('RunnerTypeBadge', () => { ...props, }, directives: { - GlTooltip: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), }, }); }; @@ -43,8 +43,6 @@ describe('RunnerTypeBadge', () => { afterEach(() => { jest.useFakeTimers({ legacyFakeTimers: true }); - - wrapper.destroy(); }); it('renders online state', () => { diff --git a/spec/frontend/ci/runner/components/runner_tag_spec.js b/spec/frontend/ci/runner/components/runner_tag_spec.js index 7bcb046ae43..e3d46e5d6df 100644 --- a/spec/frontend/ci/runner/components/runner_tag_spec.js +++ b/spec/frontend/ci/runner/components/runner_tag_spec.js @@ -29,8 +29,8 @@ describe('RunnerTag', () => { ...props, }, directives: { - GlTooltip: createMockDirective(), - GlResizeObserver: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), + GlResizeObserver: createMockDirective('gl-resize-observer'), }, }); }; @@ -39,10 +39,6 @@ describe('RunnerTag', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Displays tag text', () => { expect(wrapper.text()).toBe(mockTag); }); diff --git a/spec/frontend/ci/runner/components/runner_tags_spec.js b/spec/frontend/ci/runner/components/runner_tags_spec.js index 96bec00302b..bcb1d1f9e13 100644 --- a/spec/frontend/ci/runner/components/runner_tags_spec.js +++ b/spec/frontend/ci/runner/components/runner_tags_spec.js @@ -21,10 +21,6 @@ describe('RunnerTags', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Displays tags text', () => { expect(wrapper.text()).toMatchInterpolatedText('tag1 tag2'); diff --git a/spec/frontend/ci/runner/components/runner_type_badge_spec.js b/spec/frontend/ci/runner/components/runner_type_badge_spec.js index 58f09362759..f7ecd108967 100644 --- a/spec/frontend/ci/runner/components/runner_type_badge_spec.js +++ b/spec/frontend/ci/runner/components/runner_type_badge_spec.js @@ -2,6 +2,7 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RunnerTypeBadge from '~/ci/runner/components/runner_type_badge.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { assertProps } from 'helpers/assert_props'; import { INSTANCE_TYPE, GROUP_TYPE, @@ -23,15 +24,11 @@ describe('RunnerTypeBadge', () => { ...props, }, directives: { - GlTooltip: createMockDirective(), + GlTooltip: createMockDirective('gl-tooltip'), }, }); }; - afterEach(() => { - wrapper.destroy(); - }); - describe.each` type | text ${INSTANCE_TYPE} | ${I18N_INSTANCE_TYPE} @@ -54,7 +51,7 @@ describe('RunnerTypeBadge', () => { it('validation fails for an incorrect type', () => { expect(() => { - createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); + assertProps(RunnerTypeBadge, { type: 'AN_UNKNOWN_VALUE' }); }).toThrow(); }); diff --git a/spec/frontend/ci/runner/components/runner_type_tabs_spec.js b/spec/frontend/ci/runner/components/runner_type_tabs_spec.js index 3347c190083..71dcc5b4226 100644 --- a/spec/frontend/ci/runner/components/runner_type_tabs_spec.js +++ b/spec/frontend/ci/runner/components/runner_type_tabs_spec.js @@ -8,6 +8,7 @@ import { PROJECT_TYPE, DEFAULT_MEMBERSHIP, DEFAULT_SORT, + STATUS_ONLINE, } from '~/ci/runner/constants'; const mockSearch = { @@ -63,10 +64,6 @@ describe('RunnerTypeTabs', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - it('Renders all options to filter runners by default', () => { createComponent(); @@ -115,7 +112,7 @@ describe('RunnerTypeTabs', () => { it('Renders a count next to each tab', () => { const mockVariables = { paused: true, - status: 'ONLINE', + status: STATUS_ONLINE, }; createComponent({ diff --git a/spec/frontend/ci/runner/components/runner_update_form_spec.js b/spec/frontend/ci/runner/components/runner_update_form_spec.js index a0e51ebf958..db4c236bfff 100644 --- a/spec/frontend/ci/runner/components/runner_update_form_spec.js +++ b/spec/frontend/ci/runner/components/runner_update_form_spec.js @@ -5,8 +5,8 @@ import { __ } from '~/locale'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert, VARIANT_SUCCESS } from '~/flash'; -import { redirectTo } from '~/lib/utils/url_utility'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import RunnerUpdateForm from '~/ci/runner/components/runner_update_form.vue'; import { INSTANCE_TYPE, @@ -21,7 +21,7 @@ import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_al import { runnerFormData } from '../mock_data'; jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); jest.mock('~/lib/utils/url_utility'); @@ -86,7 +86,7 @@ describe('RunnerUpdateForm', () => { variant: VARIANT_SUCCESS, }), ); - expect(redirectTo).toHaveBeenCalledWith(mockRunnerPath); + expect(redirectTo).toHaveBeenCalledWith(mockRunnerPath); // eslint-disable-line import/no-deprecated }; beforeEach(() => { @@ -107,10 +107,6 @@ describe('RunnerUpdateForm', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('Form has a submit button', () => { expect(findSubmit().exists()).toBe(true); }); @@ -282,7 +278,7 @@ describe('RunnerUpdateForm', () => { expect(captureException).not.toHaveBeenCalled(); expect(saveAlertToLocalStorage).not.toHaveBeenCalled(); - expect(redirectTo).not.toHaveBeenCalled(); + expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated }); }); }); diff --git a/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js index b7d9d3ad23e..e9f2e888b9a 100644 --- a/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js +++ b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js @@ -3,14 +3,14 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import TagToken, { TAG_SUGGESTIONS_PATH } from '~/ci/runner/components/search_tokens/tag_token.vue'; import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { getRecentlyUsedSuggestions } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ ...jest.requireActual('~/vue_shared/components/filtered_search_bar/filtered_search_utils'), @@ -90,7 +90,6 @@ describe('TagToken', () => { afterEach(() => { getRecentlyUsedSuggestions.mockReset(); - wrapper.destroy(); }); describe('when the tags token is displayed', () => { diff --git a/spec/frontend/ci/runner/components/stat/runner_count_spec.js b/spec/frontend/ci/runner/components/stat/runner_count_spec.js index 42d8c9a1080..df774ba3e57 100644 --- a/spec/frontend/ci/runner/components/stat/runner_count_spec.js +++ b/spec/frontend/ci/runner/components/stat/runner_count_spec.js @@ -2,7 +2,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; import RunnerCount from '~/ci/runner/components/stat/runner_count.vue'; -import { INSTANCE_TYPE, GROUP_TYPE } from '~/ci/runner/constants'; +import { INSTANCE_TYPE, GROUP_TYPE, STATUS_ONLINE } from '~/ci/runner/constants'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { captureException } from '~/ci/runner/sentry_utils'; @@ -47,7 +47,7 @@ describe('RunnerCount', () => { }); describe('in admin scope', () => { - const mockVariables = { status: 'ONLINE' }; + const mockVariables = { status: STATUS_ONLINE }; beforeEach(async () => { await createComponent({ props: { scope: INSTANCE_TYPE } }); @@ -67,7 +67,7 @@ describe('RunnerCount', () => { expect(wrapper.html()).toBe(`<strong>${runnersCountData.data.runners.count}</strong>`); }); - it('does not fetch from the group query', async () => { + it('does not fetch from the group query', () => { expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled(); }); @@ -89,7 +89,7 @@ describe('RunnerCount', () => { await createComponent({ props: { scope: INSTANCE_TYPE, skip: true } }); }); - it('does not fetch data', async () => { + it('does not fetch data', () => { expect(mockRunnersCountHandler).not.toHaveBeenCalled(); expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled(); @@ -106,7 +106,7 @@ describe('RunnerCount', () => { await createComponent({ props: { scope: INSTANCE_TYPE } }); }); - it('data is not shown and error is reported', async () => { + it('data is not shown and error is reported', () => { expect(wrapper.html()).toBe('<strong></strong>'); expect(captureException).toHaveBeenCalledWith({ @@ -121,7 +121,7 @@ describe('RunnerCount', () => { await createComponent({ props: { scope: GROUP_TYPE } }); }); - it('fetches data from the group query', async () => { + it('fetches data from the group query', () => { expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(1); expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({}); @@ -141,7 +141,7 @@ describe('RunnerCount', () => { wrapper.vm.refetch(); }); - it('data is not shown and error is reported', async () => { + it('data is not shown and error is reported', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(2); }); }); diff --git a/spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js b/spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js index cad61f26012..f30b75ee614 100644 --- a/spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js +++ b/spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js @@ -32,10 +32,6 @@ describe('RunnerStats', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - it.each` case | count | value ${'number'} | ${99} | ${'99'} diff --git a/spec/frontend/ci/runner/components/stat/runner_stats_spec.js b/spec/frontend/ci/runner/components/stat/runner_stats_spec.js index 3d45674d106..13366a788d5 100644 --- a/spec/frontend/ci/runner/components/stat/runner_stats_spec.js +++ b/spec/frontend/ci/runner/components/stat/runner_stats_spec.js @@ -47,10 +47,6 @@ describe('RunnerStats', () => { }); }; - afterEach(() => { - wrapper.destroy(); - }); - it('Displays all the stats', () => { createComponent({ mountFn: mount, diff --git a/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js new file mode 100644 index 00000000000..1c052b00fc3 --- /dev/null +++ b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js @@ -0,0 +1,124 @@ +import { GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; + +import GroupRunnerRunnerApp from '~/ci/runner/group_new_runner/group_new_runner_app.vue'; +import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue'; +import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue'; +import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; +import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; +import { + PARAM_KEY_PLATFORM, + GROUP_TYPE, + DEFAULT_PLATFORM, + WINDOWS_PLATFORM, +} from '~/ci/runner/constants'; +import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { runnerCreateResult } from '../mock_data'; + +const mockGroupId = 'gid://gitlab/Group/72'; + +jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); +jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + redirectTo: jest.fn(), +})); + +const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner; + +describe('GroupRunnerRunnerApp', () => { + let wrapper; + + const findRunnerPlatformsRadioGroup = () => wrapper.findComponent(RunnerPlatformsRadioGroup); + const findRegistrationFeedbackBanner = () => wrapper.findComponent(RegistrationFeedbackBanner); + const findRegistrationCompatibilityAlert = () => + wrapper.findComponent(RegistrationCompatibilityAlert); + const findRunnerCreateForm = () => wrapper.findComponent(RunnerCreateForm); + + const createComponent = () => { + wrapper = shallowMountExtended(GroupRunnerRunnerApp, { + propsData: { + groupId: mockGroupId, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('shows a registration feedback banner', () => { + expect(findRegistrationFeedbackBanner().exists()).toBe(true); + }); + + it('shows a registration compatibility alert', () => { + expect(findRegistrationCompatibilityAlert().props('alertKey')).toBe(mockGroupId); + }); + + describe('Platform', () => { + it('shows the platforms radio group', () => { + expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM); + }); + }); + + describe('Runner form', () => { + it('shows the runner create form for an instance runner', () => { + expect(findRunnerCreateForm().props()).toEqual({ + runnerType: GROUP_TYPE, + groupId: mockGroupId, + projectId: null, + }); + }); + + describe('When a runner is saved', () => { + beforeEach(() => { + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + }); + + it('pushes an alert to be shown after redirection', () => { + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: s__('Runners|Runner created.'), + variant: VARIANT_SUCCESS, + }); + }); + + it('redirects to the registration page', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; + + expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + }); + }); + + describe('When another platform is selected and a runner is saved', () => { + beforeEach(() => { + findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM); + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + }); + + it('redirects to the registration page with the platform', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; + + expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + }); + }); + + describe('When runner fails to save', () => { + const ERROR_MSG = 'Cannot save!'; + + beforeEach(() => { + findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG)); + }); + + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/group_register_runner_app/group_register_runner_app_spec.js b/spec/frontend/ci/runner/group_register_runner_app/group_register_runner_app_spec.js new file mode 100644 index 00000000000..2f0807c700c --- /dev/null +++ b/spec/frontend/ci/runner/group_register_runner_app/group_register_runner_app_spec.js @@ -0,0 +1,120 @@ +import { nextTick } from 'vue'; +import { GlButton } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; + +import { updateHistory } from '~/lib/utils/url_utility'; +import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM, WINDOWS_PLATFORM } from '~/ci/runner/constants'; +import GroupRegisterRunnerApp from '~/ci/runner/group_register_runner/group_register_runner_app.vue'; +import RegistrationInstructions from '~/ci/runner/components/registration/registration_instructions.vue'; +import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawer.vue'; +import { runnerForRegistration } from '../mock_data'; + +const mockRunnerId = runnerForRegistration.data.runner.id; +const mockRunnersPath = '/groups/group1/-/runners'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +describe('GroupRegisterRunnerApp', () => { + let wrapper; + + const findRegistrationInstructions = () => wrapper.findComponent(RegistrationInstructions); + const findPlatformsDrawer = () => wrapper.findComponent(PlatformsDrawer); + const findBtn = () => wrapper.findComponent(GlButton); + + const createComponent = () => { + wrapper = shallowMountExtended(GroupRegisterRunnerApp, { + propsData: { + runnerId: mockRunnerId, + runnersPath: mockRunnersPath, + }, + }); + }; + + describe('When showing runner details', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when runner token is available', () => { + it('shows registration instructions', () => { + expect(findRegistrationInstructions().props()).toEqual({ + platform: DEFAULT_PLATFORM, + runnerId: mockRunnerId, + }); + }); + + it('configures platform drawer', () => { + expect(findPlatformsDrawer().props()).toEqual({ + open: false, + platform: DEFAULT_PLATFORM, + }); + }); + + it('shows runner list button', () => { + expect(findBtn().attributes('href')).toBe(mockRunnersPath); + expect(findBtn().props('variant')).toBe('confirm'); + }); + }); + }); + + describe('When another platform has been selected', () => { + beforeEach(() => { + setWindowLocation(`?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`); + + createComponent(); + }); + + it('shows registration instructions for the platform', () => { + expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM); + }); + }); + + describe('When opening install instructions', () => { + beforeEach(() => { + createComponent(); + + findRegistrationInstructions().vm.$emit('toggleDrawer'); + }); + + it('opens platform drawer', () => { + expect(findPlatformsDrawer().props('open')).toBe(true); + }); + + it('closes platform drawer', async () => { + findRegistrationInstructions().vm.$emit('toggleDrawer'); + await nextTick(); + + expect(findPlatformsDrawer().props('open')).toBe(false); + }); + + it('closes platform drawer from drawer', async () => { + findPlatformsDrawer().vm.$emit('close'); + await nextTick(); + + expect(findPlatformsDrawer().props('open')).toBe(false); + }); + + describe('when selecting a platform', () => { + beforeEach(() => { + findPlatformsDrawer().vm.$emit('selectPlatform', WINDOWS_PLATFORM); + }); + + it('updates the url', () => { + expect(updateHistory).toHaveBeenCalledTimes(1); + expect(updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`, + }); + }); + + it('updates the registration instructions', () => { + expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM); + }); + }); + }); +}); 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 2ad31dea774..0c594e8005c 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 @@ -4,8 +4,8 @@ import VueApollo from 'vue-apollo'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert, VARIANT_SUCCESS } from '~/flash'; -import { redirectTo } from '~/lib/utils/url_utility'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/ci/runner/components/runner_header.vue'; @@ -24,7 +24,7 @@ import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_al import { runnerData } from '../mock_data'; jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); jest.mock('~/lib/utils/url_utility'); @@ -74,7 +74,6 @@ describe('GroupRunnerShowApp', () => { afterEach(() => { mockRunnerQuery.mockReset(); - wrapper.destroy(); }); describe('When showing runner details', () => { @@ -84,7 +83,7 @@ describe('GroupRunnerShowApp', () => { await createComponent({ mountFn: mountExtended }); }); - it('expect GraphQL ID to be requested', async () => { + it('expect GraphQL ID to be requested', () => { expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); }); @@ -92,7 +91,7 @@ describe('GroupRunnerShowApp', () => { expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); }); - it('displays the runner edit and pause buttons', async () => { + it('displays the runner edit and pause buttons', () => { expect(findRunnerEditButton().attributes('href')).toBe(mockEditGroupRunnerPath); expect(findRunnerPauseButton().exists()).toBe(true); expect(findRunnerDeleteButton().exists()).toBe(true); @@ -186,7 +185,7 @@ describe('GroupRunnerShowApp', () => { message: 'Runner deleted', variant: VARIANT_SUCCESS, }); - expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); + expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); // eslint-disable-line import/no-deprecated }); }); }); diff --git a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js index 39ea5cade28..41be72b1645 100644 --- a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js @@ -9,7 +9,7 @@ import { mountExtended, } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -58,23 +58,22 @@ import { groupRunnersCountData, onlineContactTimeoutSecs, staleTimeoutSecs, + mockRegistrationToken, + newRunnerPath, emptyPageInfo, - emptyStateSvgPath, - emptyStateFilteredSvgPath, } from '../mock_data'; Vue.use(VueApollo); Vue.use(GlToast); const mockGroupFullPath = 'group1'; -const mockRegistrationToken = 'AABBCC'; const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges; const mockGroupRunnersCount = mockGroupRunnersEdges.length; const mockGroupRunnersHandler = jest.fn(); const mockGroupRunnersCountHandler = jest.fn(); -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), @@ -87,6 +86,7 @@ describe('GroupRunnersApp', () => { const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); + const findNewRunnerBtn = () => wrapper.findByText(s__('Runners|New group runner')); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); @@ -114,14 +114,13 @@ describe('GroupRunnersApp', () => { propsData: { registrationToken: mockRegistrationToken, groupFullPath: mockGroupFullPath, + newRunnerPath, ...props, }, provide: { localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, - emptyStateSvgPath, - emptyStateFilteredSvgPath, ...provide, }, ...options, @@ -138,7 +137,6 @@ describe('GroupRunnersApp', () => { afterEach(() => { mockGroupRunnersHandler.mockReset(); mockGroupRunnersCountHandler.mockReset(); - wrapper.destroy(); }); it('shows the runner tabs with a runner count for each type', async () => { @@ -288,7 +286,7 @@ describe('GroupRunnersApp', () => { }); }); - it('When runner is paused or unpaused, some data is refetched', async () => { + it('When runner is paused or unpaused, some data is refetched', () => { expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); findRunnerActionsCell().vm.$emit('toggledPaused'); @@ -300,7 +298,7 @@ describe('GroupRunnersApp', () => { expect(showToast).toHaveBeenCalledTimes(0); }); - it('When runner is deleted, data is refetched and a toast message is shown', async () => { + it('When runner is deleted, data is refetched and a toast message is shown', () => { findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); expect(showToast).toHaveBeenCalledTimes(1); @@ -389,7 +387,7 @@ describe('GroupRunnersApp', () => { it('when runners have not loaded, shows a loading state', () => { createComponent(); expect(findRunnerList().props('loading')).toBe(true); - expect(findRunnerPagination().attributes('disabled')).toBe('true'); + expect(findRunnerPagination().attributes('disabled')).toBeDefined(); }); it('runners can be deleted in bulk', () => { @@ -417,8 +415,12 @@ describe('GroupRunnersApp', () => { expect(createAlert).not.toHaveBeenCalled(); }); - it('shows an empty state', async () => { - expect(findRunnerListEmptyState().exists()).toBe(true); + it('shows an empty state', () => { + expect(findRunnerListEmptyState().props()).toMatchObject({ + isSearchFiltered: false, + newRunnerPath, + registrationToken: mockRegistrationToken, + }); }); }); @@ -428,11 +430,11 @@ describe('GroupRunnersApp', () => { await createComponent(); }); - it('error is shown to the user', async () => { + it('error is shown to the user', () => { expect(createAlert).toHaveBeenCalledTimes(1); }); - it('error is reported to sentry', async () => { + it('error is reported to sentry', () => { expect(captureException).toHaveBeenCalledWith({ error: new Error('Error!'), component: 'GroupRunnersApp', @@ -469,32 +471,69 @@ describe('GroupRunnersApp', () => { }); describe('when user has permission to register group runner', () => { - beforeEach(() => { + it('shows the register group runner button', () => { createComponent({ - propsData: { + props: { registrationToken: mockRegistrationToken, - groupFullPath: mockGroupFullPath, }, }); + expect(findRegistrationDropdown().exists()).toBe(true); }); - it('shows the register group runner button', () => { - expect(findRegistrationDropdown().exists()).toBe(true); + it('when create_runner_workflow_for_namespace is enabled', () => { + createComponent({ + props: { + newRunnerPath, + }, + provide: { + glFeatures: { + createRunnerWorkflowForNamespace: true, + }, + }, + }); + + expect(findNewRunnerBtn().attributes('href')).toBe(newRunnerPath); + }); + + it('when create_runner_workflow_for_namespace is disabled', () => { + createComponent({ + props: { + newRunnerPath, + }, + provide: { + glFeatures: { + createRunnerWorkflowForNamespace: false, + }, + }, + }); + + expect(findNewRunnerBtn().exists()).toBe(false); }); }); describe('when user has no permission to register group runner', () => { - beforeEach(() => { + it('does not show the register group runner button', () => { createComponent({ - propsData: { + props: { registrationToken: null, - groupFullPath: mockGroupFullPath, }, }); + expect(findRegistrationDropdown().exists()).toBe(false); }); - it('does not show the register group runner button', () => { - expect(findRegistrationDropdown().exists()).toBe(false); + it('when create_runner_workflow_for_namespace is enabled', () => { + createComponent({ + props: { + newRunnerPath: null, + }, + provide: { + glFeatures: { + createRunnerWorkflowForNamespace: true, + }, + }, + }); + + expect(findNewRunnerBtn().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js b/spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js index 03908891cfd..30e49fc7644 100644 --- a/spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js +++ b/spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js @@ -2,9 +2,9 @@ import AccessorUtilities from '~/lib/utils/accessor'; import { showAlertFromLocalStorage } from '~/ci/runner/local_storage_alert/show_alert_from_local_storage'; import { LOCAL_STORAGE_ALERT_KEY } from '~/ci/runner/local_storage_alert/constants'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; -jest.mock('~/flash'); +jest.mock('~/alert'); describe('showAlertFromLocalStorage', () => { useLocalStorageSpy(); diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js index 5cdf0ea4e3b..223a156795c 100644 --- a/spec/frontend/ci/runner/mock_data.js +++ b/spec/frontend/ci/runner/mock_data.js @@ -1,6 +1,19 @@ // Fixtures generated by: spec/frontend/fixtures/runner.rb +// List queries +import allRunnersWithCreatorData from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.with_creator.json'; +import allRunnersData from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.json'; +import allRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.paginated.json'; +import runnersCountData from 'test_fixtures/graphql/ci/runner/list/all_runners_count.query.graphql.json'; +import groupRunnersData from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.json'; +import groupRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.paginated.json'; +import groupRunnersCountData from 'test_fixtures/graphql/ci/runner/list/group_runners_count.query.graphql.json'; + +// Register runner queries +import runnerForRegistration from 'test_fixtures/graphql/ci/runner/register/runner_for_registration.query.graphql.json'; + // Show runner queries +import runnerCreateResult from 'test_fixtures/graphql/ci/runner/new/runner_create.mutation.graphql.json'; import runnerData from 'test_fixtures/graphql/ci/runner/show/runner.query.graphql.json'; import runnerWithGroupData from 'test_fixtures/graphql/ci/runner/show/runner.query.graphql.with_group.json'; import runnerProjectsData from 'test_fixtures/graphql/ci/runner/show/runner_projects.query.graphql.json'; @@ -9,15 +22,16 @@ import runnerJobsData from 'test_fixtures/graphql/ci/runner/show/runner_jobs.que // Edit runner queries import runnerFormData from 'test_fixtures/graphql/ci/runner/edit/runner_form.query.graphql.json'; -// List queries -import allRunnersData from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.json'; -import allRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.paginated.json'; -import runnersCountData from 'test_fixtures/graphql/ci/runner/list/all_runners_count.query.graphql.json'; -import groupRunnersData from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.json'; -import groupRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.paginated.json'; -import groupRunnersCountData from 'test_fixtures/graphql/ci/runner/list/group_runners_count.query.graphql.json'; - -import { DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE } from '~/ci/runner/constants'; +// New runner queries +import { + DEFAULT_MEMBERSHIP, + INSTANCE_TYPE, + CREATED_DESC, + CREATED_ASC, + STATUS_ONLINE, + STATUS_STALE, + RUNNER_PAGE_SIZE, +} from '~/ci/runner/constants'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; const emptyPageInfo = { @@ -40,29 +54,29 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, isDefault: true, }, { name: 'a single status', - urlQuery: '?status[]=ACTIVE', + urlQuery: '?status[]=ONLINE', search: { runnerType: null, membership: DEFAULT_MEMBERSHIP, - filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, - status: 'ACTIVE', - sort: 'CREATED_DESC', + status: STATUS_ONLINE, + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, @@ -79,12 +93,12 @@ export const mockSearchExamples = [ }, ], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, search: 'something', - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, @@ -105,12 +119,12 @@ export const mockSearchExamples = [ }, ], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, search: 'something else', - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, @@ -118,54 +132,54 @@ export const mockSearchExamples = [ name: 'single instance type', urlQuery: '?runner_type[]=INSTANCE_TYPE', search: { - runnerType: 'INSTANCE_TYPE', + runnerType: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, filters: [], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { - type: 'INSTANCE_TYPE', + type: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, { name: 'multiple runner status', - urlQuery: '?status[]=ACTIVE&status[]=PAUSED', + urlQuery: '?status[]=ONLINE&status[]=STALE', search: { runnerType: null, membership: DEFAULT_MEMBERSHIP, filters: [ - { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, - { type: 'status', value: { data: 'PAUSED', operator: '=' } }, + { type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }, + { type: 'status', value: { data: STATUS_STALE, operator: '=' } }, ], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { - status: 'ACTIVE', + status: STATUS_ONLINE, membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, { name: 'multiple status, a single instance type and a non default sort', - urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', + urlQuery: '?status[]=ONLINE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', search: { - runnerType: 'INSTANCE_TYPE', + runnerType: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, - filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], pagination: {}, - sort: 'CREATED_ASC', + sort: CREATED_ASC, }, graphqlVariables: { - status: 'ACTIVE', - type: 'INSTANCE_TYPE', + status: STATUS_ONLINE, + type: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_ASC', + sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }, }, @@ -177,13 +191,13 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1'], - first: 20, - sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + sort: CREATED_DESC, }, }, { @@ -197,13 +211,13 @@ export const mockSearchExamples = [ { type: 'tag', value: { data: 'tag-2', operator: '=' } }, ], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1', 'tag-2'], - first: 20, - sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + sort: CREATED_DESC, }, }, { @@ -214,11 +228,11 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [], pagination: { after: 'AFTER_CURSOR' }, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE, }, @@ -231,11 +245,11 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [], pagination: { before: 'BEFORE_CURSOR' }, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE, }, @@ -243,24 +257,24 @@ export const mockSearchExamples = [ { name: 'the next page filtered by a status, an instance type, tags and a non default sort', urlQuery: - '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&after=AFTER_CURSOR', + '?status[]=ONLINE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&after=AFTER_CURSOR', search: { - runnerType: 'INSTANCE_TYPE', + runnerType: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, filters: [ - { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }, { type: 'tag', value: { data: 'tag-1', operator: '=' } }, { type: 'tag', value: { data: 'tag-2', operator: '=' } }, ], pagination: { after: 'AFTER_CURSOR' }, - sort: 'CREATED_ASC', + sort: CREATED_ASC, }, graphqlVariables: { - status: 'ACTIVE', - type: 'INSTANCE_TYPE', + status: STATUS_ONLINE, + type: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1', 'tag-2'], - sort: 'CREATED_ASC', + sort: CREATED_ASC, after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE, }, @@ -273,12 +287,12 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { paused: true, membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, @@ -290,12 +304,12 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { paused: false, membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, @@ -304,12 +318,14 @@ export const mockSearchExamples = [ export const onlineContactTimeoutSecs = 2 * 60 * 60; export const staleTimeoutSecs = 7889238; // Ruby's `3.months` +export const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +export const mockAuthenticationToken = 'MOCK_AUTHENTICATION_TOKEN'; + export const newRunnerPath = '/runners/new'; -export const emptyStateSvgPath = 'emptyStateSvgPath.svg'; -export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg'; export { allRunnersData, + allRunnersWithCreatorData, allRunnersDataPaginated, runnersCountData, groupRunnersData, @@ -321,4 +337,6 @@ export { runnerProjectsData, runnerJobsData, runnerFormData, + runnerCreateResult, + runnerForRegistration, }; diff --git a/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js b/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js new file mode 100644 index 00000000000..5bfbbfdc074 --- /dev/null +++ b/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js @@ -0,0 +1,125 @@ +import { GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; + +import ProjectRunnerRunnerApp from '~/ci/runner/project_new_runner/project_new_runner_app.vue'; +import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue'; +import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue'; +import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; +import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; +import { + PARAM_KEY_PLATFORM, + PROJECT_TYPE, + DEFAULT_PLATFORM, + WINDOWS_PLATFORM, +} from '~/ci/runner/constants'; +import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { runnerCreateResult, mockRegistrationToken } from '../mock_data'; + +const mockProjectId = 'gid://gitlab/Project/72'; + +jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); +jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + redirectTo: jest.fn(), +})); + +const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner; + +describe('ProjectRunnerRunnerApp', () => { + let wrapper; + + const findRunnerPlatformsRadioGroup = () => wrapper.findComponent(RunnerPlatformsRadioGroup); + const findRegistrationFeedbackBanner = () => wrapper.findComponent(RegistrationFeedbackBanner); + const findRegistrationCompatibilityAlert = () => + wrapper.findComponent(RegistrationCompatibilityAlert); + const findRunnerCreateForm = () => wrapper.findComponent(RunnerCreateForm); + + const createComponent = () => { + wrapper = shallowMountExtended(ProjectRunnerRunnerApp, { + propsData: { + projectId: mockProjectId, + legacyRegistrationToken: mockRegistrationToken, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('shows a registration feedback banner', () => { + expect(findRegistrationFeedbackBanner().exists()).toBe(true); + }); + + it('shows a registration compatibility alert', () => { + expect(findRegistrationCompatibilityAlert().props('alertKey')).toBe(mockProjectId); + }); + + describe('Platform', () => { + it('shows the platforms radio group', () => { + expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM); + }); + }); + + describe('Runner form', () => { + it('shows the runner create form for an instance runner', () => { + expect(findRunnerCreateForm().props()).toEqual({ + runnerType: PROJECT_TYPE, + projectId: mockProjectId, + groupId: null, + }); + }); + + describe('When a runner is saved', () => { + beforeEach(() => { + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + }); + + it('pushes an alert to be shown after redirection', () => { + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: s__('Runners|Runner created.'), + variant: VARIANT_SUCCESS, + }); + }); + + it('redirects to the registration page', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; + + expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + }); + }); + + describe('When another platform is selected and a runner is saved', () => { + beforeEach(() => { + findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM); + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + }); + + it('redirects to the registration page with the platform', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; + + expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated + }); + }); + + describe('When runner fails to save', () => { + const ERROR_MSG = 'Cannot save!'; + + beforeEach(() => { + findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG)); + }); + + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/project_register_runner_app/project_register_runner_app_spec.js b/spec/frontend/ci/runner/project_register_runner_app/project_register_runner_app_spec.js new file mode 100644 index 00000000000..240fd82fb3b --- /dev/null +++ b/spec/frontend/ci/runner/project_register_runner_app/project_register_runner_app_spec.js @@ -0,0 +1,120 @@ +import { nextTick } from 'vue'; +import { GlButton } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; + +import { updateHistory } from '~/lib/utils/url_utility'; +import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM, WINDOWS_PLATFORM } from '~/ci/runner/constants'; +import ProjectRegisterRunnerApp from '~/ci/runner/project_register_runner/project_register_runner_app.vue'; +import RegistrationInstructions from '~/ci/runner/components/registration/registration_instructions.vue'; +import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawer.vue'; +import { runnerForRegistration } from '../mock_data'; + +const mockRunnerId = runnerForRegistration.data.runner.id; +const mockRunnersPath = '/group1/project1/-/settings/ci_cd'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +describe('ProjectRegisterRunnerApp', () => { + let wrapper; + + const findRegistrationInstructions = () => wrapper.findComponent(RegistrationInstructions); + const findPlatformsDrawer = () => wrapper.findComponent(PlatformsDrawer); + const findBtn = () => wrapper.findComponent(GlButton); + + const createComponent = () => { + wrapper = shallowMountExtended(ProjectRegisterRunnerApp, { + propsData: { + runnerId: mockRunnerId, + runnersPath: mockRunnersPath, + }, + }); + }; + + describe('When showing runner details', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when runner token is available', () => { + it('shows registration instructions', () => { + expect(findRegistrationInstructions().props()).toEqual({ + platform: DEFAULT_PLATFORM, + runnerId: mockRunnerId, + }); + }); + + it('configures platform drawer', () => { + expect(findPlatformsDrawer().props()).toEqual({ + open: false, + platform: DEFAULT_PLATFORM, + }); + }); + + it('shows runner list button', () => { + expect(findBtn().attributes('href')).toBe(mockRunnersPath); + expect(findBtn().props('variant')).toBe('confirm'); + }); + }); + }); + + describe('When another platform has been selected', () => { + beforeEach(() => { + setWindowLocation(`?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`); + + createComponent(); + }); + + it('shows registration instructions for the platform', () => { + expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM); + }); + }); + + describe('When opening install instructions', () => { + beforeEach(() => { + createComponent(); + + findRegistrationInstructions().vm.$emit('toggleDrawer'); + }); + + it('opens platform drawer', () => { + expect(findPlatformsDrawer().props('open')).toBe(true); + }); + + it('closes platform drawer', async () => { + findRegistrationInstructions().vm.$emit('toggleDrawer'); + await nextTick(); + + expect(findPlatformsDrawer().props('open')).toBe(false); + }); + + it('closes platform drawer from drawer', async () => { + findPlatformsDrawer().vm.$emit('close'); + await nextTick(); + + expect(findPlatformsDrawer().props('open')).toBe(false); + }); + + describe('when selecting a platform', () => { + beforeEach(() => { + findPlatformsDrawer().vm.$emit('selectPlatform', WINDOWS_PLATFORM); + }); + + it('updates the url', () => { + expect(updateHistory).toHaveBeenCalledTimes(1); + expect(updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`, + }); + }); + + it('updates the registration instructions', () => { + expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js b/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js index a9369a5e626..79bbf95f8f0 100644 --- a/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js +++ b/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/flash'; +import { createAlert } from '~/alert'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/ci/runner/components/runner_header.vue'; @@ -15,7 +15,7 @@ import { I18N_STATUS_NEVER_CONTACTED, I18N_INSTANCE_TYPE } from '~/ci/runner/con import { runnerFormData } from '../mock_data'; -jest.mock('~/flash'); +jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); const mockRunner = runnerFormData.data.runner; @@ -51,7 +51,6 @@ describe('RunnerEditApp', () => { afterEach(() => { mockRunnerQuery.mockReset(); - wrapper.destroy(); }); it('expect GraphQL ID to be requested', async () => { diff --git a/spec/frontend/ci/runner/runner_search_utils_spec.js b/spec/frontend/ci/runner/runner_search_utils_spec.js index f64b89d47fd..9a4a6139198 100644 --- a/spec/frontend/ci/runner/runner_search_utils_spec.js +++ b/spec/frontend/ci/runner/runner_search_utils_spec.js @@ -7,6 +7,7 @@ import { isSearchFiltered, } from 'ee_else_ce/ci/runner/runner_search_utils'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; +import { DEFAULT_SORT } from '~/ci/runner/constants'; import { mockSearchExamples } from './mock_data'; describe('search_params.js', () => { @@ -68,7 +69,7 @@ describe('search_params.js', () => { 'http://test.host/?paused[]=true', 'http://test.host/?search=my_text', ])('When a filter is removed, it is removed from the URL', (initialUrl) => { - const search = { filters: [], sort: 'CREATED_DESC' }; + const search = { filters: [], sort: DEFAULT_SORT }; const expectedUrl = `http://test.host/`; expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl); @@ -76,7 +77,7 @@ describe('search_params.js', () => { it('When unrelated search parameter is present, it does not get removed', () => { const initialUrl = `http://test.host/?unrelated=UNRELATED&status[]=ACTIVE`; - const search = { filters: [], sort: 'CREATED_DESC' }; + const search = { filters: [], sort: DEFAULT_SORT }; const expectedUrl = `http://test.host/?unrelated=UNRELATED`; expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl); diff --git a/spec/frontend/ci/runner/sentry_utils_spec.js b/spec/frontend/ci/runner/sentry_utils_spec.js index f7b689272ce..2f17cc43ac5 100644 --- a/spec/frontend/ci/runner/sentry_utils_spec.js +++ b/spec/frontend/ci/runner/sentry_utils_spec.js @@ -6,7 +6,7 @@ jest.mock('@sentry/browser'); describe('~/ci/runner/sentry_utils', () => { let mockSetTag; - beforeEach(async () => { + beforeEach(() => { mockSetTag = jest.fn(); Sentry.withScope.mockImplementation((fn) => { |