diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-11-17 11:33:21 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-11-17 11:33:21 +0000 |
commit | 7021455bd1ed7b125c55eb1b33c5a01f2bc55ee0 (patch) | |
tree | 5bdc2229f5198d516781f8d24eace62fc7e589e9 /spec/frontend/ci | |
parent | 185b095e93520f96e9cfc31d9c3e69b498cdab7c (diff) | |
download | gitlab-ce-7021455bd1ed7b125c55eb1b33c5a01f2bc55ee0.tar.gz |
Add latest changes from gitlab-org/gitlab@15-6-stable-eev15.6.0-rc42
Diffstat (limited to 'spec/frontend/ci')
65 files changed, 8426 insertions, 0 deletions
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 new file mode 100644 index 00000000000..ba948f12b33 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js @@ -0,0 +1,38 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import DeletePipelineScheduleModal from '~/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue'; + +describe('Delete pipeline schedule modal', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(DeletePipelineScheduleModal, { + propsData: { + visible: true, + ...props, + }, + }); + }; + + const findModal = () => wrapper.findComponent(GlModal); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('emits the deleteSchedule event', async () => { + findModal().vm.$emit('primary'); + + expect(wrapper.emitted()).toEqual({ deleteSchedule: [[]] }); + }); + + it('emits the hideModal event', async () => { + findModal().vm.$emit('hide'); + + expect(wrapper.emitted()).toEqual({ hideModal: [[]] }); + }); +}); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js new file mode 100644 index 00000000000..e5d9b378a42 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js @@ -0,0 +1,25 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlForm } from '@gitlab/ui'; +import PipelineSchedulesForm from '~/ci/pipeline_schedules/components/pipeline_schedules_form.vue'; + +describe('Pipeline schedules form', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineSchedulesForm); + }; + + const findForm = () => wrapper.findComponent(GlForm); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays form', () => { + expect(findForm().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js new file mode 100644 index 00000000000..4aa4cdf89a1 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js @@ -0,0 +1,280 @@ +import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { trimText } from 'helpers/text_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineSchedules from '~/ci/pipeline_schedules/components/pipeline_schedules.vue'; +import DeletePipelineScheduleModal from '~/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue'; +import TakeOwnershipModal from '~/ci/pipeline_schedules/components/take_ownership_modal.vue'; +import PipelineSchedulesTable from '~/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue'; +import deletePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql'; +import takeOwnershipMutation from '~/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql'; +import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; +import { + mockGetPipelineSchedulesGraphQLResponse, + mockPipelineScheduleNodes, + deleteMutationResponse, + takeOwnershipMutationResponse, +} from '../mock_data'; + +Vue.use(VueApollo); + +const $toast = { + show: jest.fn(), +}; + +describe('Pipeline schedules app', () => { + let wrapper; + + const successHandler = jest.fn().mockResolvedValue(mockGetPipelineSchedulesGraphQLResponse); + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + const deleteMutationHandlerSuccess = jest.fn().mockResolvedValue(deleteMutationResponse); + const deleteMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const takeOwnershipMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(takeOwnershipMutationResponse); + const takeOwnershipMutationHandlerFailed = jest + .fn() + .mockRejectedValue(new Error('GraphQL error')); + + const createMockApolloProvider = ( + requestHandlers = [[getPipelineSchedulesQuery, successHandler]], + ) => { + return createMockApollo(requestHandlers); + }; + + const createComponent = (requestHandlers) => { + wrapper = mountExtended(PipelineSchedules, { + provide: { + fullPath: 'gitlab-org/gitlab', + }, + mocks: { + $toast, + }, + apolloProvider: createMockApolloProvider(requestHandlers), + }); + }; + + const findTable = () => wrapper.findComponent(PipelineSchedulesTable); + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findDeleteModal = () => wrapper.findComponent(DeletePipelineScheduleModal); + const findTakeOwnershipModal = () => wrapper.findComponent(TakeOwnershipModal); + const findTabs = () => wrapper.findComponent(GlTabs); + const findNewButton = () => wrapper.findByTestId('new-schedule-button'); + const findAllTab = () => wrapper.findByTestId('pipeline-schedules-all-tab'); + const findActiveTab = () => wrapper.findByTestId('pipeline-schedules-active-tab'); + const findInactiveTab = () => wrapper.findByTestId('pipeline-schedules-inactive-tab'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays table, tabs and new button', async () => { + await waitForPromises(); + + expect(findTable().exists()).toBe(true); + expect(findNewButton().exists()).toBe(true); + expect(findTabs().exists()).toBe(true); + expect(findAlert().exists()).toBe(false); + }); + + it('handles loading state', async () => { + expect(findLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('fetching pipeline schedules', () => { + it('fetches query and passes an array of pipeline schedules', async () => { + createComponent(); + + expect(successHandler).toHaveBeenCalled(); + + await waitForPromises(); + + expect(findTable().props('schedules')).toEqual(mockPipelineScheduleNodes); + }); + + it('shows query error alert', async () => { + createComponent([[getPipelineSchedulesQuery, failedHandler]]); + + await waitForPromises(); + + expect(findAlert().text()).toBe('There was a problem fetching pipeline schedules.'); + }); + }); + + describe('deleting a pipeline schedule', () => { + it('shows delete mutation error alert', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [deletePipelineScheduleMutation, deleteMutationHandlerFailed], + ]); + + await waitForPromises(); + + findDeleteModal().vm.$emit('deleteSchedule'); + + await waitForPromises(); + + expect(findAlert().text()).toBe('There was a problem deleting the pipeline schedule.'); + }); + + it('deletes pipeline schedule and refetches query', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [deletePipelineScheduleMutation, deleteMutationHandlerSuccess], + ]); + + jest.spyOn(wrapper.vm.$apollo.queries.schedules, 'refetch'); + + await waitForPromises(); + + const scheduleId = mockPipelineScheduleNodes[0].id; + + findTable().vm.$emit('showDeleteModal', scheduleId); + + expect(wrapper.vm.$apollo.queries.schedules.refetch).not.toHaveBeenCalled(); + + findDeleteModal().vm.$emit('deleteSchedule'); + + await waitForPromises(); + + expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({ + id: scheduleId, + }); + expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalled(); + expect($toast.show).toHaveBeenCalledWith('Pipeline schedule successfully deleted.'); + }); + + it('handles delete modal visibility correctly', async () => { + createComponent(); + + await waitForPromises(); + + expect(findDeleteModal().props('visible')).toBe(false); + + findTable().vm.$emit('showDeleteModal', mockPipelineScheduleNodes[0].id); + + await nextTick(); + + expect(findDeleteModal().props('visible')).toBe(true); + expect(findTakeOwnershipModal().props('visible')).toBe(false); + + findDeleteModal().vm.$emit('hideModal'); + + await nextTick(); + + expect(findDeleteModal().props('visible')).toBe(false); + }); + }); + + describe('taking ownership of a pipeline schedule', () => { + it('shows take ownership mutation error alert', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [takeOwnershipMutation, takeOwnershipMutationHandlerFailed], + ]); + + await waitForPromises(); + + findTakeOwnershipModal().vm.$emit('takeOwnership'); + + await waitForPromises(); + + expect(findAlert().text()).toBe( + 'There was a problem taking ownership of the pipeline schedule.', + ); + }); + + it('takes ownership of pipeline schedule and refetches query', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [takeOwnershipMutation, takeOwnershipMutationHandlerSuccess], + ]); + + jest.spyOn(wrapper.vm.$apollo.queries.schedules, 'refetch'); + + await waitForPromises(); + + const scheduleId = mockPipelineScheduleNodes[1].id; + + findTable().vm.$emit('showTakeOwnershipModal', scheduleId); + + expect(wrapper.vm.$apollo.queries.schedules.refetch).not.toHaveBeenCalled(); + + findTakeOwnershipModal().vm.$emit('takeOwnership'); + + await waitForPromises(); + + expect(takeOwnershipMutationHandlerSuccess).toHaveBeenCalledWith({ + id: scheduleId, + }); + expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalled(); + expect($toast.show).toHaveBeenCalledWith('Successfully taken ownership from Admin.'); + }); + + it('handles take ownership modal visibility correctly', async () => { + createComponent(); + + await waitForPromises(); + + expect(findTakeOwnershipModal().props('visible')).toBe(false); + + findTable().vm.$emit('showTakeOwnershipModal', mockPipelineScheduleNodes[0].id); + + await nextTick(); + + expect(findTakeOwnershipModal().props('visible')).toBe(true); + expect(findDeleteModal().props('visible')).toBe(false); + + findTakeOwnershipModal().vm.$emit('hideModal'); + + await nextTick(); + + expect(findTakeOwnershipModal().props('visible')).toBe(false); + }); + }); + + describe('pipeline schedule tabs', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('displays All tab with count', () => { + expect(trimText(findAllTab().text())).toBe(`All ${mockPipelineScheduleNodes.length}`); + }); + + it('displays Active tab with no count', () => { + expect(findActiveTab().text()).toBe('Active'); + }); + + it('displays Inactive tab with no count', () => { + expect(findInactiveTab().text()).toBe('Inactive'); + }); + + it('should refetch the schedules query on a tab click', async () => { + jest.spyOn(wrapper.vm.$apollo.queries.schedules, 'refetch').mockImplementation(jest.fn()); + + expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalledTimes(0); + + await findAllTab().trigger('click'); + + expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalledTimes(1); + }); + }); +}); 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 new file mode 100644 index 00000000000..3364c61d155 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js @@ -0,0 +1,64 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineScheduleActions from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue'; +import { + mockPipelineScheduleNodes, + mockPipelineScheduleAsGuestNodes, + mockTakeOwnershipNodes, +} from '../../../mock_data'; + +describe('Pipeline schedule actions', () => { + let wrapper; + + const defaultProps = { + schedule: mockPipelineScheduleNodes[0], + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMountExtended(PipelineScheduleActions, { + propsData: { + ...props, + }, + }); + }; + + const findAllButtons = () => wrapper.findAllComponents(GlButton); + const findDeleteBtn = () => wrapper.findByTestId('delete-pipeline-schedule-btn'); + const findTakeOwnershipBtn = () => wrapper.findByTestId('take-ownership-pipeline-schedule-btn'); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays action buttons', () => { + createComponent(); + + expect(findAllButtons()).toHaveLength(3); + }); + + it('does not display action buttons', () => { + createComponent({ schedule: mockPipelineScheduleAsGuestNodes[0] }); + + expect(findAllButtons()).toHaveLength(0); + }); + + it('delete button emits showDeleteModal event and schedule id', () => { + createComponent(); + + findDeleteBtn().vm.$emit('click'); + + expect(wrapper.emitted()).toEqual({ + showDeleteModal: [[mockPipelineScheduleNodes[0].id]], + }); + }); + + it('take ownership button emits showTakeOwnershipModal event and schedule id', () => { + createComponent({ schedule: mockTakeOwnershipNodes[0] }); + + findTakeOwnershipBtn().vm.$emit('click'); + + expect(wrapper.emitted()).toEqual({ + showTakeOwnershipModal: [[mockTakeOwnershipNodes[0].id]], + }); + }); +}); 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 new file mode 100644 index 00000000000..17bf465baf3 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js @@ -0,0 +1,42 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import PipelineScheduleLastPipeline from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue'; +import { mockPipelineScheduleNodes } from '../../../mock_data'; + +describe('Pipeline schedule last pipeline', () => { + let wrapper; + + const defaultProps = { + schedule: mockPipelineScheduleNodes[2], + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMountExtended(PipelineScheduleLastPipeline, { + propsData: { + ...props, + }, + }); + }; + + const findCIBadge = () => wrapper.findComponent(CiBadge); + const findStatusText = () => wrapper.findByTestId('pipeline-schedule-status-text'); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays pipeline status', () => { + createComponent(); + + expect(findCIBadge().exists()).toBe(true); + expect(findCIBadge().props('status')).toBe(defaultProps.schedule.lastPipeline.detailedStatus); + expect(findStatusText().exists()).toBe(false); + }); + + it('displays "none" status text', () => { + createComponent({ schedule: mockPipelineScheduleNodes[0] }); + + expect(findStatusText().text()).toBe('None'); + expect(findCIBadge().exists()).toBe(false); + }); +}); 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 new file mode 100644 index 00000000000..1c06c411097 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js @@ -0,0 +1,43 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineScheduleNextRun from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { mockPipelineScheduleNodes } from '../../../mock_data'; + +describe('Pipeline schedule next run', () => { + let wrapper; + + const defaultProps = { + schedule: mockPipelineScheduleNodes[0], + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMountExtended(PipelineScheduleNextRun, { + propsData: { + ...props, + }, + }); + }; + + const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip); + const findInactive = () => wrapper.findByTestId('pipeline-schedule-inactive'); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays time ago', () => { + createComponent(); + + expect(findTimeAgo().exists()).toBe(true); + expect(findInactive().exists()).toBe(false); + expect(findTimeAgo().props('time')).toBe(defaultProps.schedule.realNextRun); + }); + + it('displays inactive state', () => { + const inactiveSchedule = mockPipelineScheduleNodes[1]; + createComponent({ schedule: inactiveSchedule }); + + expect(findInactive().text()).toBe('Inactive'); + expect(findTimeAgo().exists()).toBe(false); + }); +}); 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 new file mode 100644 index 00000000000..6c1991cb4ac --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js @@ -0,0 +1,40 @@ +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineScheduleOwner from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue'; +import { mockPipelineScheduleNodes } from '../../../mock_data'; + +describe('Pipeline schedule owner', () => { + let wrapper; + + const defaultProps = { + schedule: mockPipelineScheduleNodes[0], + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMount(PipelineScheduleOwner, { + propsData: { + ...props, + }, + }); + }; + + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays avatar', () => { + expect(findAvatar().exists()).toBe(true); + expect(findAvatar().props('src')).toBe(defaultProps.schedule.owner.avatarUrl); + }); + + it('avatar links to user', () => { + expect(findAvatarLink().attributes('href')).toBe(defaultProps.schedule.owner.webPath); + }); +}); 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 new file mode 100644 index 00000000000..f531f04a736 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js @@ -0,0 +1,41 @@ +import { GlIcon, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineScheduleTarget from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue'; +import { mockPipelineScheduleNodes } from '../../../mock_data'; + +describe('Pipeline schedule target', () => { + let wrapper; + + const defaultProps = { + schedule: mockPipelineScheduleNodes[0], + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMount(PipelineScheduleTarget, { + propsData: { + ...props, + }, + }); + }; + + const findIcon = () => wrapper.findComponent(GlIcon); + const findLink = () => wrapper.findComponent(GlLink); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays icon', () => { + expect(findIcon().exists()).toBe(true); + expect(findIcon().props('name')).toBe('fork'); + }); + + it('displays ref link', () => { + expect(findLink().attributes('href')).toBe(defaultProps.schedule.refPath); + expect(findLink().text()).toBe(defaultProps.schedule.refForDisplay); + }); +}); 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 new file mode 100644 index 00000000000..316b3bcf926 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js @@ -0,0 +1,39 @@ +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'; + +describe('Pipeline schedules table', () => { + let wrapper; + + const defaultProps = { + schedules: mockPipelineScheduleNodes, + }; + + const createComponent = (props = defaultProps) => { + wrapper = mountExtended(PipelineSchedulesTable, { + propsData: { + ...props, + }, + }); + }; + + const findTable = () => wrapper.findComponent(GlTableLite); + const findScheduleDescription = () => wrapper.findByTestId('pipeline-schedule-description'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays table', () => { + expect(findTable().exists()).toBe(true); + }); + + it('displays schedule description', () => { + expect(findScheduleDescription().text()).toBe('pipeline schedule'); + }); +}); 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 new file mode 100644 index 00000000000..7e6d4ec4bf8 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js @@ -0,0 +1,44 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TakeOwnershipModalLegacy from '~/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue'; + +describe('Take ownership modal', () => { + let wrapper; + const url = `/root/job-log-tester/-/pipeline_schedules/3/take_ownership`; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(TakeOwnershipModalLegacy, { + propsData: { + ownershipUrl: url, + ...props, + }, + }); + }; + + const findModal = () => wrapper.findComponent(GlModal); + + beforeEach(() => { + createComponent(); + }); + + it('has a primary action set to a url and a post data-method', () => { + const actionPrimary = findModal().props('actionPrimary'); + + expect(actionPrimary.attributes).toEqual( + expect.objectContaining([ + { + category: 'primary', + variant: 'confirm', + href: url, + 'data-method': 'post', + }, + ]), + ); + }); + + it('shows a take ownership message', () => { + expect(findModal().text()).toBe( + 'Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?', + ); + }); +}); 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 new file mode 100644 index 00000000000..e3965d13c19 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js @@ -0,0 +1,40 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import TakeOwnershipModal from '~/ci/pipeline_schedules/components/take_ownership_modal.vue'; + +describe('Take ownership modal', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(TakeOwnershipModal, { + propsData: { + visible: true, + ...props, + }, + }); + }; + + const findModal = () => wrapper.findComponent(GlModal); + + beforeEach(() => { + createComponent(); + }); + + it('shows a take ownership message', () => { + expect(findModal().text()).toBe( + 'Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?', + ); + }); + + it('emits the takeOwnership event', async () => { + findModal().vm.$emit('primary'); + + expect(wrapper.emitted()).toEqual({ takeOwnership: [[]] }); + }); + + it('emits the hideModal event', async () => { + 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 new file mode 100644 index 00000000000..3010f1d06c3 --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -0,0 +1,62 @@ +// Fixture located at spec/frontend/fixtures/pipeline_schedules.rb +import mockGetPipelineSchedulesGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.json'; +import mockGetPipelineSchedulesAsGuestGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.as_guest.json'; +import mockGetPipelineSchedulesTakeOwnershipGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.take_ownership.json'; + +const { + data: { + project: { + pipelineSchedules: { nodes }, + }, + }, +} = mockGetPipelineSchedulesGraphQLResponse; + +const { + data: { + project: { + pipelineSchedules: { nodes: guestNodes }, + }, + }, +} = mockGetPipelineSchedulesAsGuestGraphQLResponse; + +const { + data: { + project: { + pipelineSchedules: { nodes: takeOwnershipNodes }, + }, + }, +} = mockGetPipelineSchedulesTakeOwnershipGraphQLResponse; + +export const mockPipelineScheduleNodes = nodes; + +export const mockPipelineScheduleAsGuestNodes = guestNodes; + +export const mockTakeOwnershipNodes = takeOwnershipNodes; + +export const deleteMutationResponse = { + data: { + pipelineScheduleDelete: { + clientMutationId: null, + errors: [], + __typename: 'PipelineScheduleDeletePayload', + }, + }, +}; + +export const takeOwnershipMutationResponse = { + data: { + pipelineScheduleTakeOwnership: { + pipelineSchedule: { + id: '1', + owner: { + id: '2', + name: 'Admin', + }, + }, + errors: [], + __typename: 'PipelineScheduleTakeOwnershipPayload', + }, + }, +}; + +export { mockGetPipelineSchedulesGraphQLResponse }; 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 new file mode 100644 index 00000000000..7081bc57467 --- /dev/null +++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -0,0 +1,266 @@ +import Vue from 'vue'; +import { GlTab, GlTabs } from '@gitlab/ui'; +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 { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerHeader from '~/ci/runner/components/runner_header.vue'; +import RunnerDetails from '~/ci/runner/components/runner_details.vue'; +import RunnerPauseButton from '~/ci/runner/components/runner_pause_button.vue'; +import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; +import RunnerEditButton from '~/ci/runner/components/runner_edit_button.vue'; +import RunnersJobs from '~/ci/runner/components/runner_jobs.vue'; + +import runnerQuery from '~/ci/runner/graphql/show/runner.query.graphql'; +import AdminRunnerShowApp from '~/ci/runner/admin_runner_show/admin_runner_show_app.vue'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; + +import { runnerData } from '../mock_data'; + +jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility'); + +const mockRunner = runnerData.data.runner; +const mockRunnerGraphqlId = mockRunner.id; +const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; +const mockRunnersPath = '/admin/runners'; + +Vue.use(VueApollo); + +describe('AdminRunnerShowApp', () => { + let wrapper; + let mockRunnerQuery; + + const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); + const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); + const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); + const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + const findRunnersJobs = () => wrapper.findComponent(RunnersJobs); + const findJobCountBadge = () => wrapper.findByTestId('job-count-badge'); + + const mockRunnerQueryResult = (runner = {}) => { + mockRunnerQuery = jest.fn().mockResolvedValue({ + data: { + runner: { ...mockRunner, ...runner }, + }, + }); + }; + + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(AdminRunnerShowApp, { + apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]), + propsData: { + runnerId: mockRunnerId, + runnersPath: mockRunnersPath, + ...props, + }, + ...options, + }); + + return waitForPromises(); + }; + + afterEach(() => { + mockRunnerQuery.mockReset(); + wrapper.destroy(); + }); + + describe('When showing runner details', () => { + beforeEach(async () => { + mockRunnerQueryResult(); + + await createComponent({ mountFn: mountExtended }); + }); + + it('expect GraphQL ID to be requested', async () => { + expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); + }); + + it('displays the runner header', async () => { + expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); + }); + + it('displays the runner edit and pause buttons', async () => { + expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerPauseButton().exists()).toBe(true); + expect(findRunnerDeleteButton().exists()).toBe(true); + }); + + it('shows basic runner details', async () => { + const expected = `Description My Runner + Last contact Never contacted + Version 1.0.0 + IP Address None + Executor None + Architecture None + Platform darwin + Configuration Runs untagged jobs + Maximum job timeout None + Token expiry + Runner authentication token expiration + Runner authentication tokens will expire based on a set interval. + They will automatically rotate once expired. Learn more Never expires + Tags None`.replace(/\s+/g, ' '); + + expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); + }); + + describe('when runner cannot be updated', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + userPermissions: { + updateRunner: false, + }, + }); + + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('does not display the runner edit and pause buttons', () => { + expect(findRunnerEditButton().exists()).toBe(false); + expect(findRunnerPauseButton().exists()).toBe(false); + }); + }); + + describe('when runner cannot be deleted', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + userPermissions: { + deleteRunner: false, + }, + }); + + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('does not display the runner edit and pause buttons', () => { + expect(findRunnerDeleteButton().exists()).toBe(false); + }); + }); + + describe('when runner is deleted', () => { + beforeEach(async () => { + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('redirects to the runner list page', () => { + findRunnerDeleteButton().vm.$emit('deleted', { message: 'Runner deleted' }); + + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: 'Runner deleted', + variant: VARIANT_SUCCESS, + }); + expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); + }); + }); + + describe('when runner does not have an edit url', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + editAdminUrl: null, + }); + + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('does not display the runner edit button', () => { + expect(findRunnerEditButton().exists()).toBe(false); + expect(findRunnerPauseButton().exists()).toBe(true); + }); + }); + }); + + describe('When loading', () => { + it('does not show runner details', () => { + mockRunnerQueryResult(); + + createComponent(); + + expect(findRunnerDetails().exists()).toBe(false); + }); + + it('does not show runner jobs', () => { + mockRunnerQueryResult(); + + createComponent(); + + expect(findRunnersJobs().exists()).toBe(false); + }); + }); + + describe('When there is an error', () => { + beforeEach(async () => { + mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!')); + await createComponent(); + }); + + it('does not show runner details', () => { + expect(findRunnerDetails().exists()).toBe(false); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Error!'), + component: 'AdminRunnerShowApp', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalled(); + }); + }); + + describe('Jobs tab', () => { + const stubs = { + GlTab, + GlTabs, + }; + + it('without a runner, shows no jobs', () => { + mockRunnerQuery = jest.fn().mockResolvedValue({ + data: { + runner: null, + }, + }); + + createComponent({ stubs }); + + expect(findJobCountBadge().exists()).toBe(false); + expect(findRunnersJobs().exists()).toBe(false); + }); + + it('without a job count, shows no jobs count', async () => { + mockRunnerQueryResult({ jobCount: null }); + + await createComponent({ stubs }); + + expect(findJobCountBadge().exists()).toBe(false); + }); + + it('with a job count, shows jobs count', async () => { + const runner = { jobCount: 3 }; + mockRunnerQueryResult(runner); + + await createComponent({ stubs }); + + expect(findJobCountBadge().text()).toBe('3'); + expect(findRunnersJobs().props('runner')).toEqual({ ...mockRunner, ...runner }); + }); + }); +}); 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 new file mode 100644 index 00000000000..9778a6fe66c --- /dev/null +++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js @@ -0,0 +1,473 @@ +import Vue, { nextTick } from 'vue'; +import { GlToast, GlLink } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { + extendedWrapper, + shallowMountExtended, + mountExtended, +} from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { updateHistory } from '~/lib/utils/url_utility'; + +import { upgradeStatusTokenConfig } from 'ee_else_ce/ci/runner/components/search_tokens/upgrade_status_token_config'; +import { createLocalState } from '~/ci/runner/graphql/list/local_state'; +import AdminRunnersApp from '~/ci/runner/admin_runners/admin_runners_app.vue'; +import RunnerTypeTabs from '~/ci/runner/components/runner_type_tabs.vue'; +import RunnerFilteredSearchBar from '~/ci/runner/components/runner_filtered_search_bar.vue'; +import RunnerList from '~/ci/runner/components/runner_list.vue'; +import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue'; +import RunnerStats from '~/ci/runner/components/stat/runner_stats.vue'; +import RunnerActionsCell from '~/ci/runner/components/cells/runner_actions_cell.vue'; +import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue'; +import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; + +import { + ADMIN_FILTERED_SEARCH_NAMESPACE, + CREATED_ASC, + CREATED_DESC, + DEFAULT_SORT, + I18N_STATUS_ONLINE, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + I18N_INSTANCE_TYPE, + I18N_GROUP_TYPE, + I18N_PROJECT_TYPE, + INSTANCE_TYPE, + PARAM_KEY_PAUSED, + PARAM_KEY_STATUS, + PARAM_KEY_TAG, + STATUS_ONLINE, + DEFAULT_MEMBERSHIP, + RUNNER_PAGE_SIZE, +} from '~/ci/runner/constants'; +import allRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners.query.graphql'; +import allRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners_count.query.graphql'; +import { captureException } from '~/ci/runner/sentry_utils'; + +import { + allRunnersData, + runnersCountData, + allRunnersDataPaginated, + onlineContactTimeoutSecs, + staleTimeoutSecs, + 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('~/ci/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +Vue.use(VueApollo); +Vue.use(GlToast); + +const COUNT_QUERIES = 7; // 4 tabs + 3 status queries + +describe('AdminRunnersApp', () => { + let wrapper; + let showToast; + + const findRunnerStats = () => wrapper.findComponent(RunnerStats); + const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); + const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); + const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); + const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); + const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); + const findRunnerPaginationNext = () => findRunnerPagination().findByText(s__('Pagination|Next')); + const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + + const createComponent = ({ + props = {}, + mountFn = shallowMountExtended, + provide, + ...options + } = {}) => { + const { cacheConfig, localMutations } = createLocalState(); + + const handlers = [ + [allRunnersQuery, mockRunnersHandler], + [allRunnersCountQuery, mockRunnersCountHandler], + ]; + + wrapper = mountFn(AdminRunnersApp, { + apolloProvider: createMockApollo(handlers, {}, cacheConfig), + propsData: { + registrationToken: mockRegistrationToken, + ...props, + }, + provide: { + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, + ...provide, + }, + ...options, + }); + + showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); + + return waitForPromises(); + }; + + beforeEach(() => { + mockRunnersHandler.mockResolvedValue(allRunnersData); + mockRunnersCountHandler.mockResolvedValue(runnersCountData); + }); + + afterEach(() => { + mockRunnersHandler.mockReset(); + mockRunnersCountHandler.mockReset(); + showToast.mockReset(); + wrapper.destroy(); + }); + + it('shows the runner setup instructions', () => { + createComponent(); + + expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE); + }); + + describe('shows total runner counts', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + }); + + it('fetches counts', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); + }); + + it('shows the runner tabs', () => { + const tabs = findRunnerTypeTabs().text(); + expect(tabs).toMatchInterpolatedText( + `All ${mockRunnersCount} ${I18N_INSTANCE_TYPE} ${mockRunnersCount} ${I18N_GROUP_TYPE} ${mockRunnersCount} ${I18N_PROJECT_TYPE} ${mockRunnersCount}`, + ); + }); + + it('shows the total', () => { + expect(findRunnerStats().text()).toContain(`${I18N_STATUS_ONLINE} ${mockRunnersCount}`); + expect(findRunnerStats().text()).toContain(`${I18N_STATUS_OFFLINE} ${mockRunnersCount}`); + expect(findRunnerStats().text()).toContain(`${I18N_STATUS_STALE} ${mockRunnersCount}`); + }); + }); + + it('shows the runners list', async () => { + await createComponent(); + + expect(mockRunnersHandler).toHaveBeenCalledTimes(1); + expect(findRunnerList().props('runners')).toEqual(mockRunners); + }); + + it('runner item links to the runner admin page', async () => { + await createComponent({ mountFn: mountExtended }); + + const { id, shortSha } = 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}`); + }); + + it('renders runner actions for each runner', async () => { + await createComponent({ mountFn: mountExtended }); + + const runnerActions = wrapper + .find('tr [data-testid="td-actions"]') + .findComponent(RunnerActionsCell); + const runner = mockRunners[0]; + + expect(runnerActions.props()).toEqual({ + runner, + editUrl: runner.editAdminUrl, + }); + }); + + it('requests the runners with no filters', async () => { + await createComponent(); + + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ + status: undefined, + type: undefined, + membership: DEFAULT_MEMBERSHIP, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('sets tokens in the filtered search', () => { + createComponent(); + + expect(findRunnerFilteredSearchBar().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_PAUSED, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + recentSuggestionsStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, + }), + upgradeStatusTokenConfig, + ]); + }); + + describe('Single runner row', () => { + const { id: graphqlId, shortSha } = mockRunners[0]; + const id = getIdFromGraphQLId(graphqlId); + + beforeEach(async () => { + mockRunnersCountHandler.mockClear(); + + await createComponent({ mountFn: mountExtended }); + }); + + it('Links to the runner page', async () => { + 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}`); + }); + + it('When runner is paused or unpaused, some data is refetched', async () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); + + findRunnerActionsCell().vm.$emit('toggledPaused'); + + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); + expect(showToast).toHaveBeenCalledTimes(0); + }); + + it('When runner is deleted, data is refetched and a toast message is shown', async () => { + findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Runner deleted'); + }); + }); + + describe('when a filter is preselected', () => { + beforeEach(async () => { + setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}&paused[]=true`); + + await createComponent({ mountFn: mountExtended }); + }); + + it('sets the filters in the search bar', () => { + expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + runnerType: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, + filters: [ + { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, + { type: PARAM_KEY_PAUSED, value: { data: 'true', operator: '=' } }, + ], + sort: 'CREATED_DESC', + pagination: {}, + }); + }); + + it('requests the runners with filter parameters', () => { + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ + status: STATUS_ONLINE, + type: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, + paused: true, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('fetches count results for requested status', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledWith({ + type: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, + status: STATUS_ONLINE, + paused: true, + }); + }); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + + findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + sort: CREATED_ASC, + }); + + await nextTick(); + }); + + it('updates the browser url', () => { + expect(updateHistory).toHaveBeenLastCalledWith({ + title: expect.any(String), + url: expect.stringContaining('?status[]=ONLINE&sort=CREATED_ASC'), + }); + }); + + it('requests the runners with filters', () => { + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ + status: STATUS_ONLINE, + membership: DEFAULT_MEMBERSHIP, + sort: CREATED_ASC, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('fetches count results for requested status', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledWith({ + status: STATUS_ONLINE, + membership: DEFAULT_MEMBERSHIP, + }); + }); + }); + + it('when runners have not loaded, shows a loading state', () => { + createComponent(); + expect(findRunnerList().props('loading')).toBe(true); + expect(findRunnerPagination().attributes('disabled')).toBe('true'); + }); + + describe('Bulk delete', () => { + describe('Before runners are deleted', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + }); + + it('runner list is checkable', () => { + expect(findRunnerList().props('checkable')).toBe(true); + }); + }); + + describe('When runners are deleted', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + }); + + it('count data is refetched', async () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); + + findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); + + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); + }); + + it('toast is shown', async () => { + expect(showToast).toHaveBeenCalledTimes(0); + + findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Runners deleted'); + }); + }); + }); + + describe('when no runners are found', () => { + beforeEach(async () => { + mockRunnersHandler.mockResolvedValue({ + data: { + runners: { + nodes: [], + pageInfo: emptyPageInfo, + }, + }, + }); + + await createComponent(); + }); + + it('shows no errors', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('shows an empty state', () => { + expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(false); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(async () => { + findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + sort: CREATED_ASC, + }); + await waitForPromises(); + }); + + it('shows an empty state for a filtered search', () => { + expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(true); + }); + }); + }); + + describe('when runners query fails', () => { + beforeEach(async () => { + mockRunnersHandler.mockRejectedValue(new Error('Error!')); + await createComponent(); + }); + + it('error is shown to the user', async () => { + expect(createAlert).toHaveBeenCalledTimes(1); + }); + + it('error is reported to sentry', async () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Error!'), + component: 'AdminRunnersApp', + }); + }); + }); + + describe('Pagination', () => { + const { pageInfo } = allRunnersDataPaginated.data.runners; + + beforeEach(async () => { + mockRunnersHandler.mockResolvedValue(allRunnersDataPaginated); + + await createComponent({ mountFn: mountExtended }); + }); + + it('passes the page info', () => { + expect(findRunnerPagination().props('pageInfo')).toEqual(pageInfo); + }); + + it('navigates to the next page', async () => { + await findRunnerPaginationNext().trigger('click'); + + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ + membership: DEFAULT_MEMBERSHIP, + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: pageInfo.endCursor, + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/__snapshots__/runner_status_popover_spec.js.snap b/spec/frontend/ci/runner/components/__snapshots__/runner_status_popover_spec.js.snap new file mode 100644 index 00000000000..b27a1adf01b --- /dev/null +++ b/spec/frontend/ci/runner/components/__snapshots__/runner_status_popover_spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RunnerStatusPopover renders complete text 1`] = `"Never contacted: Runner has never contacted GitLab (when you register a runner, use gitlab-runner run to bring it online) Online: Runner has contacted GitLab within the last 2 hours Offline: Runner has not contacted GitLab in more than 2 hours Stale: Runner has not contacted GitLab in more than 3 months"`; diff --git a/spec/frontend/ci/runner/components/cells/link_cell_spec.js b/spec/frontend/ci/runner/components/cells/link_cell_spec.js new file mode 100644 index 00000000000..61bb4432c8e --- /dev/null +++ b/spec/frontend/ci/runner/components/cells/link_cell_spec.js @@ -0,0 +1,72 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import LinkCell from '~/ci/runner/components/cells/link_cell.vue'; + +describe('LinkCell', () => { + let wrapper; + + const findGlLink = () => wrapper.findComponent(GlLink); + const findSpan = () => wrapper.find('span'); + + const createComponent = ({ props = {}, ...options } = {}) => { + wrapper = shallowMountExtended(LinkCell, { + propsData: { + ...props, + }, + ...options, + }); + }; + + it('when an href is provided, renders a link', () => { + createComponent({ props: { href: '/url' } }); + expect(findGlLink().exists()).toBe(true); + }); + + it('when an href is not provided, renders no link', () => { + createComponent(); + expect(findGlLink().exists()).toBe(false); + }); + + describe.each` + href | findContent + ${null} | ${findSpan} + ${'/url'} | ${findGlLink} + `('When href is $href', ({ href, findContent }) => { + const content = 'My Text'; + const attrs = { foo: 'bar' }; + const listeners = { + click: jest.fn(), + }; + + beforeEach(() => { + createComponent({ + props: { href }, + slots: { + default: content, + }, + attrs, + listeners, + }); + }); + + afterAll(() => { + listeners.click.mockReset(); + }); + + it('Renders content', () => { + expect(findContent().text()).toBe(content); + }); + + it('Passes attributes', () => { + expect(findContent().attributes()).toMatchObject(attrs); + }); + + it('Passes event listeners', () => { + expect(listeners.click).toHaveBeenCalledTimes(0); + + findContent().vm.$emit('click'); + + expect(listeners.click).toHaveBeenCalledTimes(1); + }); + }); +}); 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 new file mode 100644 index 00000000000..82e262d1b73 --- /dev/null +++ b/spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js @@ -0,0 +1,138 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import RunnerActionsCell from '~/ci/runner/components/cells/runner_actions_cell.vue'; +import RunnerPauseButton from '~/ci/runner/components/runner_pause_button.vue'; +import RunnerEditButton from '~/ci/runner/components/runner_edit_button.vue'; +import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; +import { allRunnersData } from '../../mock_data'; + +const mockRunner = allRunnersData.data.runners.nodes[0]; + +describe('RunnerActionsCell', () => { + let wrapper; + + const findEditBtn = () => wrapper.findComponent(RunnerEditButton); + const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton); + const findDeleteBtn = () => wrapper.findComponent(RunnerDeleteButton); + + const createComponent = ({ runner = {}, ...props } = {}) => { + wrapper = shallowMountExtended(RunnerActionsCell, { + propsData: { + editUrl: mockRunner.editAdminUrl, + runner: { + id: mockRunner.id, + shortSha: mockRunner.shortSha, + editAdminUrl: mockRunner.editAdminUrl, + userPermissions: mockRunner.userPermissions, + ...runner, + }, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Edit Action', () => { + it('Displays the runner edit link with the correct href', () => { + createComponent(); + + expect(findEditBtn().attributes('href')).toBe(mockRunner.editAdminUrl); + }); + + it('Does not render the runner edit link when user cannot update', () => { + createComponent({ + runner: { + userPermissions: { + ...mockRunner.userPermissions, + updateRunner: false, + }, + }, + }); + + expect(findEditBtn().exists()).toBe(false); + }); + + it('Does not render the runner edit link when editUrl is not provided', () => { + createComponent({ + editUrl: null, + }); + + expect(findEditBtn().exists()).toBe(false); + }); + }); + + describe('Pause action', () => { + it('Renders a compact pause button', () => { + createComponent(); + + expect(findRunnerPauseBtn().props('compact')).toBe(true); + }); + + it('Does not render the runner pause button when user cannot update', () => { + createComponent({ + runner: { + userPermissions: { + ...mockRunner.userPermissions, + updateRunner: false, + }, + }, + }); + + expect(findRunnerPauseBtn().exists()).toBe(false); + }); + }); + + describe('Delete action', () => { + it('Renders a compact delete button', () => { + createComponent(); + + expect(findDeleteBtn().props('compact')).toBe(true); + }); + + it('Passes runner data to delete button', () => { + createComponent({ + runner: mockRunner, + }); + + expect(findDeleteBtn().props('runner')).toEqual(mockRunner); + }); + + it('Emits toggledPaused events', () => { + createComponent(); + + expect(wrapper.emitted('toggledPaused')).toBe(undefined); + + findRunnerPauseBtn().vm.$emit('toggledPaused'); + + expect(wrapper.emitted('toggledPaused')).toHaveLength(1); + }); + + it('Emits delete events', () => { + const value = { name: 'Runner' }; + + createComponent(); + + expect(wrapper.emitted('deleted')).toBe(undefined); + + findDeleteBtn().vm.$emit('deleted', value); + + expect(wrapper.emitted('deleted')).toEqual([[value]]); + }); + + it('Does not render the runner delete button when user cannot delete', () => { + createComponent({ + runner: { + userPermissions: { + ...mockRunner.userPermissions, + deleteRunner: false, + }, + }, + }); + + expect(findDeleteBtn().exists()).toBe(false); + }); + }); +}); 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 new file mode 100644 index 00000000000..3097e43e583 --- /dev/null +++ b/spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js @@ -0,0 +1,111 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import RunnerOwnerCell from '~/ci/runner/components/cells/runner_owner_cell.vue'; + +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/ci/runner/constants'; + +describe('RunnerOwnerCell', () => { + let wrapper; + + const findLink = () => wrapper.findComponent(GlLink); + const getLinkTooltip = () => getBinding(findLink().element, 'gl-tooltip').value; + + const createComponent = ({ runner } = {}) => { + wrapper = shallowMount(RunnerOwnerCell, { + directives: { + GlTooltip: createMockDirective(), + }, + propsData: { + runner, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When its an instance runner', () => { + beforeEach(() => { + createComponent({ + runner: { + runnerType: INSTANCE_TYPE, + }, + }); + }); + + it('shows an administrator label', () => { + expect(findLink().exists()).toBe(false); + expect(wrapper.text()).toBe(s__('Runners|Administrator')); + }); + }); + + describe('When its a group runner', () => { + const mockName = 'Group 2'; + const mockFullName = 'Group 1 / Group 2'; + const mockWebUrl = '/group-1/group-2'; + + beforeEach(() => { + createComponent({ + runner: { + runnerType: GROUP_TYPE, + groups: { + nodes: [ + { + name: mockName, + fullName: mockFullName, + webUrl: mockWebUrl, + }, + ], + }, + }, + }); + }); + + it('Displays a group link', () => { + expect(findLink().attributes('href')).toBe(mockWebUrl); + expect(wrapper.text()).toBe(mockName); + expect(getLinkTooltip()).toBe(mockFullName); + }); + }); + + describe('When its a project runner', () => { + const mockName = 'Project 1'; + const mockNameWithNamespace = 'Group 1 / Project 1'; + const mockWebUrl = '/group-1/project-1'; + + beforeEach(() => { + createComponent({ + runner: { + runnerType: PROJECT_TYPE, + ownerProject: { + name: mockName, + nameWithNamespace: mockNameWithNamespace, + webUrl: mockWebUrl, + }, + }, + }); + }); + + it('Displays a project link', () => { + expect(findLink().attributes('href')).toBe(mockWebUrl); + expect(wrapper.text()).toBe(mockName); + expect(getLinkTooltip()).toBe(mockNameWithNamespace); + }); + }); + + describe('When its an empty runner', () => { + beforeEach(() => { + createComponent({ + runner: {}, + }); + }); + + it('shows no label', () => { + expect(wrapper.text()).toBe(''); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/cells/runner_stacked_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_stacked_summary_cell_spec.js new file mode 100644 index 00000000000..4aa354f9b62 --- /dev/null +++ b/spec/frontend/ci/runner/components/cells/runner_stacked_summary_cell_spec.js @@ -0,0 +1,164 @@ +import { __ } from '~/locale'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerStackedSummaryCell from '~/ci/runner/components/cells/runner_stacked_summary_cell.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import RunnerTags from '~/ci/runner/components/runner_tags.vue'; +import RunnerSummaryField from '~/ci/runner/components/cells/runner_summary_field.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +import { INSTANCE_TYPE, I18N_INSTANCE_TYPE, PROJECT_TYPE } from '~/ci/runner/constants'; + +import { allRunnersData } from '../../mock_data'; + +const mockRunner = allRunnersData.data.runners.nodes[0]; + +describe('RunnerTypeCell', () => { + let wrapper; + + const findLockIcon = () => wrapper.findByTestId('lock-icon'); + const findRunnerTags = () => wrapper.findComponent(RunnerTags); + const findRunnerSummaryField = (icon) => + wrapper.findAllComponents(RunnerSummaryField).filter((w) => w.props('icon') === icon) + .wrappers[0]; + + const createComponent = (runner, options) => { + wrapper = mountExtended(RunnerStackedSummaryCell, { + propsData: { + runner: { + ...mockRunner, + ...runner, + }, + }, + stubs: { + RunnerSummaryField, + }, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays the runner name as id and short token', () => { + expect(wrapper.text()).toContain( + `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`, + ); + }); + + it('Does not display the locked icon', () => { + expect(findLockIcon().exists()).toBe(false); + }); + + it('Displays the locked icon for locked runners', () => { + createComponent({ + runnerType: PROJECT_TYPE, + locked: true, + }); + + expect(findLockIcon().exists()).toBe(true); + }); + + it('Displays the runner type', () => { + createComponent({ + runnerType: INSTANCE_TYPE, + locked: true, + }); + + expect(wrapper.text()).toContain(I18N_INSTANCE_TYPE); + }); + + it('Displays the runner version', () => { + expect(wrapper.text()).toContain(mockRunner.version); + }); + + it('Displays the runner description', () => { + expect(wrapper.text()).toContain(mockRunner.description); + }); + + it('Displays last contact', () => { + createComponent({ + contactedAt: '2022-01-02', + }); + + expect(findRunnerSummaryField('clock').findComponent(TimeAgo).props('time')).toBe('2022-01-02'); + }); + + it('Displays empty last contact', () => { + createComponent({ + contactedAt: null, + }); + + expect(findRunnerSummaryField('clock').findComponent(TimeAgo).exists()).toBe(false); + expect(findRunnerSummaryField('clock').text()).toContain(__('Never')); + }); + + it('Displays ip address', () => { + createComponent({ + ipAddress: '127.0.0.1', + }); + + expect(findRunnerSummaryField('disk').text()).toContain('127.0.0.1'); + }); + + it('Displays no ip address', () => { + createComponent({ + ipAddress: null, + }); + + expect(findRunnerSummaryField('disk')).toBeUndefined(); + }); + + it('Displays job count', () => { + expect(findRunnerSummaryField('pipeline').text()).toContain(`${mockRunner.jobCount}`); + }); + + it('Formats large job counts', () => { + createComponent({ + jobCount: 1000, + }); + + expect(findRunnerSummaryField('pipeline').text()).toContain('1,000'); + }); + + it('Formats large job counts with a plus symbol', () => { + createComponent({ + jobCount: 1001, + }); + + expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+'); + }); + + it('Displays created at', () => { + expect(findRunnerSummaryField('calendar').findComponent(TimeAgo).props('time')).toBe( + mockRunner.createdAt, + ); + }); + + it('Displays tag list', () => { + createComponent({ + tagList: ['shell', 'linux'], + }); + + expect(findRunnerTags().props('tagList')).toEqual(['shell', 'linux']); + }); + + it('Displays a custom slot', () => { + const slotContent = 'My custom runner name'; + + createComponent( + {}, + { + slots: { + 'runner-name': slotContent, + }, + }, + ); + + expect(wrapper.text()).toContain(slotContent); + }); +}); 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 new file mode 100644 index 00000000000..2fb824a8fa5 --- /dev/null +++ b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js @@ -0,0 +1,77 @@ +import { mount } 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'; +import RunnerPausedBadge from '~/ci/runner/components/runner_paused_badge.vue'; +import { + I18N_PAUSED, + I18N_STATUS_ONLINE, + I18N_STATUS_OFFLINE, + INSTANCE_TYPE, + STATUS_ONLINE, + STATUS_OFFLINE, +} from '~/ci/runner/constants'; + +describe('RunnerStatusCell', () => { + let wrapper; + + const findStatusBadge = () => wrapper.findComponent(RunnerStatusBadge); + const findPausedBadge = () => wrapper.findComponent(RunnerPausedBadge); + + const createComponent = ({ runner = {} } = {}) => { + wrapper = mount(RunnerStatusCell, { + propsData: { + runner: { + runnerType: INSTANCE_TYPE, + active: true, + status: STATUS_ONLINE, + ...runner, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays online status', () => { + createComponent(); + + expect(wrapper.text()).toContain(I18N_STATUS_ONLINE); + expect(findStatusBadge().text()).toBe(I18N_STATUS_ONLINE); + }); + + it('Displays offline status', () => { + createComponent({ + runner: { + status: STATUS_OFFLINE, + }, + }); + + expect(wrapper.text()).toMatchInterpolatedText(I18N_STATUS_OFFLINE); + expect(findStatusBadge().text()).toBe(I18N_STATUS_OFFLINE); + }); + + it('Displays paused status', () => { + createComponent({ + runner: { + active: false, + status: STATUS_ONLINE, + }, + }); + + expect(wrapper.text()).toMatchInterpolatedText(`${I18N_STATUS_ONLINE} ${I18N_PAUSED}`); + expect(findPausedBadge().text()).toBe(I18N_PAUSED); + }); + + it('Is empty when data is missing', () => { + createComponent({ + runner: { + status: null, + }, + }); + + expect(wrapper.text()).toBe(''); + }); +}); 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 new file mode 100644 index 00000000000..f536e0dcbcf --- /dev/null +++ b/spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js @@ -0,0 +1,49 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerSummaryField from '~/ci/runner/components/cells/runner_summary_field.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('RunnerSummaryField', () => { + let wrapper; + + const findIcon = () => wrapper.findComponent(GlIcon); + const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip').value; + + const createComponent = ({ props, ...options } = {}) => { + wrapper = shallowMount(RunnerSummaryField, { + propsData: { + icon: '', + tooltip: '', + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows content in slot', () => { + createComponent({ + slots: { default: 'content' }, + }); + + expect(wrapper.text()).toBe('content'); + }); + + it('shows icon', () => { + createComponent({ props: { icon: 'git' } }); + + expect(findIcon().props('name')).toBe('git'); + }); + + it('shows tooltip', () => { + createComponent({ props: { tooltip: 'tooltip' } }); + + expect(getTooltipValue()).toBe('tooltip'); + }); +}); diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js new file mode 100644 index 00000000000..cb46c668930 --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js @@ -0,0 +1,198 @@ +import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; +import { mount, shallowMount, createWrapper } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; + +import VueApollo from 'vue-apollo'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue'; +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 getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; + +import { + mockGraphqlRunnerPlatforms, + mockGraphqlInstructions, +} from 'jest/vue_shared/components/runner_instructions/mock_data'; + +const mockToken = '0123456789'; +const maskToken = '**********'; + +Vue.use(VueApollo); + +describe('RegistrationDropdown', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + + const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); + const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); + const findRegistrationTokenInput = () => + wrapper.findByLabelText(RegistrationToken.i18n.registrationToken); + const findTokenResetDropdownItem = () => + wrapper.findComponent(RegistrationTokenResetDropdownItem); + const findModal = () => wrapper.findComponent(GlModal); + const findModalContent = () => + createWrapper(document.body) + .find('[data-testid="runner-instructions-modal"]') + .text() + .replace(/[\n\t\s]+/g, ' '); + + const openModal = async () => { + await findRegistrationInstructionsDropdownItem().trigger('click'); + findModal().vm.$emit('shown'); + + await waitForPromises(); + }; + + const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { + wrapper = extendedWrapper( + mountFn(RegistrationDropdown, { + propsData: { + registrationToken: mockToken, + type: INSTANCE_TYPE, + ...props, + }, + ...options, + }), + ); + }; + + const createComponentWithModal = () => { + const requestHandlers = [ + [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], + [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)], + ]; + + createComponent( + { + // Mock load modal contents from API + apolloProvider: createMockApollo(requestHandlers), + // Use `attachTo` to find the modal + attachTo: document.body, + }, + mount, + ); + }; + + it.each` + type | text + ${INSTANCE_TYPE} | ${'Register an instance runner'} + ${GROUP_TYPE} | ${'Register a group runner'} + ${PROJECT_TYPE} | ${'Register a project runner'} + `('Dropdown text for type $type is "$text"', () => { + createComponent({ props: { type: INSTANCE_TYPE } }, mount); + + expect(wrapper.text()).toContain('Register an instance runner'); + }); + + it('Passes attributes to the dropdown component', () => { + createComponent({ attrs: { right: true } }); + + expect(findDropdown().attributes()).toMatchObject({ right: 'true' }); + }); + + describe('Instructions dropdown item', () => { + it('Displays "Show runner" dropdown item', () => { + createComponent(); + + expect(findRegistrationInstructionsDropdownItem().text()).toBe( + 'Show runner installation and registration instructions', + ); + }); + + describe('When the dropdown item is clicked', () => { + beforeEach(async () => { + createComponentWithModal({}, mount); + + await openModal(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('opens the modal with contents', () => { + const modalText = findModalContent(); + + expect(modalText).toContain('Install a runner'); + + // Environment selector + expect(modalText).toContain('Environment'); + expect(modalText).toContain('Linux macOS Windows Docker Kubernetes'); + + // Architecture selector + expect(modalText).toContain('Architecture'); + expect(modalText).toContain('amd64 amd64 386 arm arm64'); + + expect(modalText).toContain('Download and install binary'); + }); + }); + }); + + describe('Registration token', () => { + it('Displays dropdown form for the registration token', () => { + createComponent(); + + expect(findTokenDropdownItem().exists()).toBe(true); + }); + + it('Displays masked value by default', () => { + createComponent({}, mount); + + expect(findRegistrationTokenInput().element.value).toBe(maskToken); + }); + }); + + describe('Reset token item', () => { + it('Displays registration token reset item', () => { + createComponent(); + + expect(findTokenResetDropdownItem().exists()).toBe(true); + }); + + it.each([INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE])('Set up token reset for %s', (type) => { + createComponent({ props: { type } }); + + expect(findTokenResetDropdownItem().props('type')).toBe(type); + }); + }); + + describe('When token is reset', () => { + const newToken = 'mock1'; + + const resetToken = async () => { + findTokenResetDropdownItem().vm.$emit('tokenReset', newToken); + await nextTick(); + }; + + it('Updates token input', async () => { + createComponent({}, mount); + + expect(findRegistrationToken().props('value')).not.toBe(newToken); + + await resetToken(); + + expect(findRegistrationToken().props('value')).toBe(newToken); + }); + + it('Updates token in modal', async () => { + createComponentWithModal({}, mount); + + await openModal(); + + expect(findModalContent()).toContain(mockToken); + + await resetToken(); + + expect(findModalContent()).toContain(newToken); + }); + }); +}); 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 new file mode 100644 index 00000000000..783a4d9252a --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -0,0 +1,209 @@ +import { GlDropdownItem, GlLoadingIcon, GlToast, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +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 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('~/ci/runner/sentry_utils'); + +Vue.use(VueApollo); +Vue.use(GlToast); + +const mockNewToken = 'NEW_TOKEN'; +const modalID = 'token-reset-modal'; + +describe('RegistrationTokenResetDropdownItem', () => { + let wrapper; + let runnersRegistrationTokenResetMutationHandler; + let showToast; + + const mockEvent = { preventDefault: jest.fn() }; + const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findModal = () => wrapper.findComponent(GlModal); + const clickSubmit = () => findModal().vm.$emit('primary', mockEvent); + + const createComponent = ({ props, provide = {} } = {}) => { + wrapper = shallowMount(RegistrationTokenResetDropdownItem, { + provide, + propsData: { + type: INSTANCE_TYPE, + ...props, + }, + apolloProvider: createMockApollo([ + [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler], + ]), + directives: { + GlModal: createMockDirective(), + }, + }); + + showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; + }; + + beforeEach(() => { + runnersRegistrationTokenResetMutationHandler = jest.fn().mockResolvedValue({ + data: { + runnersRegistrationTokenReset: { + token: mockNewToken, + errors: [], + }, + }, + }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays reset button', () => { + expect(findDropdownItem().exists()).toBe(true); + }); + + describe('modal directive integration', () => { + it('has the correct ID on the dropdown', () => { + const binding = getBinding(findDropdownItem().element, 'gl-modal'); + + expect(binding.value).toBe(modalID); + }); + + it('has the correct ID on the modal', () => { + expect(findModal().props('modalId')).toBe(modalID); + }); + }); + + describe('On click and confirmation', () => { + const mockGroupId = '11'; + const mockProjectId = '22'; + + describe.each` + type | provide | expectedInput + ${INSTANCE_TYPE} | ${{}} | ${{ type: INSTANCE_TYPE }} + ${GROUP_TYPE} | ${{ groupId: mockGroupId }} | ${{ type: GROUP_TYPE, id: `gid://gitlab/Group/${mockGroupId}` }} + ${PROJECT_TYPE} | ${{ projectId: mockProjectId }} | ${{ type: PROJECT_TYPE, id: `gid://gitlab/Project/${mockProjectId}` }} + `('Resets token of type $type', ({ type, provide, expectedInput }) => { + beforeEach(async () => { + createComponent({ + provide, + props: { type }, + }); + + findDropdownItem().trigger('click'); + clickSubmit(); + await waitForPromises(); + }); + + it('resets token', () => { + expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1); + expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({ + input: expectedInput, + }); + }); + + it('emits result', () => { + expect(wrapper.emitted('tokenReset')).toHaveLength(1); + expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]); + }); + + it('does not show a loading state', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('shows confirmation', () => { + expect(showToast).toHaveBeenLastCalledWith( + expect.stringContaining('registration token generated'), + ); + }); + }); + }); + + describe('On click without confirmation', () => { + beforeEach(async () => { + findDropdownItem().vm.$emit('click'); + await waitForPromises(); + }); + + it('does not reset token', () => { + expect(runnersRegistrationTokenResetMutationHandler).not.toHaveBeenCalled(); + }); + + it('does not emit any result', () => { + expect(wrapper.emitted('tokenReset')).toBeUndefined(); + }); + + it('does not show a loading state', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('does not shows confirmation', () => { + expect(showToast).not.toHaveBeenCalled(); + }); + }); + + describe('On error', () => { + it('On network error, error message is shown', async () => { + const mockErrorMsg = 'Token reset failed!'; + + runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + findDropdownItem().trigger('click'); + clickSubmit(); + await waitForPromises(); + + expect(createAlert).toHaveBeenLastCalledWith({ + message: mockErrorMsg, + }); + expect(captureException).toHaveBeenCalledWith({ + error: new Error(mockErrorMsg), + component: 'RunnerRegistrationTokenReset', + }); + }); + + it('On validation error, error message is shown', async () => { + const mockErrorMsg = 'User not allowed!'; + const mockErrorMsg2 = 'Type is not valid!'; + + runnersRegistrationTokenResetMutationHandler.mockResolvedValue({ + data: { + runnersRegistrationTokenReset: { + token: null, + errors: [mockErrorMsg, mockErrorMsg2], + }, + }, + }); + + findDropdownItem().trigger('click'); + clickSubmit(); + await waitForPromises(); + + expect(createAlert).toHaveBeenLastCalledWith({ + message: `${mockErrorMsg} ${mockErrorMsg2}`, + }); + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerRegistrationTokenReset', + }); + }); + }); + + describe('Immediately after click', () => { + it('shows loading state', async () => { + findDropdownItem().trigger('click'); + clickSubmit(); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/registration/registration_token_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_spec.js new file mode 100644 index 00000000000..d2a51c0d910 --- /dev/null +++ b/spec/frontend/ci/runner/components/registration/registration_token_spec.js @@ -0,0 +1,62 @@ +import { GlToast } from '@gitlab/ui'; +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 = '***********'; + +describe('RegistrationToken', () => { + let wrapper; + let showToast; + + Vue.use(GlToast); + + const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RegistrationToken, { + propsData: { + value: mockToken, + inputId: 'token-value', + ...props, + }, + }); + + 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('copyButtonTitle')).toBe( + 'Copy registration token', + ); + }); + + // Component integration test to ensure secure masking + it('Displays masked value by default', () => { + createComponent({ mountFn: mountExtended }); + + expect(wrapper.find('input').element.value).toBe(mockMasked); + }); + + describe('When the copy to clipboard button is clicked', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows a copied message', () => { + findInputCopyToggleVisibility().vm.$emit('copy'); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Registration token copied!'); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_assigned_item_spec.js b/spec/frontend/ci/runner/components/runner_assigned_item_spec.js new file mode 100644 index 00000000000..5df2e04c340 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_assigned_item_spec.js @@ -0,0 +1,68 @@ +import { GlAvatar, GlBadge } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerAssignedItem from '~/ci/runner/components/runner_assigned_item.vue'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; + +const mockHref = '/group/project'; +const mockName = 'Project'; +const mockDescription = 'Project description'; +const mockFullName = 'Group / Project'; +const mockAvatarUrl = '/avatar.png'; + +describe('RunnerAssignedItem', () => { + let wrapper; + + const findAvatar = () => wrapper.findByTestId('item-avatar'); + const findBadge = () => wrapper.findComponent(GlBadge); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(RunnerAssignedItem, { + propsData: { + href: mockHref, + name: mockName, + fullName: mockFullName, + avatarUrl: mockAvatarUrl, + description: mockDescription, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Shows an avatar', () => { + const avatar = findAvatar(); + + expect(avatar.attributes('href')).toBe(mockHref); + expect(avatar.findComponent(GlAvatar).props()).toMatchObject({ + alt: mockName, + entityName: mockName, + src: mockAvatarUrl, + shape: AVATAR_SHAPE_OPTION_RECT, + size: 48, + }); + }); + + it('Shows an item link', () => { + const groupFullName = wrapper.findByText(mockFullName); + + expect(groupFullName.attributes('href')).toBe(mockHref); + }); + + it('Shows description', () => { + expect(wrapper.text()).toContain(mockDescription); + }); + + it('Shows owner badge', () => { + createComponent({ props: { isOwner: true } }); + + expect(findBadge().text()).toBe(s__('Runner|Owner')); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_bulk_delete_checkbox_spec.js b/spec/frontend/ci/runner/components/runner_bulk_delete_checkbox_spec.js new file mode 100644 index 00000000000..dad36b0179f --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_bulk_delete_checkbox_spec.js @@ -0,0 +1,140 @@ +import Vue from 'vue'; +import { GlFormCheckbox } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerBulkDeleteCheckbox from '~/ci/runner/components/runner_bulk_delete_checkbox.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createLocalState } from '~/ci/runner/graphql/list/local_state'; + +Vue.use(VueApollo); + +const makeRunner = (id, deleteRunner = true) => ({ + id, + userPermissions: { deleteRunner }, +}); + +// Multi-select checkbox possible states: +const stateToAttrs = { + unchecked: { disabled: undefined, checked: undefined, indeterminate: undefined }, + checked: { disabled: undefined, checked: 'true', indeterminate: undefined }, + indeterminate: { disabled: undefined, checked: undefined, indeterminate: 'true' }, + disabled: { disabled: 'true', checked: undefined, indeterminate: undefined }, +}; + +describe('RunnerBulkDeleteCheckbox', () => { + let wrapper; + let mockState; + let mockCheckedRunnerIds; + + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + + const expectCheckboxToBe = (state) => { + const expected = stateToAttrs[state]; + expect(findCheckbox().attributes('disabled')).toBe(expected.disabled); + expect(findCheckbox().attributes('checked')).toBe(expected.checked); + expect(findCheckbox().attributes('indeterminate')).toBe(expected.indeterminate); + }; + + const createComponent = ({ runners = [] } = {}) => { + const { cacheConfig, localMutations } = mockState; + const apolloProvider = createMockApollo(undefined, undefined, cacheConfig); + + wrapper = shallowMountExtended(RunnerBulkDeleteCheckbox, { + apolloProvider, + provide: { + localMutations, + }, + propsData: { + runners, + }, + }); + }; + + beforeEach(() => { + mockState = createLocalState(); + + jest + .spyOn(mockState.cacheConfig.typePolicies.Query.fields, 'checkedRunnerIds') + .mockImplementation(() => mockCheckedRunnerIds); + + jest.spyOn(mockState.localMutations, 'setRunnersChecked'); + }); + + describe('when all runners can be deleted', () => { + const mockIds = ['1', '2', '3']; + const mockIdAnotherPage = '4'; + const mockRunners = mockIds.map((id) => makeRunner(id)); + + it.each` + case | checkedRunnerIds | state + ${'no runners'} | ${[]} | ${'unchecked'} + ${'no runners in this page'} | ${[mockIdAnotherPage]} | ${'unchecked'} + ${'all runners'} | ${mockIds} | ${'checked'} + ${'some runners'} | ${[mockIds[0]]} | ${'indeterminate'} + ${'all plus other runners'} | ${[...mockIds, mockIdAnotherPage]} | ${'checked'} + `('if $case are checked, checkbox is $state', ({ checkedRunnerIds, state }) => { + mockCheckedRunnerIds = checkedRunnerIds; + + createComponent({ runners: mockRunners }); + expectCheckboxToBe(state); + }); + }); + + describe('when some runners cannot be deleted', () => { + it('all allowed runners are selected, checkbox is checked', () => { + mockCheckedRunnerIds = ['a', 'b', 'c']; + createComponent({ + runners: [makeRunner('a'), makeRunner('b'), makeRunner('c', false)], + }); + + expectCheckboxToBe('checked'); + }); + + it('some allowed runners are selected, checkbox is indeterminate', () => { + mockCheckedRunnerIds = ['a', 'b']; + createComponent({ + runners: [makeRunner('a'), makeRunner('b'), makeRunner('c')], + }); + + expectCheckboxToBe('indeterminate'); + }); + + it('no allowed runners are selected, checkbox is disabled', () => { + mockCheckedRunnerIds = ['a', 'b']; + createComponent({ + runners: [makeRunner('a', false), makeRunner('b', false)], + }); + + expectCheckboxToBe('disabled'); + }); + }); + + describe('When user selects', () => { + const mockRunners = [makeRunner('1'), makeRunner('2')]; + + beforeEach(() => { + mockCheckedRunnerIds = ['1', '2']; + createComponent({ runners: mockRunners }); + }); + + it.each([[true], [false]])('sets checked to %s', (checked) => { + findCheckbox().vm.$emit('change', checked); + + expect(mockState.localMutations.setRunnersChecked).toHaveBeenCalledTimes(1); + expect(mockState.localMutations.setRunnersChecked).toHaveBeenCalledWith({ + isChecked: checked, + runners: mockRunners, + }); + }); + }); + + describe('When runners are loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('is disabled', () => { + expectCheckboxToBe('disabled'); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js b/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js new file mode 100644 index 00000000000..64f5a0e3b57 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_bulk_delete_spec.js @@ -0,0 +1,295 @@ +import Vue from 'vue'; +import { GlModal, GlSprintf } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { createAlert } from '~/flash'; +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 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'; +import { allRunnersData } from '../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +describe('RunnerBulkDelete', () => { + let wrapper; + let apolloCache; + let mockState; + let mockCheckedRunnerIds; + + const findClearBtn = () => wrapper.findByText(s__('Runners|Clear selection')); + const findDeleteBtn = () => wrapper.findByText(s__('Runners|Delete selected')); + const findModal = () => wrapper.findComponent(GlModal); + + const mockRunners = allRunnersData.data.runners.nodes; + const mockId1 = allRunnersData.data.runners.nodes[0].id; + const mockId2 = allRunnersData.data.runners.nodes[1].id; + + const bulkRunnerDeleteHandler = jest.fn(); + + const createComponent = () => { + const { cacheConfig, localMutations } = mockState; + const apolloProvider = createMockApollo( + [[BulkRunnerDeleteMutation, bulkRunnerDeleteHandler]], + undefined, + cacheConfig, + ); + + wrapper = shallowMountExtended(RunnerBulkDelete, { + apolloProvider, + provide: { + localMutations, + }, + propsData: { + runners: mockRunners, + }, + directives: { + GlTooltip: createMockDirective(), + }, + stubs: { + GlSprintf, + GlModal, + }, + }); + + apolloCache = apolloProvider.defaultClient.cache; + jest.spyOn(apolloCache, 'evict'); + jest.spyOn(apolloCache, 'gc'); + }; + + beforeEach(() => { + mockState = createLocalState(); + + jest + .spyOn(mockState.cacheConfig.typePolicies.Query.fields, 'checkedRunnerIds') + .mockImplementation(() => mockCheckedRunnerIds); + }); + + afterEach(() => { + bulkRunnerDeleteHandler.mockReset(); + }); + + describe('When no runners are checked', () => { + beforeEach(async () => { + mockCheckedRunnerIds = []; + + createComponent(); + + await waitForPromises(); + }); + + it('shows no contents', () => { + expect(wrapper.html()).toBe(''); + }); + }); + + describe.each` + count | ids | text + ${1} | ${[mockId1]} | ${'1 runner'} + ${2} | ${[mockId1, mockId2]} | ${'2 runners'} + `('When $count runner(s) are checked', ({ ids, text }) => { + beforeEach(() => { + mockCheckedRunnerIds = ids; + + createComponent(); + + jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {}); + }); + + it(`shows "${text}"`, () => { + expect(wrapper.text()).toContain(text); + }); + + it('clears selection', () => { + expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(0); + + findClearBtn().vm.$emit('click'); + + expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(1); + }); + + it('shows confirmation modal', () => { + const modalId = getBinding(findDeleteBtn().element, 'gl-modal'); + + expect(findModal().props('modal-id')).toBe(modalId); + expect(findModal().text()).toContain(text); + }); + }); + + describe('when runners are deleted', () => { + let evt; + let mockHideModal; + + const confirmDeletion = () => { + evt = { + preventDefault: jest.fn(), + }; + findModal().vm.$emit('primary', evt); + }; + + beforeEach(() => { + mockCheckedRunnerIds = [mockId1, mockId2]; + + createComponent(); + + jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {}); + mockHideModal = jest.spyOn(findModal().vm, 'hide').mockImplementation(() => {}); + }); + + describe('when deletion is confirmed', () => { + beforeEach(() => { + confirmDeletion(); + }); + + it('has loading state', () => { + expect(findModal().props('actionPrimary').attributes.loading).toBe(true); + expect(findModal().props('actionCancel').attributes.loading).toBe(true); + }); + + it('modal is not prevented from closing', () => { + expect(evt.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('mutation is called', () => { + expect(bulkRunnerDeleteHandler).toHaveBeenCalledWith({ + input: { ids: mockCheckedRunnerIds }, + }); + }); + }); + + describe('when deletion is successful', () => { + beforeEach(async () => { + bulkRunnerDeleteHandler.mockResolvedValue({ + data: { + bulkRunnerDelete: { deletedIds: mockCheckedRunnerIds, errors: [] }, + }, + }); + + confirmDeletion(); + await waitForPromises(); + }); + + it('removes loading state', () => { + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); + expect(findModal().props('actionCancel').attributes.loading).toBe(false); + }); + + it('user interface is updated', () => { + const { evict, gc } = apolloCache; + + expect(evict).toHaveBeenCalledTimes(mockCheckedRunnerIds.length); + expect(evict).toHaveBeenCalledWith({ + id: expect.stringContaining(mockCheckedRunnerIds[0]), + }); + expect(evict).toHaveBeenCalledWith({ + id: expect.stringContaining(mockCheckedRunnerIds[1]), + }); + + expect(gc).toHaveBeenCalledTimes(1); + }); + + it('emits deletion confirmation', () => { + expect(wrapper.emitted('deleted')).toEqual([ + [{ message: expect.stringContaining(`${mockCheckedRunnerIds.length}`) }], + ]); + }); + + it('modal is hidden', () => { + expect(mockHideModal).toHaveBeenCalledTimes(1); + }); + }); + + describe('when deletion fails partially', () => { + beforeEach(async () => { + bulkRunnerDeleteHandler.mockResolvedValue({ + data: { + bulkRunnerDelete: { + deletedIds: [mockId1], // only one runner could be deleted + errors: ['Can only delete up to 1 runners per call. Ignored 1 runner(s).'], + }, + }, + }); + + confirmDeletion(); + await waitForPromises(); + }); + + it('removes loading state', () => { + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); + expect(findModal().props('actionCancel').attributes.loading).toBe(false); + }); + + it('user interface is partially updated', () => { + const { evict, gc } = apolloCache; + + expect(evict).toHaveBeenCalledTimes(1); + expect(evict).toHaveBeenCalledWith({ + id: expect.stringContaining(mockId1), + }); + + expect(gc).toHaveBeenCalledTimes(1); + }); + + it('emits deletion confirmation', () => { + expect(wrapper.emitted('deleted')).toEqual([[{ message: expect.stringContaining('1') }]]); + }); + + it('alert is called', () => { + expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: expect.any(String), + captureError: true, + error: expect.any(Error), + }); + }); + + it('modal is hidden', () => { + expect(mockHideModal).toHaveBeenCalledTimes(1); + }); + }); + + describe('when deletion fails', () => { + beforeEach(async () => { + bulkRunnerDeleteHandler.mockRejectedValue(new Error('error!')); + + confirmDeletion(); + await waitForPromises(); + }); + + it('resolves loading state', () => { + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); + expect(findModal().props('actionCancel').attributes.loading).toBe(false); + }); + + it('user interface is not updated', () => { + const { evict, gc } = apolloCache; + + expect(evict).not.toHaveBeenCalled(); + expect(gc).not.toHaveBeenCalled(); + expect(mockState.localMutations.clearChecked).not.toHaveBeenCalled(); + }); + + it('does not emit deletion confirmation', () => { + expect(wrapper.emitted('deleted')).toBeUndefined(); + }); + + it('alert is called', () => { + expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: expect.any(String), + captureError: true, + error: expect.any(Error), + }); + }); + + it('modal is hidden', () => { + expect(mockHideModal).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_delete_button_spec.js b/spec/frontend/ci/runner/components/runner_delete_button_spec.js new file mode 100644 index 00000000000..02960ad427e --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_delete_button_spec.js @@ -0,0 +1,275 @@ +import Vue from 'vue'; +import { GlButton } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql'; +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 { I18N_DELETE_RUNNER } from '~/ci/runner/constants'; + +import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; +import RunnerDeleteModal from '~/ci/runner/components/runner_delete_modal.vue'; +import { allRunnersData } from '../mock_data'; + +const mockRunner = allRunnersData.data.runners.nodes[0]; +const mockRunnerId = getIdFromGraphQLId(mockRunner.id); +const mockRunnerName = `#${mockRunnerId} (${mockRunner.shortSha})`; + +Vue.use(VueApollo); + +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); + +describe('RunnerDeleteButton', () => { + let wrapper; + let apolloProvider; + let apolloCache; + let runnerDeleteHandler; + + const findBtn = () => wrapper.findComponent(GlButton); + const findModal = () => wrapper.findComponent(RunnerDeleteModal); + + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; + const getModal = () => getBinding(findBtn().element, 'gl-modal').value; + + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + const { runner, ...propsData } = props; + + wrapper = mountFn(RunnerDeleteButton, { + propsData: { + runner: { + // We need typename so that cache.identify works + // eslint-disable-next-line no-underscore-dangle + __typename: mockRunner.__typename, + id: mockRunner.id, + shortSha: mockRunner.shortSha, + ...runner, + }, + ...propsData, + }, + apolloProvider, + directives: { + GlTooltip: createMockDirective(), + GlModal: createMockDirective(), + }, + }); + }; + + const clickOkAndWait = async () => { + findModal().vm.$emit('primary'); + await waitForPromises(); + }; + + beforeEach(() => { + runnerDeleteHandler = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + runnerDelete: { + errors: [], + }, + }, + }); + }); + apolloProvider = createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]); + apolloCache = apolloProvider.defaultClient.cache; + + jest.spyOn(apolloCache, 'evict'); + jest.spyOn(apolloCache, 'gc'); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays a delete button without an icon', () => { + expect(findBtn().props()).toMatchObject({ + loading: false, + icon: '', + }); + expect(findBtn().classes('btn-icon')).toBe(false); + expect(findBtn().text()).toBe(I18N_DELETE_RUNNER); + }); + + it('Displays a modal with the runner name', () => { + expect(findModal().props('runnerName')).toBe(mockRunnerName); + }); + + it('Does not have tabindex when button is enabled', () => { + expect(wrapper.attributes('tabindex')).toBeUndefined(); + }); + + it('Displays a modal when clicked', () => { + const modalId = `delete-runner-modal-${mockRunnerId}`; + + expect(getModal()).toBe(modalId); + expect(findModal().attributes('modal-id')).toBe(modalId); + }); + + it('Does not display redundant text for screen readers', () => { + expect(findBtn().attributes('aria-label')).toBe(undefined); + }); + + it('Passes other attributes to the button', () => { + createComponent({ props: { category: 'secondary' } }); + + expect(findBtn().props('category')).toBe('secondary'); + }); + + describe(`Before the delete button is clicked`, () => { + it('The mutation has not been called', () => { + expect(runnerDeleteHandler).toHaveBeenCalledTimes(0); + }); + }); + + describe('Immediately after the delete button is clicked', () => { + beforeEach(async () => { + findModal().vm.$emit('primary'); + }); + + it('The button has a loading state', async () => { + expect(findBtn().props('loading')).toBe(true); + }); + + it('The stale tooltip is removed', async () => { + expect(getTooltip()).toBe(''); + }); + }); + + describe('After clicking on the delete button', () => { + beforeEach(async () => { + await clickOkAndWait(); + }); + + it('The mutation to delete is called', () => { + expect(runnerDeleteHandler).toHaveBeenCalledTimes(1); + expect(runnerDeleteHandler).toHaveBeenCalledWith({ + input: { + id: mockRunner.id, + }, + }); + }); + + it('The user can be notified with an event', () => { + const deleted = wrapper.emitted('deleted'); + + expect(deleted).toHaveLength(1); + expect(deleted[0][0].message).toMatch(`#${mockRunnerId}`); + expect(deleted[0][0].message).toMatch(`${mockRunner.shortSha}`); + }); + + it('evicts runner from apollo cache', () => { + expect(apolloCache.evict).toHaveBeenCalledWith({ + id: apolloCache.identify(mockRunner), + }); + expect(apolloCache.gc).toHaveBeenCalled(); + }); + }); + + describe('When update fails', () => { + describe('On a network error', () => { + const mockErrorMsg = 'Update error!'; + + beforeEach(async () => { + runnerDeleteHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + await clickOkAndWait(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(mockErrorMsg), + component: 'RunnerDeleteButton', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + title: expect.stringContaining(mockRunnerName), + message: mockErrorMsg, + }); + }); + }); + + describe('On a validation error', () => { + const mockErrorMsg = 'Runner not found!'; + const mockErrorMsg2 = 'User not allowed!'; + + beforeEach(async () => { + runnerDeleteHandler.mockResolvedValueOnce({ + data: { + runnerDelete: { + errors: [mockErrorMsg, mockErrorMsg2], + }, + }, + }); + + await clickOkAndWait(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerDeleteButton', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + title: expect.stringContaining(mockRunnerName), + message: `${mockErrorMsg} ${mockErrorMsg2}`, + }); + }); + + it('does not evict runner from apollo cache', () => { + expect(apolloCache.evict).not.toHaveBeenCalled(); + expect(apolloCache.gc).not.toHaveBeenCalled(); + }); + }); + }); + + describe('When displaying a compact button for an active runner', () => { + beforeEach(() => { + createComponent({ + props: { + runner: { + active: true, + }, + compact: true, + }, + mountFn: mountExtended, + }); + }); + + it('Displays no text', () => { + expect(findBtn().text()).toBe(''); + expect(findBtn().classes('btn-icon')).toBe(true); + }); + + it('Display correctly for screen readers', () => { + expect(findBtn().attributes('aria-label')).toBe(I18N_DELETE_RUNNER); + expect(getTooltip()).toBe(I18N_DELETE_RUNNER); + }); + + describe('Immediately after the button is clicked', () => { + beforeEach(async () => { + findModal().vm.$emit('primary'); + }); + + it('The button has a loading state', async () => { + expect(findBtn().props('loading')).toBe(true); + }); + + it('The stale tooltip is removed', async () => { + expect(getTooltip()).toBe(''); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_delete_modal_spec.js b/spec/frontend/ci/runner/components/runner_delete_modal_spec.js new file mode 100644 index 00000000000..f2fb0206763 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_delete_modal_spec.js @@ -0,0 +1,60 @@ +import { GlModal } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import RunnerDeleteModal from '~/ci/runner/components/runner_delete_modal.vue'; + +describe('RunnerDeleteModal', () => { + let wrapper; + + const findGlModal = () => wrapper.findComponent(GlModal); + + const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { + wrapper = mountFn(RunnerDeleteModal, { + attachTo: document.body, + propsData: { + runnerName: '#99 (AABBCCDD)', + ...props, + }, + attrs: { + modalId: 'delete-runner-modal-99', + }, + }); + }; + + it('Displays title', () => { + createComponent(); + + expect(findGlModal().props('title')).toBe('Delete runner #99 (AABBCCDD)?'); + }); + + it('Displays buttons', () => { + createComponent(); + + expect(findGlModal().props('actionPrimary')).toMatchObject({ text: 'Delete runner' }); + expect(findGlModal().props('actionCancel')).toMatchObject({ text: 'Cancel' }); + }); + + it('Displays contents', () => { + createComponent(); + + expect(findGlModal().html()).toContain( + 'The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', + ); + }); + + describe('When modal is confirmed by the user', () => { + let hideModalSpy; + + beforeEach(() => { + createComponent({}, mount); + hideModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide').mockImplementation(() => {}); + }); + + it('Modal gets hidden', () => { + expect(hideModalSpy).toHaveBeenCalledTimes(0); + + findGlModal().vm.$emit('primary'); + + expect(hideModalSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_details_spec.js b/spec/frontend/ci/runner/components/runner_details_spec.js new file mode 100644 index 00000000000..65a81973869 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_details_spec.js @@ -0,0 +1,130 @@ +import { GlSprintf, GlIntersperse } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { findDd } from 'helpers/dl_locator_helper'; +import { ACCESS_LEVEL_REF_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED } from '~/ci/runner/constants'; + +import RunnerDetails from '~/ci/runner/components/runner_details.vue'; +import RunnerDetail from '~/ci/runner/components/runner_detail.vue'; +import RunnerGroups from '~/ci/runner/components/runner_groups.vue'; +import RunnerTags from '~/ci/runner/components/runner_tags.vue'; +import RunnerTag from '~/ci/runner/components/runner_tag.vue'; + +import { runnerData, runnerWithGroupData } from '../mock_data'; + +const mockRunner = runnerData.data.runner; +const mockGroupRunner = runnerWithGroupData.data.runner; + +describe('RunnerDetails', () => { + let wrapper; + const mockNow = '2021-01-15T12:00:00Z'; + const mockOneHourAgo = '2021-01-15T11:00:00Z'; + + useFakeDate(mockNow); + + const findDetailGroups = () => wrapper.findComponent(RunnerGroups); + + const createComponent = ({ props = {}, stubs, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerDetails, { + propsData: { + ...props, + }, + stubs: { + RunnerDetail, + ...stubs, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Details tab', () => { + describe.each` + field | runner | expectedValue + ${'Description'} | ${{ description: 'My runner' }} | ${'My runner'} + ${'Description'} | ${{ description: null }} | ${'None'} + ${'Last contact'} | ${{ contactedAt: mockOneHourAgo }} | ${'1 hour ago'} + ${'Last contact'} | ${{ contactedAt: null }} | ${'Never contacted'} + ${'Version'} | ${{ version: '12.3' }} | ${'12.3'} + ${'Version'} | ${{ version: null }} | ${'None'} + ${'Executor'} | ${{ executorName: 'shell' }} | ${'shell'} + ${'Architecture'} | ${{ architectureName: 'amd64' }} | ${'amd64'} + ${'Platform'} | ${{ platformName: 'darwin' }} | ${'darwin'} + ${'IP Address'} | ${{ ipAddress: '127.0.0.1' }} | ${'127.0.0.1'} + ${'IP Address'} | ${{ ipAddress: null }} | ${'None'} + ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: true }} | ${'Protected, Runs untagged jobs'} + ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: false }} | ${'Protected'} + ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED, runUntagged: true }} | ${'Runs untagged jobs'} + ${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED, runUntagged: false }} | ${'None'} + ${'Maximum job timeout'} | ${{ maximumTimeout: null }} | ${'None'} + ${'Maximum job timeout'} | ${{ maximumTimeout: 0 }} | ${'0 seconds'} + ${'Maximum job timeout'} | ${{ maximumTimeout: 59 }} | ${'59 seconds'} + ${'Maximum job timeout'} | ${{ maximumTimeout: 10 * 60 + 5 }} | ${'10 minutes 5 seconds'} + ${'Token expiry'} | ${{ tokenExpiresAt: mockOneHourAgo }} | ${'1 hour ago'} + ${'Token expiry'} | ${{ tokenExpiresAt: null }} | ${'Never expires'} + `('"$field" field', ({ field, runner, expectedValue }) => { + beforeEach(() => { + createComponent({ + props: { + runner: { + ...mockRunner, + ...runner, + }, + }, + stubs: { + GlIntersperse, + GlSprintf, + TimeAgo, + }, + }); + }); + + it(`displays expected value "${expectedValue}"`, () => { + expect(findDd(field, wrapper).text()).toBe(expectedValue); + }); + }); + + describe('"Tags" field', () => { + const stubs = { RunnerTags, RunnerTag }; + + it('displays expected value "tag-1 tag-2"', () => { + createComponent({ + props: { + runner: { ...mockRunner, tagList: ['tag-1', 'tag-2'] }, + }, + stubs, + }); + + expect(findDd('Tags', wrapper).text().replace(/\s+/g, ' ')).toBe('tag-1 tag-2'); + }); + + it('displays "None" when runner has no tags', () => { + createComponent({ + props: { + runner: { ...mockRunner, tagList: [] }, + }, + stubs, + }); + + expect(findDd('Tags', wrapper).text().replace(/\s+/g, ' ')).toBe('None'); + }); + }); + + describe('Group runners', () => { + beforeEach(() => { + createComponent({ + props: { + runner: mockGroupRunner, + }, + }); + }); + + it('Shows a group runner details', () => { + expect(findDetailGroups().props('runner')).toEqual(mockGroupRunner); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_edit_button_spec.js b/spec/frontend/ci/runner/components/runner_edit_button_spec.js new file mode 100644 index 00000000000..907cdc90100 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_edit_button_spec.js @@ -0,0 +1,41 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerEditButton from '~/ci/runner/components/runner_edit_button.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('RunnerEditButton', () => { + let wrapper; + + const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip').value; + + const createComponent = ({ attrs = {}, mountFn = shallowMount } = {}) => { + wrapper = mountFn(RunnerEditButton, { + attrs, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays Edit text', () => { + expect(wrapper.attributes('aria-label')).toBe('Edit'); + }); + + it('Displays Edit tooltip', () => { + expect(getTooltipValue()).toBe('Edit'); + }); + + it('Renders a link and adds an href attribute', () => { + createComponent({ attrs: { href: '/edit' }, mountFn: mount }); + + expect(wrapper.element.tagName).toBe('A'); + expect(wrapper.attributes('href')).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 new file mode 100644 index 00000000000..496c144083e --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js @@ -0,0 +1,188 @@ +import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +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'; +import { tagTokenConfig } from '~/ci/runner/components/search_tokens/tag_token_config'; +import { + PARAM_KEY_STATUS, + PARAM_KEY_TAG, + STATUS_ONLINE, + INSTANCE_TYPE, + DEFAULT_MEMBERSHIP, + DEFAULT_SORT, + CONTACTED_DESC, +} from '~/ci/runner/constants'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; + +const mockSearch = { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: { page: 1 }, + sort: DEFAULT_SORT, +}; + +describe('RunnerList', () => { + let wrapper; + + const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); + const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); + + const mockOtherSort = CONTACTED_DESC; + const mockFilters = [ + { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, + { type: 'filtered-search-term', value: { data: '' } }, + ]; + + const expectToHaveLastEmittedInput = (value) => { + const inputs = wrapper.emitted('input'); + expect(inputs[inputs.length - 1][0]).toEqual(value); + }; + + const createComponent = ({ props = {}, options = {} } = {}) => { + wrapper = shallowMountExtended(RunnerFilteredSearchBar, { + propsData: { + namespace: 'runners', + tokens: [], + value: mockSearch, + ...props, + }, + stubs: { + FilteredSearch, + GlFilteredSearch, + GlDropdown, + GlDropdownItem, + }, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('binds a namespace to the filtered search', () => { + expect(findFilteredSearch().props('namespace')).toBe('runners'); + }); + + it('sets sorting options', () => { + const SORT_OPTIONS_COUNT = 2; + + expect(findSortOptions()).toHaveLength(SORT_OPTIONS_COUNT); + expect(findSortOptions().at(0).text()).toBe('Created date'); + expect(findSortOptions().at(1).text()).toBe('Last contact'); + }); + + it('sets tokens to the filtered search', () => { + createComponent({ + props: { + tokens: [statusTokenConfig, tagTokenConfig], + }, + }); + + expect(findFilteredSearch().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_STATUS, + token: BaseToken, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + token: TagToken, + }), + ]); + }); + + it('can be configured with null or undefined tokens, which are ignored', () => { + createComponent({ + props: { + tokens: [statusTokenConfig, null, undefined], + }, + }); + + expect(findFilteredSearch().props('tokens')).toEqual([statusTokenConfig]); + }); + + it('fails validation for v-model with the wrong shape', () => { + expect(() => { + createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } }); + }).toThrow('Invalid prop: custom validator check failed'); + + expect(() => { + createComponent({ props: { value: { sort: 'sort' } } }); + }).toThrow('Invalid prop: custom validator check failed'); + }); + + describe('when a search is preselected', () => { + beforeEach(() => { + createComponent({ + props: { + value: { + runnerType: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, + sort: mockOtherSort, + filters: mockFilters, + }, + }, + }); + }); + + it('filter values are shown', () => { + expect(findGlFilteredSearch().props('value')).toMatchObject(mockFilters); + }); + + it('sort option is selected', () => { + expect( + findSortOptions() + .filter((w) => w.props('isChecked')) + .at(0) + .text(), + ).toEqual('Last contact'); + }); + + it('when the user sets a filter, the "search" preserves the other filters', () => { + findGlFilteredSearch().vm.$emit('input', mockFilters); + findGlFilteredSearch().vm.$emit('submit'); + + expectToHaveLastEmittedInput({ + runnerType: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, + filters: mockFilters, + sort: mockOtherSort, + pagination: {}, + }); + }); + }); + + it('when the user sets a filter, the "search" is emitted with filters', () => { + findGlFilteredSearch().vm.$emit('input', mockFilters); + findGlFilteredSearch().vm.$emit('submit'); + + expectToHaveLastEmittedInput({ + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: mockFilters, + sort: DEFAULT_SORT, + pagination: {}, + }); + }); + + it('when the user sets a sorting method, the "search" is emitted with the sort', () => { + findSortOptions().at(1).vm.$emit('click'); + + expectToHaveLastEmittedInput({ + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + sort: mockOtherSort, + pagination: {}, + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_groups_spec.js b/spec/frontend/ci/runner/components/runner_groups_spec.js new file mode 100644 index 00000000000..0991feb2e55 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_groups_spec.js @@ -0,0 +1,67 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import RunnerGroups from '~/ci/runner/components/runner_groups.vue'; +import RunnerAssignedItem from '~/ci/runner/components/runner_assigned_item.vue'; + +import { runnerData, runnerWithGroupData } from '../mock_data'; + +const mockInstanceRunner = runnerData.data.runner; +const mockGroupRunner = runnerWithGroupData.data.runner; +const mockGroup = mockGroupRunner.groups.nodes[0]; + +describe('RunnerGroups', () => { + let wrapper; + + const findHeading = () => wrapper.find('h3'); + const findRunnerAssignedItems = () => wrapper.findAllComponents(RunnerAssignedItem); + + const createComponent = ({ runner = mockGroupRunner, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerGroups, { + propsData: { + runner, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Shows a heading', () => { + createComponent(); + + expect(findHeading().text()).toBe('Assigned Group'); + }); + + describe('When there is a group runner', () => { + beforeEach(() => { + createComponent(); + }); + + it('Shows a project', () => { + createComponent(); + + const item = findRunnerAssignedItems().at(0); + const { webUrl, name, fullName, avatarUrl } = mockGroup; + + expect(item.props()).toMatchObject({ + href: webUrl, + name, + fullName, + avatarUrl, + }); + }); + }); + + describe('When there are no groups', () => { + beforeEach(() => { + createComponent({ + runner: mockInstanceRunner, + }); + }); + + it('Shows a "None" label', () => { + expect(wrapper.findByText('None').exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_header_spec.js b/spec/frontend/ci/runner/components/runner_header_spec.js new file mode 100644 index 00000000000..a04011de1cd --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_header_spec.js @@ -0,0 +1,124 @@ +import { GlSprintf } from '@gitlab/ui'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + I18N_STATUS_ONLINE, + I18N_GROUP_TYPE, + GROUP_TYPE, + STATUS_ONLINE, +} from '~/ci/runner/constants'; +import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; + +import RunnerHeader from '~/ci/runner/components/runner_header.vue'; +import RunnerTypeBadge from '~/ci/runner/components/runner_type_badge.vue'; +import RunnerStatusBadge from '~/ci/runner/components/runner_status_badge.vue'; + +import { runnerData } from '../mock_data'; + +const mockRunner = runnerData.data.runner; + +describe('RunnerHeader', () => { + let wrapper; + + const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge); + const findRunnerStatusBadge = () => wrapper.findComponent(RunnerStatusBadge); + const findRunnerLockedIcon = () => wrapper.findByTestId('lock-icon'); + const findTimeAgo = () => wrapper.findComponent(TimeAgo); + + const createComponent = ({ runner = {}, options = {}, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerHeader, { + propsData: { + runner: { + ...mockRunner, + ...runner, + }, + }, + stubs: { + GlSprintf, + TimeAgo, + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the runner status', () => { + createComponent({ + mountFn: mountExtended, + runner: { + status: STATUS_ONLINE, + }, + }); + + expect(findRunnerStatusBadge().text()).toContain(I18N_STATUS_ONLINE); + }); + + it('displays the runner type', () => { + createComponent({ + mountFn: mountExtended, + runner: { + runnerType: GROUP_TYPE, + }, + }); + + expect(findRunnerTypeBadge().text()).toContain(I18N_GROUP_TYPE); + }); + + it('displays the runner id', () => { + createComponent({ + runner: { + id: convertToGraphQLId(TYPE_CI_RUNNER, 99), + }, + }); + + expect(wrapper.text()).toContain('Runner #99'); + }); + + it('displays the runner locked icon', () => { + createComponent({ + runner: { + locked: true, + }, + mountFn: mountExtended, + }); + + expect(findRunnerLockedIcon().exists()).toBe(true); + }); + + it('displays the runner creation time', () => { + createComponent(); + + expect(wrapper.text()).toMatch(/created .+/); + expect(findTimeAgo().props('time')).toBe(mockRunner.createdAt); + }); + + it('does not display runner creation time if "createdAt" is missing', () => { + createComponent({ + runner: { + id: convertToGraphQLId(TYPE_CI_RUNNER, 99), + createdAt: null, + }, + }); + + expect(wrapper.text()).toContain('Runner #99'); + expect(wrapper.text()).not.toMatch(/created .+/); + expect(findTimeAgo().exists()).toBe(false); + }); + + it('displays actions in a slot', () => { + createComponent({ + options: { + slots: { + actions: '<div data-testid="actions-content">My Actions</div>', + }, + mountFn: mountExtended, + }, + }); + + expect(wrapper.findByTestId('actions-content').text()).toBe('My Actions'); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_jobs_spec.js b/spec/frontend/ci/runner/components/runner_jobs_spec.js new file mode 100644 index 00000000000..bdb8a4a31a3 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_jobs_spec.js @@ -0,0 +1,155 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; +import Vue from 'vue'; +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 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 { captureException } from '~/ci/runner/sentry_utils'; +import { I18N_NO_JOBS_FOUND, 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('~/ci/runner/sentry_utils'); + +const mockRunner = runnerData.data.runner; +const mockRunnerWithJobs = runnerJobsData.data.runner; +const mockJobs = mockRunnerWithJobs.jobs.nodes; + +Vue.use(VueApollo); + +describe('RunnerJobs', () => { + let wrapper; + let mockRunnerJobsQuery; + + const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoader); + const findRunnerJobsTable = () => wrapper.findComponent(RunnerJobsTable); + const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + + const createComponent = ({ mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerJobs, { + apolloProvider: createMockApollo([[runnerJobsQuery, mockRunnerJobsQuery]]), + propsData: { + runner: mockRunner, + }, + }); + }; + + beforeEach(() => { + mockRunnerJobsQuery = jest.fn(); + }); + + afterEach(() => { + mockRunnerJobsQuery.mockReset(); + wrapper.destroy(); + }); + + it('Requests runner jobs', async () => { + createComponent(); + + await waitForPromises(); + + expect(mockRunnerJobsQuery).toHaveBeenCalledTimes(1); + expect(mockRunnerJobsQuery).toHaveBeenCalledWith({ + id: mockRunner.id, + first: RUNNER_DETAILS_JOBS_PAGE_SIZE, + }); + }); + + describe('When there are jobs assigned', () => { + beforeEach(async () => { + mockRunnerJobsQuery.mockResolvedValueOnce(runnerJobsData); + + createComponent(); + await waitForPromises(); + }); + + it('Shows jobs', () => { + const jobs = findRunnerJobsTable().props('jobs'); + + expect(jobs).toEqual(mockJobs); + }); + + describe('When "Next" page is clicked', () => { + beforeEach(async () => { + findRunnerPagination().vm.$emit('input', { page: 2, after: 'AFTER_CURSOR' }); + + await waitForPromises(); + }); + + it('A new page is requested', () => { + expect(mockRunnerJobsQuery).toHaveBeenCalledTimes(2); + expect(mockRunnerJobsQuery).toHaveBeenLastCalledWith({ + id: mockRunner.id, + first: RUNNER_DETAILS_JOBS_PAGE_SIZE, + after: 'AFTER_CURSOR', + }); + }); + }); + }); + + describe('When loading', () => { + it('shows loading indicator and no other content', () => { + createComponent(); + + expect(findGlSkeletonLoading().exists()).toBe(true); + expect(findRunnerJobsTable().exists()).toBe(false); + expect(findRunnerPagination().attributes('disabled')).toBe('true'); + }); + }); + + describe('When there are no jobs', () => { + beforeEach(async () => { + mockRunnerJobsQuery.mockResolvedValueOnce({ + data: { + runner: { + id: mockRunner.id, + projectCount: 0, + jobs: { + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + }, + }, + }, + }); + + createComponent(); + await waitForPromises(); + }); + + it('Shows a "None" label', () => { + expect(wrapper.text()).toBe(I18N_NO_JOBS_FOUND); + }); + }); + + describe('When an error occurs', () => { + beforeEach(async () => { + mockRunnerJobsQuery.mockRejectedValue(new Error('Error!')); + + createComponent(); + await waitForPromises(); + }); + + it('shows an error', () => { + expect(createAlert).toHaveBeenCalled(); + }); + + it('reports an error', () => { + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerJobs', + error: expect.any(Error), + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_jobs_table_spec.js b/spec/frontend/ci/runner/components/runner_jobs_table_spec.js new file mode 100644 index 00000000000..8defe568df8 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_jobs_table_spec.js @@ -0,0 +1,137 @@ +import { GlTableLite } from '@gitlab/ui'; +import { + extendedWrapper, + shallowMountExtended, + mountExtended, +} from 'helpers/vue_test_utils_helper'; +import { __, s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerJobsTable from '~/ci/runner/components/runner_jobs_table.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { runnerJobsData } from '../mock_data'; + +const mockJobs = runnerJobsData.data.runner.jobs.nodes; + +describe('RunnerJobsTable', () => { + let wrapper; + const mockNow = '2021-01-15T12:00:00Z'; + const mockOneHourAgo = '2021-01-15T11:00:00Z'; + + useFakeDate(mockNow); + + const findTable = () => wrapper.findComponent(GlTableLite); + const findHeaders = () => wrapper.findAll('th'); + const findRows = () => wrapper.findAll('[data-testid^="job-row-"]'); + const findCell = ({ field }) => + extendedWrapper(findRows().at(0).find(`[data-testid="td-${field}"]`)); + + const createComponent = ({ props = {} } = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(RunnerJobsTable, { + propsData: { + jobs: mockJobs, + ...props, + }, + stubs: { + GlTableLite, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Sets job id as a row key', () => { + createComponent(); + + expect(findTable().attributes('primarykey')).toBe('id'); + }); + + describe('Table data', () => { + beforeEach(() => { + createComponent({}, mountExtended); + }); + + it('Displays headers', () => { + const headerLabels = findHeaders().wrappers.map((w) => w.text()); + + expect(headerLabels).toEqual([ + s__('Job|Status'), + __('Job'), + __('Project'), + __('Commit'), + s__('Job|Finished at'), + s__('Job|Duration'), + s__('Job|Queued'), + s__('Runners|Tags'), + ]); + }); + + it('Displays a list of jobs', () => { + expect(findRows()).toHaveLength(1); + }); + + it('Displays details of a job', () => { + const { id, detailedStatus, pipeline, shortSha, commitPath } = mockJobs[0]; + + expect(findCell({ field: 'status' }).text()).toMatchInterpolatedText(detailedStatus.text); + + expect(findCell({ field: 'job' }).text()).toContain(`#${getIdFromGraphQLId(id)}`); + expect(findCell({ field: 'job' }).find('a').attributes('href')).toBe( + detailedStatus.detailsPath, + ); + + expect(findCell({ field: 'project' }).text()).toBe(pipeline.project.name); + expect(findCell({ field: 'project' }).find('a').attributes('href')).toBe( + pipeline.project.webUrl, + ); + + expect(findCell({ field: 'commit' }).text()).toBe(shortSha); + expect(findCell({ field: 'commit' }).find('a').attributes('href')).toBe(commitPath); + }); + }); + + describe('Table data formatting', () => { + let mockJobsCopy; + + beforeEach(() => { + mockJobsCopy = [ + { + ...mockJobs[0], + }, + ]; + }); + + it('Formats finishedAt time', () => { + mockJobsCopy[0].finishedAt = mockOneHourAgo; + + createComponent({ props: { jobs: mockJobsCopy } }, mountExtended); + + expect(findCell({ field: 'finished_at' }).text()).toBe('1 hour ago'); + }); + + it('Formats duration time', () => { + mockJobsCopy[0].duration = 60; + + createComponent({ props: { jobs: mockJobsCopy } }, mountExtended); + + expect(findCell({ field: 'duration' }).text()).toBe('00:01:00'); + }); + + it('Formats queued time', () => { + mockJobsCopy[0].queuedDuration = 30; + + createComponent({ props: { jobs: mockJobsCopy } }, mountExtended); + + expect(findCell({ field: 'queued' }).text()).toBe('00:00:30'); + }); + + it('Formats tags', () => { + mockJobsCopy[0].tags = ['tag-1', 'tag-2']; + + createComponent({ props: { jobs: mockJobsCopy } }, mountExtended); + + expect(findCell({ field: 'tags' }).text()).toMatchInterpolatedText('tag-1 tag-2'); + }); + }); +}); 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 new file mode 100644 index 00000000000..d351f7b6908 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js @@ -0,0 +1,103 @@ +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 RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue'; + +const mockSvgPath = 'mock-svg-path.svg'; +const mockFilteredSvgPath = 'mock-filtered-svg-path.svg'; +const mockRegistrationToken = 'REGISTRATION_TOKEN'; + +describe('RunnerListEmptyState', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findLink = () => wrapper.findComponent(GlLink); + const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); + + const createComponent = ({ props, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerListEmptyState, { + propsData: { + svgPath: mockSvgPath, + filteredSvgPath: mockFilteredSvgPath, + registrationToken: mockRegistrationToken, + ...props, + }, + directives: { + GlModal: createMockDirective(), + }, + stubs: { + GlEmptyState, + GlSprintf, + GlLink, + }, + }); + }; + + describe('when search is not filtered', () => { + const title = s__('Runners|Get started with runners'); + + describe('when there is a registration token', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); + }); + + it('displays "no results" text with instructions', () => { + const desc = s__( + 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', + ); + + expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); + }); + + it('opens a runner registration instructions modal with a link', () => { + const { value } = getBinding(findLink().element, 'gl-modal'); + + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + }); + }); + + describe('when there is no registration token', () => { + beforeEach(() => { + createComponent({ props: { registrationToken: null } }); + }); + + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); + }); + + it('displays "no results" text', () => { + const desc = s__( + 'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.', + ); + + expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); + }); + + it('has no registration instructions link', () => { + expect(findLink().exists()).toBe(false); + }); + }); + }); + + describe('when search is filtered', () => { + beforeEach(() => { + createComponent({ props: { isSearchFiltered: true } }); + }); + + it('renders a "filtered search" illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockFilteredSvgPath); + }); + + it('displays "no filtered results" text', () => { + expect(findEmptyState().text()).toContain(s__('Runners|No results found')); + expect(findEmptyState().text()).toContain(s__('Runners|Edit your search and try again')); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js new file mode 100644 index 00000000000..d53a0ce8f4f --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_list_spec.js @@ -0,0 +1,231 @@ +import { GlTableLite, GlSkeletonLoader } from '@gitlab/ui'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { + extendedWrapper, + shallowMountExtended, + mountExtended, +} from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { createLocalState } from '~/ci/runner/graphql/list/local_state'; + +import RunnerList from '~/ci/runner/components/runner_list.vue'; +import RunnerBulkDelete from '~/ci/runner/components/runner_bulk_delete.vue'; +import RunnerBulkDeleteCheckbox from '~/ci/runner/components/runner_bulk_delete_checkbox.vue'; + +import { I18N_PROJECT_TYPE, I18N_STATUS_NEVER_CONTACTED } from '~/ci/runner/constants'; +import { allRunnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; + +const mockRunners = allRunnersData.data.runners.nodes; +const mockActiveRunnersCount = mockRunners.length; + +describe('RunnerList', () => { + let wrapper; + let cacheConfig; + let localMutations; + + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findTable = () => wrapper.findComponent(GlTableLite); + const findHeaders = () => wrapper.findAll('th'); + const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]'); + const findCell = ({ row = 0, fieldKey }) => + extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); + const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete); + const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox); + + const createComponent = ( + { props = {}, provide = {}, ...options } = {}, + mountFn = shallowMountExtended, + ) => { + ({ cacheConfig, localMutations } = createLocalState()); + + wrapper = mountFn(RunnerList, { + apolloProvider: createMockApollo([], {}, cacheConfig), + propsData: { + runners: mockRunners, + activeRunnersCount: mockActiveRunnersCount, + ...props, + }, + provide: { + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, + ...provide, + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays headers', () => { + createComponent( + { + stubs: { + HelpPopover: { + template: '<div/>', + }, + }, + }, + mountExtended, + ); + + const headerLabels = findHeaders().wrappers.map((w) => w.text()); + + expect(findHeaders().at(0).findComponent(HelpPopover).exists()).toBe(true); + expect(findHeaders().at(2).findComponent(HelpPopover).exists()).toBe(true); + + expect(headerLabels).toEqual([ + s__('Runners|Status'), + s__('Runners|Runner'), + s__('Runners|Owner'), + '', // actions has no label + ]); + }); + + it('Sets runner id as a row key', () => { + createComponent(); + + expect(findTable().attributes('primary-key')).toBe('id'); + }); + + it('Displays a list of runners', () => { + createComponent({}, mountExtended); + + expect(findRows()).toHaveLength(4); + + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('Displays details of a runner', () => { + createComponent({}, mountExtended); + + const { id, description, version, shortSha } = mockRunners[0]; + const numericId = getIdFromGraphQLId(id); + + // Badges + expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText( + I18N_STATUS_NEVER_CONTACTED, + ); + + // Runner summary + const summary = findCell({ fieldKey: 'summary' }).text(); + + expect(summary).toContain(`#${numericId} (${shortSha})`); + expect(summary).toContain(I18N_PROJECT_TYPE); + + expect(summary).toContain(version); + expect(summary).toContain(description); + + expect(summary).toContain('Last contact'); + expect(summary).toContain('0'); // job count + expect(summary).toContain('Created'); + + // Actions + expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true); + }); + + describe('When the list is checkable', () => { + beforeEach(() => { + createComponent( + { + props: { + checkable: true, + }, + }, + mountExtended, + ); + }); + + it('runner bulk delete is available', () => { + expect(findRunnerBulkDelete().props('runners')).toEqual(mockRunners); + }); + + it('runner bulk delete checkbox is available', () => { + expect(findRunnerBulkDeleteCheckbox().props('runners')).toEqual(mockRunners); + }); + + it('Displays a checkbox field', () => { + expect(findCell({ fieldKey: 'checkbox' }).find('input').exists()).toBe(true); + }); + + it('Sets a runner as checked', async () => { + const runner = mockRunners[0]; + const setRunnerCheckedMock = jest + .spyOn(localMutations, 'setRunnerChecked') + .mockImplementation(() => {}); + + const checkbox = findCell({ fieldKey: 'checkbox' }).find('input'); + await checkbox.setChecked(); + + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); + expect(setRunnerCheckedMock).toHaveBeenCalledWith({ + runner, + isChecked: true, + }); + }); + + it('Emits a deleted event', async () => { + const event = { message: 'Deleted!' }; + findRunnerBulkDelete().vm.$emit('deleted', event); + + expect(wrapper.emitted('deleted')).toEqual([[event]]); + }); + }); + + describe('Scoped cell slots', () => { + it('Render #runner-name slot in "summary" cell', () => { + createComponent( + { + scopedSlots: { 'runner-name': ({ runner }) => `Summary: ${runner.id}` }, + }, + mountExtended, + ); + + expect(findCell({ fieldKey: 'summary' }).text()).toContain(`Summary: ${mockRunners[0].id}`); + }); + + it('Render #runner-actions-cell slot in "actions" cell', () => { + createComponent( + { + scopedSlots: { 'runner-actions-cell': ({ runner }) => `Actions: ${runner.id}` }, + }, + mountExtended, + ); + + expect(findCell({ fieldKey: 'actions' }).text()).toBe(`Actions: ${mockRunners[0].id}`); + }); + }); + + it('Shows runner identifier', () => { + const { id, shortSha } = mockRunners[0]; + const numericId = getIdFromGraphQLId(id); + + createComponent({}, mountExtended); + + expect(findCell({ fieldKey: 'summary' }).text()).toContain(`#${numericId} (${shortSha})`); + }); + + describe('When data is loading', () => { + it('shows a busy state', () => { + createComponent({ props: { runners: [], loading: true } }); + + expect(findTable().classes('gl-opacity-6')).toBe(true); + }); + + it('when there are no runners, shows an skeleton loader', () => { + createComponent({ props: { runners: [], loading: true } }, mountExtended); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('when there are runners, shows a busy indicator skeleton loader', () => { + createComponent({ props: { loading: true } }, mountExtended); + + expect(findSkeletonLoader().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js b/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js new file mode 100644 index 00000000000..f089becd400 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_membership_toggle_spec.js @@ -0,0 +1,57 @@ +import { GlToggle } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerMembershipToggle from '~/ci/runner/components/runner_membership_toggle.vue'; +import { + I18N_SHOW_ONLY_INHERITED, + MEMBERSHIP_DESCENDANTS, + MEMBERSHIP_ALL_AVAILABLE, +} from '~/ci/runner/constants'; + +describe('RunnerMembershipToggle', () => { + let wrapper; + + const findToggle = () => wrapper.findComponent(GlToggle); + + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + wrapper = mountFn(RunnerMembershipToggle, { + propsData: props, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays text', () => { + createComponent({ mountFn: mount }); + + expect(wrapper.text()).toBe(I18N_SHOW_ONLY_INHERITED); + }); + + it.each` + membershipValue | toggleValue + ${MEMBERSHIP_DESCENDANTS} | ${true} + ${MEMBERSHIP_ALL_AVAILABLE} | ${false} + `( + 'Displays a membership of $membershipValue as enabled=$toggleValue', + ({ membershipValue, toggleValue }) => { + createComponent({ props: { value: membershipValue } }); + + expect(findToggle().props('value')).toBe(toggleValue); + }, + ); + + it.each` + changeEvt | membershipValue + ${true} | ${MEMBERSHIP_DESCENDANTS} + ${false} | ${MEMBERSHIP_ALL_AVAILABLE} + `( + 'Emits $changeEvt when value is changed to $membershipValue', + ({ changeEvt, membershipValue }) => { + createComponent(); + findToggle().vm.$emit('change', changeEvt); + + expect(wrapper.emitted('input')).toStrictEqual([[membershipValue]]); + }, + ); +}); diff --git a/spec/frontend/ci/runner/components/runner_pagination_spec.js b/spec/frontend/ci/runner/components/runner_pagination_spec.js new file mode 100644 index 00000000000..f835ee4514d --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_pagination_spec.js @@ -0,0 +1,115 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; + +const mockStartCursor = 'START_CURSOR'; +const mockEndCursor = 'END_CURSOR'; + +describe('RunnerPagination', () => { + let wrapper; + + const findPagination = () => wrapper.findComponent(GlKeysetPagination); + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(RunnerPagination, { + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When in between pages', () => { + const mockPageInfo = { + startCursor: mockStartCursor, + endCursor: mockEndCursor, + hasPreviousPage: true, + hasNextPage: true, + }; + + beforeEach(() => { + createComponent({ + pageInfo: mockPageInfo, + }); + }); + + it('Contains the current page information', () => { + expect(findPagination().props()).toMatchObject(mockPageInfo); + }); + + it('Goes to the prev page', () => { + findPagination().vm.$emit('prev'); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + before: mockStartCursor, + }, + ]); + }); + + it('Goes to the next page', () => { + findPagination().vm.$emit('next'); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + after: mockEndCursor, + }, + ]); + }); + }); + + describe.each` + page | hasPreviousPage | hasNextPage + ${'first'} | ${false} | ${true} + ${'last'} | ${true} | ${false} + `('When on the $page page', ({ page, hasPreviousPage, hasNextPage }) => { + const mockPageInfo = { + startCursor: mockStartCursor, + endCursor: mockEndCursor, + hasPreviousPage, + hasNextPage, + }; + + beforeEach(() => { + createComponent({ + pageInfo: mockPageInfo, + }); + }); + + it(`Contains the ${page} page information`, () => { + expect(findPagination().props()).toMatchObject(mockPageInfo); + }); + }); + + describe('When no other pages', () => { + beforeEach(() => { + createComponent({ + pageInfo: { + hasPreviousPage: false, + hasNextPage: false, + }, + }); + }); + + it('is not shown', () => { + expect(findPagination().exists()).toBe(false); + }); + }); + + describe('When adding more attributes', () => { + beforeEach(() => { + createComponent({ + pageInfo: { + hasPreviousPage: true, + hasNextPage: false, + }, + disabled: true, + }); + }); + + it('attributes are passed', () => { + expect(findPagination().props('disabled')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_pause_button_spec.js b/spec/frontend/ci/runner/components/runner_pause_button_spec.js new file mode 100644 index 00000000000..12680e01b98 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_pause_button_spec.js @@ -0,0 +1,263 @@ +import Vue, { nextTick } from 'vue'; +import { GlButton } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +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 { + I18N_PAUSE, + I18N_PAUSE_TOOLTIP, + I18N_RESUME, + I18N_RESUME_TOOLTIP, +} from '~/ci/runner/constants'; + +import RunnerPauseButton from '~/ci/runner/components/runner_pause_button.vue'; +import { allRunnersData } from '../mock_data'; + +const mockRunner = allRunnersData.data.runners.nodes[0]; + +Vue.use(VueApollo); + +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); + +describe('RunnerPauseButton', () => { + let wrapper; + let runnerToggleActiveHandler; + + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; + const findBtn = () => wrapper.findComponent(GlButton); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + const { runner, ...propsData } = props; + + wrapper = mountFn(RunnerPauseButton, { + propsData: { + runner: { + id: mockRunner.id, + active: mockRunner.active, + ...runner, + }, + ...propsData, + }, + apolloProvider: createMockApollo([[runnerToggleActiveMutation, runnerToggleActiveHandler]]), + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const clickAndWait = async () => { + findBtn().vm.$emit('click'); + await waitForPromises(); + }; + + beforeEach(() => { + runnerToggleActiveHandler = jest.fn().mockImplementation(({ input }) => { + return Promise.resolve({ + data: { + runnerUpdate: { + runner: { + id: input.id, + active: input.active, + }, + errors: [], + }, + }, + }); + }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Pause/Resume action', () => { + describe.each` + runnerState | icon | content | tooltip | isActive | newActiveValue + ${'paused'} | ${'play'} | ${I18N_RESUME} | ${I18N_RESUME_TOOLTIP} | ${false} | ${true} + ${'active'} | ${'pause'} | ${I18N_PAUSE} | ${I18N_PAUSE_TOOLTIP} | ${true} | ${false} + `('When the runner is $runnerState', ({ icon, content, tooltip, isActive, newActiveValue }) => { + beforeEach(() => { + createComponent({ + props: { + runner: { + active: isActive, + }, + }, + }); + }); + + it(`Displays a ${icon} button`, () => { + expect(findBtn().props('loading')).toBe(false); + expect(findBtn().props('icon')).toBe(icon); + }); + + it('Displays button content', () => { + expect(findBtn().text()).toBe(content); + expect(getTooltip()).toBe(tooltip); + }); + + it('Does not display redundant text for screen readers', () => { + expect(findBtn().attributes('aria-label')).toBe(undefined); + }); + + describe(`Before the ${icon} button is clicked`, () => { + it('The mutation has not been called', () => { + expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(0); + }); + }); + + describe(`Immediately after the ${icon} button is clicked`, () => { + const setup = async () => { + findBtn().vm.$emit('click'); + await nextTick(); + }; + + it('The button has a loading state', async () => { + await setup(); + + expect(findBtn().props('loading')).toBe(true); + }); + + it('The stale tooltip is removed', async () => { + await setup(); + + expect(getTooltip()).toBe(''); + }); + }); + + describe(`After clicking on the ${icon} button`, () => { + beforeEach(async () => { + await clickAndWait(); + }); + + it(`The mutation to that sets active to ${newActiveValue} is called`, async () => { + expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(1); + expect(runnerToggleActiveHandler).toHaveBeenCalledWith({ + input: { + id: mockRunner.id, + active: newActiveValue, + }, + }); + }); + + it('The button does not have a loading state', () => { + expect(findBtn().props('loading')).toBe(false); + }); + + it('The button emits toggledPaused', () => { + expect(wrapper.emitted('toggledPaused')).toHaveLength(1); + }); + }); + + describe('When update fails', () => { + describe('On a network error', () => { + const mockErrorMsg = 'Update error!'; + + beforeEach(async () => { + runnerToggleActiveHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + await clickAndWait(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(mockErrorMsg), + component: 'RunnerPauseButton', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + + describe('On a validation error', () => { + const mockErrorMsg = 'Runner not found!'; + const mockErrorMsg2 = 'User not allowed!'; + + beforeEach(async () => { + runnerToggleActiveHandler.mockResolvedValueOnce({ + data: { + runnerUpdate: { + runner: { + id: mockRunner.id, + active: isActive, + }, + errors: [mockErrorMsg, mockErrorMsg2], + }, + }, + }); + + await clickAndWait(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerPauseButton', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + }); + + describe('When displaying a compact button for an active runner', () => { + beforeEach(() => { + createComponent({ + props: { + runner: { + active: true, + }, + compact: true, + }, + mountFn: mountExtended, + }); + }); + + it('Displays no text', () => { + expect(findBtn().text()).toBe(''); + + // Note: Use <template v-if> to ensure rendering a + // text-less button. Ensure we don't send even empty an + // content slot to prevent a distorted/rectangular button. + expect(wrapper.find('.gl-button-text').exists()).toBe(false); + }); + + it('Display correctly for screen readers', () => { + expect(findBtn().attributes('aria-label')).toBe(I18N_PAUSE); + expect(getTooltip()).toBe(I18N_PAUSE_TOOLTIP); + }); + + describe('Immediately after the button is clicked', () => { + const setup = async () => { + findBtn().vm.$emit('click'); + await nextTick(); + }; + + it('The button has a loading state', async () => { + await setup(); + + expect(findBtn().props('loading')).toBe(true); + }); + + it('The stale tooltip is removed', async () => { + await setup(); + + expect(getTooltip()).toBe(''); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_paused_badge_spec.js b/spec/frontend/ci/runner/components/runner_paused_badge_spec.js new file mode 100644 index 00000000000..b051ebe99a7 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_paused_badge_spec.js @@ -0,0 +1,46 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerStatePausedBadge from '~/ci/runner/components/runner_paused_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { I18N_PAUSED } from '~/ci/runner/constants'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerStatePausedBadge, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders paused state', () => { + expect(wrapper.text()).toBe(I18N_PAUSED); + expect(findBadge().props('variant')).toBe('warning'); + }); + + it('renders tooltip', () => { + expect(getTooltip().value).toBeDefined(); + }); + + it('passes arbitrary attributes to the badge', () => { + createComponent({ props: { size: 'sm' } }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_projects_spec.js b/spec/frontend/ci/runner/components/runner_projects_spec.js new file mode 100644 index 00000000000..17517c4db66 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_projects_spec.js @@ -0,0 +1,251 @@ +import { GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import Vue from 'vue'; +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 { sprintf } from '~/locale'; +import { + I18N_ASSIGNED_PROJECTS, + I18N_CLEAR_FILTER_PROJECTS, + I18N_FILTER_PROJECTS, + I18N_NO_PROJECTS_FOUND, + RUNNER_DETAILS_PROJECTS_PAGE_SIZE, +} from '~/ci/runner/constants'; +import RunnerProjects from '~/ci/runner/components/runner_projects.vue'; +import RunnerAssignedItem from '~/ci/runner/components/runner_assigned_item.vue'; +import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; +import { captureException } from '~/ci/runner/sentry_utils'; + +import runnerProjectsQuery from '~/ci/runner/graphql/show/runner_projects.query.graphql'; + +import { runnerData, runnerProjectsData } from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); + +const mockRunner = runnerData.data.runner; +const mockRunnerWithProjects = runnerProjectsData.data.runner; +const mockProjects = mockRunnerWithProjects.projects.nodes; + +Vue.use(VueApollo); + +describe('RunnerProjects', () => { + let wrapper; + let mockRunnerProjectsQuery; + + const findHeading = () => wrapper.find('h3'); + const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoader); + const findGlSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); + const findRunnerAssignedItems = () => wrapper.findAllComponents(RunnerAssignedItem); + const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + + const createComponent = ({ mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerProjects, { + apolloProvider: createMockApollo([[runnerProjectsQuery, mockRunnerProjectsQuery]]), + propsData: { + runner: mockRunner, + }, + }); + }; + + beforeEach(() => { + mockRunnerProjectsQuery = jest.fn(); + }); + + afterEach(() => { + mockRunnerProjectsQuery.mockReset(); + wrapper.destroy(); + }); + + it('Requests runner projects', async () => { + createComponent(); + + await waitForPromises(); + + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(1); + expect(mockRunnerProjectsQuery).toHaveBeenCalledWith({ + id: mockRunner.id, + search: '', + first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + }); + }); + + it('Shows a filter box', () => { + createComponent(); + + expect(findGlSearchBoxByType().attributes()).toMatchObject({ + clearbuttontitle: I18N_CLEAR_FILTER_PROJECTS, + debounce: '500', + placeholder: I18N_FILTER_PROJECTS, + }); + }); + + describe('When there are projects assigned', () => { + beforeEach(async () => { + mockRunnerProjectsQuery.mockResolvedValueOnce(runnerProjectsData); + + createComponent(); + await waitForPromises(); + }); + + it('Shows a heading', async () => { + const expected = sprintf(I18N_ASSIGNED_PROJECTS, { projectCount: mockProjects.length }); + + expect(findHeading().text()).toBe(expected); + }); + + it('Shows projects', () => { + expect(findRunnerAssignedItems().length).toBe(mockProjects.length); + }); + + it('Shows a project', () => { + const item = findRunnerAssignedItems().at(0); + const { webUrl, name, nameWithNamespace, avatarUrl } = mockProjects[0]; + + expect(item.props()).toMatchObject({ + href: webUrl, + name, + fullName: nameWithNamespace, + avatarUrl, + isOwner: true, // first project is always owner + }); + }); + + describe('When "Next" page is clicked', () => { + beforeEach(async () => { + findRunnerPagination().vm.$emit('input', { page: 3, after: 'AFTER_CURSOR' }); + + await waitForPromises(); + }); + + it('A new page is requested', () => { + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(2); + expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({ + id: mockRunner.id, + search: '', + first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + after: 'AFTER_CURSOR', + }); + }); + + it('When "Prev" page is clicked, the previous page is requested', async () => { + findRunnerPagination().vm.$emit('input', { page: 2, before: 'BEFORE_CURSOR' }); + + await waitForPromises(); + + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(3); + expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({ + id: mockRunner.id, + search: '', + last: RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + before: 'BEFORE_CURSOR', + }); + }); + + it('When user filters after paginating, the first page is requested', async () => { + findGlSearchBoxByType().vm.$emit('input', 'my search'); + await waitForPromises(); + + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(3); + expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({ + id: mockRunner.id, + search: 'my search', + first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + }); + }); + }); + + describe('When user filters', () => { + it('Filtered results are requested', async () => { + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(1); + + findGlSearchBoxByType().vm.$emit('input', 'my search'); + await waitForPromises(); + + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(2); + expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({ + id: mockRunner.id, + search: 'my search', + first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + }); + }); + + it('Filtered results are not requested for short searches', async () => { + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(1); + + findGlSearchBoxByType().vm.$emit('input', 'm'); + await waitForPromises(); + + findGlSearchBoxByType().vm.$emit('input', 'my'); + await waitForPromises(); + + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('When loading', () => { + it('shows loading indicator and no other content', () => { + createComponent(); + + expect(findGlSkeletonLoading().exists()).toBe(true); + + expect(wrapper.findByText(I18N_NO_PROJECTS_FOUND).exists()).toBe(false); + expect(findRunnerAssignedItems().length).toBe(0); + + expect(findRunnerPagination().attributes('disabled')).toBe('true'); + expect(findGlSearchBoxByType().props('isLoading')).toBe(true); + }); + }); + + describe('When there are no projects', () => { + beforeEach(async () => { + mockRunnerProjectsQuery.mockResolvedValueOnce({ + data: { + runner: { + id: mockRunner.id, + projectCount: 0, + projects: { + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + }, + }, + }, + }); + + createComponent(); + await waitForPromises(); + }); + + it('Shows a "None" label', () => { + expect(wrapper.findByText(I18N_NO_PROJECTS_FOUND).exists()).toBe(true); + }); + }); + + describe('When an error occurs', () => { + beforeEach(async () => { + mockRunnerProjectsQuery.mockRejectedValue(new Error('Error!')); + + createComponent(); + await waitForPromises(); + }); + + it('shows an error', () => { + expect(createAlert).toHaveBeenCalled(); + }); + + it('reports an error', () => { + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerProjects', + error: expect.any(Error), + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_status_badge_spec.js new file mode 100644 index 00000000000..7d3064c2aef --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_status_badge_spec.js @@ -0,0 +1,133 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerStatusBadge from '~/ci/runner/components/runner_status_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { + I18N_STATUS_ONLINE, + I18N_STATUS_NEVER_CONTACTED, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + I18N_NEVER_CONTACTED_TOOLTIP, + I18N_STALE_NEVER_CONTACTED_TOOLTIP, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, + STATUS_NEVER_CONTACTED, +} from '~/ci/runner/constants'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = (props = {}) => { + wrapper = shallowMount(RunnerStatusBadge, { + propsData: { + runner: { + contactedAt: '2020-12-31T23:59:00Z', + status: STATUS_ONLINE, + }, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); + }); + + afterEach(() => { + jest.useFakeTimers('legacy'); + + wrapper.destroy(); + }); + + it('renders online state', () => { + createComponent(); + + expect(wrapper.text()).toBe(I18N_STATUS_ONLINE); + expect(findBadge().props('variant')).toBe('success'); + expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago'); + }); + + it('renders never contacted state', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_NEVER_CONTACTED, + }, + }); + + expect(wrapper.text()).toBe(I18N_STATUS_NEVER_CONTACTED); + expect(findBadge().props('variant')).toBe('muted'); + expect(getTooltip().value).toBe(I18N_NEVER_CONTACTED_TOOLTIP); + }); + + it('renders offline state', () => { + createComponent({ + runner: { + contactedAt: '2020-12-31T00:00:00Z', + status: STATUS_OFFLINE, + }, + }); + + expect(wrapper.text()).toBe(I18N_STATUS_OFFLINE); + expect(findBadge().props('variant')).toBe('muted'); + expect(getTooltip().value).toBe('Runner is offline; last contact was 1 day ago'); + }); + + it('renders stale state', () => { + createComponent({ + runner: { + contactedAt: '2020-01-01T00:00:00Z', + status: STATUS_STALE, + }, + }); + + expect(wrapper.text()).toBe(I18N_STATUS_STALE); + expect(findBadge().props('variant')).toBe('warning'); + expect(getTooltip().value).toBe('Runner is stale; last contact was 1 year ago'); + }); + + it('renders stale state with no contact time', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_STALE, + }, + }); + + expect(wrapper.text()).toBe(I18N_STATUS_STALE); + expect(findBadge().props('variant')).toBe('warning'); + expect(getTooltip().value).toBe(I18N_STALE_NEVER_CONTACTED_TOOLTIP); + }); + + describe('does not fail when data is missing', () => { + it('contacted_at is missing', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_ONLINE, + }, + }); + + expect(wrapper.text()).toBe(I18N_STATUS_ONLINE); + expect(getTooltip().value).toBe('Runner is online; last contact was never'); + }); + + it('status is missing', () => { + createComponent({ + runner: { + status: null, + }, + }); + + expect(wrapper.text()).toBe(''); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_status_popover_spec.js b/spec/frontend/ci/runner/components/runner_status_popover_spec.js new file mode 100644 index 00000000000..89fb95f2da4 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_status_popover_spec.js @@ -0,0 +1,36 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerStatusPopover from '~/ci/runner/components/runner_status_popover.vue'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; + +describe('RunnerStatusPopover', () => { + let wrapper; + + const createComponent = ({ provide = {} } = {}) => { + wrapper = shallowMountExtended(RunnerStatusPopover, { + provide: { + onlineContactTimeoutSecs, + staleTimeoutSecs, + ...provide, + }, + stubs: { + GlSprintf, + }, + }); + }; + + const findHelpPopover = () => wrapper.findComponent(HelpPopover); + + it('renders popoover', () => { + createComponent(); + + expect(findHelpPopover().exists()).toBe(true); + }); + + it('renders complete text', () => { + createComponent(); + + expect(findHelpPopover().text()).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_tag_spec.js b/spec/frontend/ci/runner/components/runner_tag_spec.js new file mode 100644 index 00000000000..7bcb046ae43 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_tag_spec.js @@ -0,0 +1,79 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; + +import { RUNNER_TAG_BADGE_VARIANT } from '~/ci/runner/constants'; +import RunnerTag from '~/ci/runner/components/runner_tag.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +const mockTag = 'tag1'; + +describe('RunnerTag', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltipValue = () => getBinding(findBadge().element, 'gl-tooltip').value; + + const setDimensions = ({ scrollWidth, offsetWidth }) => { + jest.spyOn(findBadge().element, 'scrollWidth', 'get').mockReturnValue(scrollWidth); + jest.spyOn(findBadge().element, 'offsetWidth', 'get').mockReturnValue(offsetWidth); + + // Mock trigger resize + getBinding(findBadge().element, 'gl-resize-observer').value(); + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTag, { + propsData: { + tag: mockTag, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + GlResizeObserver: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays tag text', () => { + expect(wrapper.text()).toBe(mockTag); + }); + + it('Displays tags with correct style', () => { + expect(findBadge().props()).toMatchObject({ + size: 'sm', + variant: RUNNER_TAG_BADGE_VARIANT, + }); + }); + + it('Displays tags with md size', () => { + createComponent({ + props: { size: 'md' }, + }); + + expect(findBadge().props('size')).toBe('md'); + }); + + it.each` + case | scrollWidth | offsetWidth | expectedTooltip + ${'overflowing'} | ${110} | ${100} | ${mockTag} + ${'not overflowing'} | ${90} | ${100} | ${''} + ${'almost overflowing'} | ${100} | ${100} | ${''} + `( + 'Sets "$expectedTooltip" as tooltip when $case', + async ({ scrollWidth, offsetWidth, expectedTooltip }) => { + setDimensions({ scrollWidth, offsetWidth }); + await nextTick(); + + expect(getTooltipValue()).toBe(expectedTooltip); + }, + ); +}); diff --git a/spec/frontend/ci/runner/components/runner_tags_spec.js b/spec/frontend/ci/runner/components/runner_tags_spec.js new file mode 100644 index 00000000000..96bec00302b --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_tags_spec.js @@ -0,0 +1,54 @@ +import { GlBadge } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerTags from '~/ci/runner/components/runner_tags.vue'; + +describe('RunnerTags', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const findBadgesAt = (i = 0) => wrapper.findAllComponents(GlBadge).at(i); + + const createComponent = ({ props = {} } = {}) => { + wrapper = mount(RunnerTags, { + propsData: { + tagList: ['tag1', 'tag2'], + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays tags text', () => { + expect(wrapper.text()).toMatchInterpolatedText('tag1 tag2'); + + expect(findBadgesAt(0).text()).toBe('tag1'); + expect(findBadgesAt(1).text()).toBe('tag2'); + }); + + it('Displays tags with correct style', () => { + expect(findBadge().props('size')).toBe('sm'); + }); + + it('Displays tags with md size', () => { + createComponent({ + props: { size: 'md' }, + }); + + expect(findBadge().props('size')).toBe('md'); + }); + + it('Is empty when there are no tags', () => { + createComponent({ + props: { tagList: null }, + }); + + expect(wrapper.html()).toEqual(''); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_type_badge_spec.js b/spec/frontend/ci/runner/components/runner_type_badge_spec.js new file mode 100644 index 00000000000..58f09362759 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_type_badge_spec.js @@ -0,0 +1,66 @@ +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 { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_INSTANCE_TYPE, + I18N_GROUP_TYPE, + I18N_PROJECT_TYPE, +} from '~/ci/runner/constants'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTypeBadge, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + type | text + ${INSTANCE_TYPE} | ${I18N_INSTANCE_TYPE} + ${GROUP_TYPE} | ${I18N_GROUP_TYPE} + ${PROJECT_TYPE} | ${I18N_PROJECT_TYPE} + `('displays $type runner', ({ type, text }) => { + beforeEach(() => { + createComponent({ props: { type } }); + }); + + it(`as "${text}" with an "info" variant`, () => { + expect(findBadge().text()).toBe(text); + expect(findBadge().props('variant')).toBe('muted'); + }); + + it('with a tooltip', () => { + expect(getTooltip().value).toBeDefined(); + }); + }); + + it('validation fails for an incorrect type', () => { + expect(() => { + createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); + }).toThrow(); + }); + + it('does not render content when type is missing', () => { + createComponent({ props: { type: undefined } }); + + expect(findBadge().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_type_tabs_spec.js b/spec/frontend/ci/runner/components/runner_type_tabs_spec.js new file mode 100644 index 00000000000..3347c190083 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_type_tabs_spec.js @@ -0,0 +1,214 @@ +import { GlTab } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTypeTabs from '~/ci/runner/components/runner_type_tabs.vue'; +import RunnerCount from '~/ci/runner/components/stat/runner_count.vue'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + DEFAULT_MEMBERSHIP, + DEFAULT_SORT, +} from '~/ci/runner/constants'; + +const mockSearch = { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: { page: 1 }, + sort: DEFAULT_SORT, +}; + +const mockCount = (type, multiplier = 1) => { + let count; + switch (type) { + case INSTANCE_TYPE: + count = 3; + break; + case GROUP_TYPE: + count = 2; + break; + case PROJECT_TYPE: + count = 1; + break; + default: + count = 6; + break; + } + return count * multiplier; +}; + +describe('RunnerTypeTabs', () => { + let wrapper; + + const findTabs = () => wrapper.findAllComponents(GlTab); + const findActiveTab = () => + findTabs() + .filter((tab) => tab.attributes('active') === 'true') + .at(0); + const getTabsTitles = () => findTabs().wrappers.map((tab) => tab.text().replace(/\s+/g, ' ')); + + const createComponent = ({ props, stubs, ...options } = {}) => { + wrapper = shallowMount(RunnerTypeTabs, { + propsData: { + value: mockSearch, + countScope: INSTANCE_TYPE, + countVariables: {}, + ...props, + }, + stubs: { + GlTab, + ...stubs, + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Renders all options to filter runners by default', () => { + createComponent(); + + expect(getTabsTitles()).toEqual(['All', 'Instance', 'Group', 'Project']); + }); + + it('Shows count when receiving a number', () => { + createComponent({ + stubs: { + RunnerCount: { + props: ['variables'], + render() { + return this.$scopedSlots.default({ + count: mockCount(this.variables.type), + }); + }, + }, + }, + }); + + expect(getTabsTitles()).toEqual([`All 6`, `Instance 3`, `Group 2`, `Project 1`]); + }); + + it('Shows formatted count when receiving a large number', () => { + createComponent({ + stubs: { + RunnerCount: { + props: ['variables'], + render() { + return this.$scopedSlots.default({ + count: mockCount(this.variables.type, 1000), + }); + }, + }, + }, + }); + + expect(getTabsTitles()).toEqual([ + `All 6,000`, + `Instance 3,000`, + `Group 2,000`, + `Project 1,000`, + ]); + }); + + it('Renders a count next to each tab', () => { + const mockVariables = { + paused: true, + status: 'ONLINE', + }; + + createComponent({ + props: { + countVariables: mockVariables, + }, + }); + + findTabs().wrappers.forEach((tab) => { + expect(tab.findComponent(RunnerCount).props()).toEqual({ + scope: INSTANCE_TYPE, + skip: false, + variables: expect.objectContaining(mockVariables), + }); + }); + }); + + it('Renders fewer options to filter runners', () => { + createComponent({ + props: { + runnerTypes: [GROUP_TYPE, PROJECT_TYPE], + }, + }); + + expect(getTabsTitles()).toEqual(['All', 'Group', 'Project']); + }); + + it('"All" is selected by default', () => { + createComponent(); + + expect(findActiveTab().text()).toBe('All'); + }); + + it('Another tab can be preselected by the user', () => { + createComponent({ + props: { + value: { + ...mockSearch, + runnerType: INSTANCE_TYPE, + }, + }, + }); + + expect(findActiveTab().text()).toBe('Instance'); + }); + + describe('When the user selects a tab', () => { + const emittedValue = () => wrapper.emitted('input')[0][0]; + + beforeEach(() => { + createComponent(); + findTabs().at(2).vm.$emit('click'); + }); + + it(`Runner type is emitted`, () => { + expect(emittedValue()).toEqual({ + ...mockSearch, + runnerType: GROUP_TYPE, + }); + }); + + it('Runner type is selected', async () => { + const newValue = emittedValue(); + await wrapper.setProps({ value: newValue }); + + expect(findActiveTab().text()).toBe('Group'); + }); + }); + + describe('Component API', () => { + describe('When .refetch() is called', () => { + let mockRefetch; + + beforeEach(() => { + mockRefetch = jest.fn(); + + createComponent({ + stubs: { + RunnerCount: { + methods: { + refetch: mockRefetch, + }, + render() {}, + }, + }, + }); + + wrapper.vm.refetch(); + }); + + it('refetch is called for each count', () => { + expect(mockRefetch).toHaveBeenCalledTimes(4); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_update_form_spec.js b/spec/frontend/ci/runner/components/runner_update_form_spec.js new file mode 100644 index 00000000000..a0e51ebf958 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_update_form_spec.js @@ -0,0 +1,288 @@ +import Vue, { nextTick } from 'vue'; +import { GlForm, GlSkeletonLoader } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +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 RunnerUpdateForm from '~/ci/runner/components/runner_update_form.vue'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + ACCESS_LEVEL_REF_PROTECTED, + ACCESS_LEVEL_NOT_PROTECTED, +} from '~/ci/runner/constants'; +import runnerUpdateMutation from '~/ci/runner/graphql/edit/runner_update.mutation.graphql'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; +import { runnerFormData } from '../mock_data'; + +jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility'); + +const mockRunner = runnerFormData.data.runner; +const mockRunnerPath = '/admin/runners/1'; + +Vue.use(VueApollo); + +describe('RunnerUpdateForm', () => { + let wrapper; + let runnerUpdateHandler; + + const findForm = () => wrapper.findComponent(GlForm); + const findPausedCheckbox = () => wrapper.findByTestId('runner-field-paused'); + const findProtectedCheckbox = () => wrapper.findByTestId('runner-field-protected'); + const findRunUntaggedCheckbox = () => wrapper.findByTestId('runner-field-run-untagged'); + const findLockedCheckbox = () => wrapper.findByTestId('runner-field-locked'); + const findFields = () => wrapper.findAll('[data-testid^="runner-field"'); + + const findDescriptionInput = () => wrapper.findByTestId('runner-field-description').find('input'); + const findMaxJobTimeoutInput = () => + wrapper.findByTestId('runner-field-max-timeout').find('input'); + const findTagsInput = () => wrapper.findByTestId('runner-field-tags').find('input'); + + const findSubmit = () => wrapper.find('[type="submit"]'); + const findSubmitDisabledAttr = () => findSubmit().attributes('disabled'); + const findCancelBtn = () => wrapper.findByRole('link', { name: __('Cancel') }); + const submitForm = () => findForm().trigger('submit'); + const submitFormAndWait = () => submitForm().then(waitForPromises); + + const getFieldsModel = () => ({ + active: !findPausedCheckbox().element.checked, + accessLevel: findProtectedCheckbox().element.checked + ? ACCESS_LEVEL_REF_PROTECTED + : ACCESS_LEVEL_NOT_PROTECTED, + runUntagged: findRunUntaggedCheckbox().element.checked, + locked: findLockedCheckbox().element?.checked || false, + maximumTimeout: findMaxJobTimeoutInput().element.value || null, + tagList: findTagsInput().element.value.split(',').filter(Boolean), + }); + + const createComponent = ({ props } = {}) => { + wrapper = mountExtended(RunnerUpdateForm, { + propsData: { + runner: mockRunner, + runnerPath: mockRunnerPath, + ...props, + }, + apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]), + }); + }; + + const expectToHaveSubmittedRunnerContaining = (submittedRunner) => { + expect(runnerUpdateHandler).toHaveBeenCalledTimes(1); + expect(runnerUpdateHandler).toHaveBeenCalledWith({ + input: expect.objectContaining(submittedRunner), + }); + + expect(saveAlertToLocalStorage).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.any(String), + variant: VARIANT_SUCCESS, + }), + ); + expect(redirectTo).toHaveBeenCalledWith(mockRunnerPath); + }; + + beforeEach(() => { + runnerUpdateHandler = jest.fn().mockImplementation(({ input }) => { + return Promise.resolve({ + data: { + runnerUpdate: { + runner: { + ...mockRunner, + ...input, + }, + errors: [], + }, + }, + }); + }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Form has a submit button', () => { + expect(findSubmit().exists()).toBe(true); + }); + + it('Form fields match data', () => { + expect(mockRunner).toMatchObject(getFieldsModel()); + }); + + it('Form shows a cancel button', () => { + expect(runnerUpdateHandler).not.toHaveBeenCalled(); + expect(findCancelBtn().attributes('href')).toBe(mockRunnerPath); + }); + + it('Form prevent multiple submissions', async () => { + await submitForm(); + + expect(findSubmitDisabledAttr()).toBe('disabled'); + }); + + it('Updates runner with no changes', async () => { + await submitFormAndWait(); + + // Some read-only fields are not submitted + const { __typename, shortSha, runnerType, createdAt, status, ...submitted } = mockRunner; + + expectToHaveSubmittedRunnerContaining(submitted); + }); + + describe('When data is being loaded', () => { + beforeEach(() => { + createComponent({ props: { loading: true } }); + }); + + it('Form skeleton is shown', () => { + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); + expect(findFields()).toHaveLength(0); + }); + + it('Form cannot be submitted', () => { + expect(findSubmit().props('loading')).toBe(true); + }); + + it('Form is updated when data loads', async () => { + wrapper.setProps({ + loading: false, + }); + + await nextTick(); + + expect(findFields()).not.toHaveLength(0); + expect(mockRunner).toMatchObject(getFieldsModel()); + }); + }); + + it.each` + runnerType | exists | outcome + ${INSTANCE_TYPE} | ${false} | ${'hidden'} + ${GROUP_TYPE} | ${false} | ${'hidden'} + ${PROJECT_TYPE} | ${true} | ${'shown'} + `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, exists }) => { + const runner = { ...mockRunner, runnerType }; + createComponent({ props: { runner } }); + + expect(findLockedCheckbox().exists()).toBe(exists); + }); + + describe('On submit, runner gets updated', () => { + it.each` + test | initialValue | findCheckbox | checked | submitted + ${'pauses'} | ${{ active: true }} | ${findPausedCheckbox} | ${true} | ${{ active: false }} + ${'activates'} | ${{ active: false }} | ${findPausedCheckbox} | ${false} | ${{ active: true }} + ${'unprotects'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} | ${findProtectedCheckbox} | ${true} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} + ${'protects'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} | ${findProtectedCheckbox} | ${false} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} + ${'"runs untagged jobs"'} | ${{ runUntagged: true }} | ${findRunUntaggedCheckbox} | ${false} | ${{ runUntagged: false }} + ${'"runs tagged jobs"'} | ${{ runUntagged: false }} | ${findRunUntaggedCheckbox} | ${true} | ${{ runUntagged: true }} + ${'locks'} | ${{ runnerType: PROJECT_TYPE, locked: true }} | ${findLockedCheckbox} | ${false} | ${{ locked: false }} + ${'unlocks'} | ${{ runnerType: PROJECT_TYPE, locked: false }} | ${findLockedCheckbox} | ${true} | ${{ locked: true }} + `('Checkbox $test runner', async ({ initialValue, findCheckbox, checked, submitted }) => { + const runner = { ...mockRunner, ...initialValue }; + createComponent({ props: { runner } }); + + await findCheckbox().setChecked(checked); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + + it.each` + test | initialValue | findInput | value | submitted + ${'description'} | ${{ description: 'Desc. 1' }} | ${findDescriptionInput} | ${'Desc. 2'} | ${{ description: 'Desc. 2' }} + ${'max timeout'} | ${{ maximumTimeout: 36000 }} | ${findMaxJobTimeoutInput} | ${'40000'} | ${{ maximumTimeout: 40000 }} + ${'tags'} | ${{ tagList: ['tag1'] }} | ${findTagsInput} | ${'tag2, tag3'} | ${{ tagList: ['tag2', 'tag3'] }} + `("Field updates runner's $test", async ({ initialValue, findInput, value, submitted }) => { + const runner = { ...mockRunner, ...initialValue }; + createComponent({ props: { runner } }); + + await findInput().setValue(value); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + + it.each` + value | submitted + ${''} | ${{ tagList: [] }} + ${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }} + ${'with spaces'} | ${{ tagList: ['with spaces'] }} + ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }} + `('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => { + const runner = { ...mockRunner, tagList: ['tag1'] }; + createComponent({ props: { runner } }); + + await findTagsInput().setValue(value); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + }); + + describe('On error', () => { + beforeEach(() => { + createComponent(); + }); + + it('On network error, error message is shown', async () => { + const mockErrorMsg = 'Update error!'; + + runnerUpdateHandler.mockRejectedValue(new Error(mockErrorMsg)); + + await submitFormAndWait(); + + expect(createAlert).toHaveBeenLastCalledWith({ + message: mockErrorMsg, + }); + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerUpdateForm', + error: new Error(mockErrorMsg), + }); + expect(findSubmitDisabledAttr()).toBeUndefined(); + }); + + it('On validation error, error message is shown and it is not sent to sentry', async () => { + const mockErrorMsg = 'Invalid value!'; + + runnerUpdateHandler.mockResolvedValue({ + data: { + runnerUpdate: { + runner: mockRunner, + errors: [mockErrorMsg], + }, + }, + }); + + await submitFormAndWait(); + + expect(createAlert).toHaveBeenLastCalledWith({ + message: mockErrorMsg, + }); + expect(findSubmitDisabledAttr()).toBeUndefined(); + + expect(captureException).not.toHaveBeenCalled(); + expect(saveAlertToLocalStorage).not.toHaveBeenCalled(); + expect(redirectTo).not.toHaveBeenCalled(); + }); + }); +}); 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 new file mode 100644 index 00000000000..d3c7ea50f9d --- /dev/null +++ b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js @@ -0,0 +1,208 @@ +import { GlFilteredSearchSuggestion, GlLoadingIcon, GlToken } from '@gitlab/ui'; +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 axios from '~/lib/utils/axios_utils'; + +import TagToken, { TAG_SUGGESTIONS_PATH } from '~/ci/runner/components/search_tokens/tag_token.vue'; +import { OPERATOR_IS_ONLY } 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('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ + ...jest.requireActual('~/vue_shared/components/filtered_search_bar/filtered_search_utils'), + getRecentlyUsedSuggestions: jest.fn(), +})); + +const mockStorageKey = 'stored-recent-tags'; + +const mockTags = [ + { id: 1, name: 'linux' }, + { id: 2, name: 'windows' }, + { id: 3, name: 'mac' }, +]; + +const mockTagsFiltered = [mockTags[0]]; + +const mockSearchTerm = mockTags[0].name; + +const GlFilteredSearchTokenStub = { + template: `<div> + <slot name="view-token"></slot> + <slot name="suggestions"></slot> + </div>`, +}; + +const mockTagTokenConfig = { + icon: 'tag', + title: 'Tags', + type: 'tag', + token: TagToken, + recentSuggestionsStorageKey: mockStorageKey, + operators: OPERATOR_IS_ONLY, +}; + +describe('TagToken', () => { + let mock; + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(TagToken, { + propsData: { + config: mockTagTokenConfig, + value: { data: '' }, + active: false, + ...props, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + filteredSearchSuggestionListInstance: { + register: jest.fn(), + unregister: jest.fn(), + }, + }, + stubs: { + GlFilteredSearchToken: GlFilteredSearchTokenStub, + }, + }); + }; + + const findGlFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findGlFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchTokenStub); + const findToken = () => wrapper.findComponent(GlToken); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(200, mockTags); + mock + .onGet(TAG_SUGGESTIONS_PATH, { params: { search: mockSearchTerm } }) + .reply(200, mockTagsFiltered); + + getRecentlyUsedSuggestions.mockReturnValue([]); + }); + + afterEach(() => { + getRecentlyUsedSuggestions.mockReset(); + wrapper.destroy(); + }); + + describe('when the tags token is displayed', () => { + beforeEach(() => { + createComponent(); + }); + + it('requests tags suggestions', () => { + expect(mock.history.get[0].params).toEqual({ search: '' }); + }); + + it('displays tags suggestions', async () => { + await waitForPromises(); + + mockTags.forEach(({ name }, i) => { + expect(findGlFilteredSearchSuggestions().at(i).text()).toBe(name); + }); + }); + }); + + describe('when suggestions are stored', () => { + const storedSuggestions = [{ id: 4, value: 'docker', text: 'docker' }]; + + beforeEach(async () => { + getRecentlyUsedSuggestions.mockReturnValue(storedSuggestions); + + createComponent(); + await waitForPromises(); + }); + + it('suggestions are loaded from a correct key', () => { + expect(getRecentlyUsedSuggestions).toHaveBeenCalledWith(mockStorageKey); + }); + + it('displays stored tags suggestions', () => { + expect(findGlFilteredSearchSuggestions()).toHaveLength( + mockTags.length + storedSuggestions.length, + ); + + expect(findGlFilteredSearchSuggestions().at(0).text()).toBe(storedSuggestions[0].text); + }); + }); + + describe('when the users filters suggestions', () => { + beforeEach(() => { + createComponent(); + + findGlFilteredSearchToken().vm.$emit('input', { data: mockSearchTerm }); + }); + + it('requests filtered tags suggestions', () => { + expect(mock.history.get[1].params).toEqual({ search: mockSearchTerm }); + }); + + it('shows the loading icon', async () => { + findGlFilteredSearchToken().vm.$emit('input', { data: mockSearchTerm }); + await nextTick(); + + expect(findGlLoadingIcon().exists()).toBe(true); + }); + + it('displays filtered tags suggestions', async () => { + await waitForPromises(); + + expect(findGlFilteredSearchSuggestions()).toHaveLength(mockTagsFiltered.length); + + expect(findGlFilteredSearchSuggestions().at(0).text()).toBe(mockTagsFiltered[0].name); + }); + }); + + describe('when suggestions cannot be loaded', () => { + beforeEach(async () => { + mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(500); + + createComponent(); + await waitForPromises(); + }); + + it('error is shown', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: expect.any(String) }); + }); + }); + + describe('when the user selects a value', () => { + beforeEach(async () => { + createComponent({ value: { data: mockTags[0].name } }); + findGlFilteredSearchToken().vm.$emit('select'); + + await waitForPromises(); + }); + + it('selected tag is displayed', () => { + expect(findToken().exists()).toBe(true); + }); + }); + + describe('when suggestions are disabled', () => { + beforeEach(async () => { + createComponent({ + config: { + ...mockTagTokenConfig, + suggestionsDisabled: true, + }, + }); + + await waitForPromises(); + }); + + it('displays no suggestions', () => { + expect(findGlFilteredSearchSuggestions()).toHaveLength(0); + expect(mock.history.get).toHaveLength(0); + }); + }); +}); diff --git a/spec/frontend/ci/runner/components/stat/runner_count_spec.js b/spec/frontend/ci/runner/components/stat/runner_count_spec.js new file mode 100644 index 00000000000..42d8c9a1080 --- /dev/null +++ b/spec/frontend/ci/runner/components/stat/runner_count_spec.js @@ -0,0 +1,148 @@ +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 createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { captureException } from '~/ci/runner/sentry_utils'; + +import allRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners_count.query.graphql'; +import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql'; + +import { runnersCountData, groupRunnersCountData } from '../../mock_data'; + +jest.mock('~/ci/runner/sentry_utils'); + +Vue.use(VueApollo); + +describe('RunnerCount', () => { + let wrapper; + let mockRunnersCountHandler; + let mockGroupRunnersCountHandler; + + const createComponent = ({ props = {}, ...options } = {}) => { + const handlers = [ + [allRunnersCountQuery, mockRunnersCountHandler], + [groupRunnersCountQuery, mockGroupRunnersCountHandler], + ]; + + wrapper = shallowMount(RunnerCount, { + apolloProvider: createMockApollo(handlers), + propsData: { + ...props, + }, + scopedSlots: { + default: '<strong>{{props.count}}</strong>', + }, + ...options, + }); + + return waitForPromises(); + }; + + beforeEach(() => { + mockRunnersCountHandler = jest.fn().mockResolvedValue(runnersCountData); + mockGroupRunnersCountHandler = jest.fn().mockResolvedValue(groupRunnersCountData); + }); + + describe('in admin scope', () => { + const mockVariables = { status: 'ONLINE' }; + + beforeEach(async () => { + await createComponent({ props: { scope: INSTANCE_TYPE } }); + }); + + it('fetches data from admin query', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(1); + expect(mockRunnersCountHandler).toHaveBeenCalledWith({}); + }); + + it('fetches data with filters', async () => { + await createComponent({ props: { scope: INSTANCE_TYPE, variables: mockVariables } }); + + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(2); + expect(mockRunnersCountHandler).toHaveBeenCalledWith(mockVariables); + + expect(wrapper.html()).toBe(`<strong>${runnersCountData.data.runners.count}</strong>`); + }); + + it('does not fetch from the group query', async () => { + expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled(); + }); + + describe('when this query is skipped after data was loaded', () => { + beforeEach(async () => { + wrapper.setProps({ skip: true }); + + await nextTick(); + }); + + it('clears current data', () => { + expect(wrapper.html()).toBe('<strong></strong>'); + }); + }); + }); + + describe('when skipping query', () => { + beforeEach(async () => { + await createComponent({ props: { scope: INSTANCE_TYPE, skip: true } }); + }); + + it('does not fetch data', async () => { + expect(mockRunnersCountHandler).not.toHaveBeenCalled(); + expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled(); + + expect(wrapper.html()).toBe('<strong></strong>'); + }); + }); + + describe('when runners query fails', () => { + const mockError = new Error('error!'); + + beforeEach(async () => { + mockRunnersCountHandler.mockRejectedValue(mockError); + + await createComponent({ props: { scope: INSTANCE_TYPE } }); + }); + + it('data is not shown and error is reported', async () => { + expect(wrapper.html()).toBe('<strong></strong>'); + + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerCount', + error: mockError, + }); + }); + }); + + describe('in group scope', () => { + beforeEach(async () => { + await createComponent({ props: { scope: GROUP_TYPE } }); + }); + + it('fetches data from the group query', async () => { + expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(1); + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({}); + + expect(wrapper.html()).toBe( + `<strong>${groupRunnersCountData.data.group.runners.count}</strong>`, + ); + }); + + it('does not fetch from the group query', () => { + expect(mockRunnersCountHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when .refetch() is called', () => { + beforeEach(async () => { + await createComponent({ props: { scope: INSTANCE_TYPE } }); + wrapper.vm.refetch(); + }); + + it('data is not shown and error is reported', async () => { + 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 new file mode 100644 index 00000000000..cad61f26012 --- /dev/null +++ b/spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js @@ -0,0 +1,61 @@ +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { shallowMount } from '@vue/test-utils'; +import RunnerSingleStat from '~/ci/runner/components/stat/runner_single_stat.vue'; +import RunnerCount from '~/ci/runner/components/stat/runner_count.vue'; +import { INSTANCE_TYPE, GROUP_TYPE } from '~/ci/runner/constants'; + +describe('RunnerStats', () => { + let wrapper; + + const findRunnerCount = () => wrapper.findComponent(RunnerCount); + const findGlSingleStat = () => wrapper.findComponent(GlSingleStat); + + const createComponent = ({ props = {}, count, mountFn = shallowMount, ...options } = {}) => { + wrapper = mountFn(RunnerSingleStat, { + propsData: { + scope: INSTANCE_TYPE, + title: 'My title', + variables: {}, + ...props, + }, + stubs: { + RunnerCount: { + props: ['scope', 'variables', 'skip'], + render() { + return this.$scopedSlots.default({ + count, + }); + }, + }, + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + case | count | value + ${'number'} | ${99} | ${'99'} + ${'long number'} | ${1000} | ${'1,000'} + ${'empty number'} | ${null} | ${'-'} + `('formats $case', ({ count, value }) => { + createComponent({ count }); + + expect(findGlSingleStat().props('value')).toBe(value); + }); + + it('Passes runner count props', () => { + const props = { + scope: GROUP_TYPE, + variables: { paused: true }, + skip: true, + }; + + createComponent({ props }); + + expect(findRunnerCount().props()).toEqual(props); + }); +}); diff --git a/spec/frontend/ci/runner/components/stat/runner_stats_spec.js b/spec/frontend/ci/runner/components/stat/runner_stats_spec.js new file mode 100644 index 00000000000..daebf3df050 --- /dev/null +++ b/spec/frontend/ci/runner/components/stat/runner_stats_spec.js @@ -0,0 +1,81 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerStats from '~/ci/runner/components/stat/runner_stats.vue'; +import RunnerSingleStat from '~/ci/runner/components/stat/runner_single_stat.vue'; +import { + I18N_STATUS_ONLINE, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + INSTANCE_TYPE, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, +} from '~/ci/runner/constants'; + +describe('RunnerStats', () => { + let wrapper; + + const findSingleStats = () => wrapper.findAllComponents(RunnerSingleStat); + + const createComponent = ({ props = {}, mountFn = shallowMount, ...options } = {}) => { + wrapper = mountFn(RunnerStats, { + propsData: { + scope: INSTANCE_TYPE, + variables: {}, + ...props, + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays all the stats', () => { + const mockCounts = { + [STATUS_ONLINE]: 3, + [STATUS_OFFLINE]: 2, + [STATUS_STALE]: 1, + }; + + createComponent({ + mountFn: mount, + stubs: { + RunnerCount: { + props: ['variables'], + render() { + return this.$scopedSlots.default({ + count: mockCounts[this.variables.status], + }); + }, + }, + }, + }); + + const text = wrapper.text(); + expect(text).toContain(`${I18N_STATUS_ONLINE} 3`); + expect(text).toContain(`${I18N_STATUS_OFFLINE} 2`); + expect(text).toContain(`${I18N_STATUS_STALE} 1`); + }); + + it('Skips query for other stats', () => { + createComponent({ + props: { + variables: { status: STATUS_ONLINE }, + }, + }); + + expect(findSingleStats().at(0).props('skip')).toBe(false); + expect(findSingleStats().at(1).props('skip')).toBe(true); + expect(findSingleStats().at(2).props('skip')).toBe(true); + }); + + it('Displays all counts for filtered searches', () => { + const mockVariables = { paused: true }; + createComponent({ props: { variables: mockVariables } }); + + findSingleStats().wrappers.forEach((stat) => { + expect(stat.props('variables')).toMatchObject(mockVariables); + }); + }); +}); diff --git a/spec/frontend/ci/runner/graphql/local_state_spec.js b/spec/frontend/ci/runner/graphql/local_state_spec.js new file mode 100644 index 00000000000..ce07a6a618d --- /dev/null +++ b/spec/frontend/ci/runner/graphql/local_state_spec.js @@ -0,0 +1,167 @@ +import { gql } from '@apollo/client/core'; +import createApolloClient from '~/lib/graphql'; +import { createLocalState } from '~/ci/runner/graphql/list/local_state'; +import getCheckedRunnerIdsQuery from '~/ci/runner/graphql/list/checked_runner_ids.query.graphql'; +import { RUNNER_TYPENAME } from '~/ci/runner/constants'; + +const makeRunner = (id, deleteRunner = true) => ({ + id, + userPermissions: { + deleteRunner, + }, +}); + +describe('~/ci/runner/graphql/list/local_state', () => { + let localState; + let apolloClient; + + const createSubject = () => { + if (apolloClient) { + throw new Error('test subject already exists!'); + } + + localState = createLocalState(); + + const { cacheConfig, typeDefs } = localState; + + apolloClient = createApolloClient({}, { cacheConfig, typeDefs }); + }; + + const addMockRunnerToCache = (id) => { + // mock some runners in the cache to prevent dangling references + apolloClient.writeFragment({ + id: `${RUNNER_TYPENAME}:${id}`, + fragment: gql` + fragment DummyRunner on CiRunner { + __typename + } + `, + data: { + __typename: RUNNER_TYPENAME, + }, + }); + }; + + const queryCheckedRunnerIds = () => { + const { checkedRunnerIds } = apolloClient.readQuery({ + query: getCheckedRunnerIdsQuery, + }); + return checkedRunnerIds; + }; + + beforeEach(() => { + createSubject(); + }); + + afterEach(() => { + localState = null; + apolloClient = null; + }); + + describe('queryCheckedRunnerIds', () => { + it('has empty checked list by default', () => { + expect(queryCheckedRunnerIds()).toEqual([]); + }); + + it('returns checked runners that have a reference in the cache', () => { + const id = 'a'; + + addMockRunnerToCache(id); + localState.localMutations.setRunnerChecked({ + runner: makeRunner(id), + isChecked: true, + }); + + expect(queryCheckedRunnerIds()).toEqual(['a']); + }); + + it('return checked runners that are not dangling references', () => { + addMockRunnerToCache('a'); // 'b' is missing from the cache, perhaps because it was deleted + localState.localMutations.setRunnerChecked({ runner: makeRunner('a'), isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: makeRunner('b'), isChecked: true }); + + expect(queryCheckedRunnerIds()).toEqual(['a']); + }); + }); + + describe.each` + inputs | expected + ${[['a', true], ['b', true], ['b', true]]} | ${['a', 'b']} + ${[['a', true], ['b', true], ['a', false]]} | ${['b']} + ${[['c', true], ['b', true], ['a', true], ['d', false]]} | ${['c', 'b', 'a']} + `('setRunnerChecked', ({ inputs, expected }) => { + beforeEach(() => { + inputs.forEach(([id, isChecked]) => { + addMockRunnerToCache(id); + localState.localMutations.setRunnerChecked({ runner: makeRunner(id), isChecked }); + }); + }); + it(`for inputs="${inputs}" has a ids="[${expected}]"`, () => { + expect(queryCheckedRunnerIds()).toEqual(expected); + }); + }); + + describe.each` + inputs | expected + ${[[['a', 'b'], true]]} | ${['a', 'b']} + ${[[['a', 'b'], false]]} | ${[]} + ${[[['a', 'b'], true], [['c', 'd'], true]]} | ${['a', 'b', 'c', 'd']} + ${[[['a', 'b'], true], [['a', 'b'], false]]} | ${[]} + ${[[['a', 'b'], true], [['b'], false]]} | ${['a']} + `('setRunnersChecked', ({ inputs, expected }) => { + beforeEach(() => { + inputs.forEach(([ids, isChecked]) => { + ids.forEach(addMockRunnerToCache); + + localState.localMutations.setRunnersChecked({ + runners: ids.map((id) => makeRunner(id)), + isChecked, + }); + }); + }); + + it(`for inputs="${inputs}" has a ids="[${expected}]"`, () => { + expect(queryCheckedRunnerIds()).toEqual(expected); + }); + }); + + describe('clearChecked', () => { + it('clears all checked items', () => { + ['a', 'b', 'c'].forEach((id) => { + addMockRunnerToCache(id); + localState.localMutations.setRunnerChecked({ runner: makeRunner(id), isChecked: true }); + }); + + expect(queryCheckedRunnerIds()).toEqual(['a', 'b', 'c']); + + localState.localMutations.clearChecked(); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); + + describe('when some runners cannot be deleted', () => { + beforeEach(() => { + addMockRunnerToCache('a'); + addMockRunnerToCache('b'); + }); + + it('setRunnerChecked does not check runner that cannot be deleted', () => { + localState.localMutations.setRunnerChecked({ + runner: makeRunner('a', false), + isChecked: true, + }); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + + it('setRunnersChecked does not check runner that cannot be deleted', () => { + localState.localMutations.setRunnersChecked({ + runners: [makeRunner('a', false), makeRunner('b', false)], + isChecked: true, + }); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); +}); 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 new file mode 100644 index 00000000000..c6c3f3b7040 --- /dev/null +++ b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js @@ -0,0 +1,215 @@ +import Vue from 'vue'; +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 { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerHeader from '~/ci/runner/components/runner_header.vue'; +import RunnerDetails from '~/ci/runner/components/runner_details.vue'; +import RunnerPauseButton from '~/ci/runner/components/runner_pause_button.vue'; +import RunnerDeleteButton from '~/ci/runner/components/runner_delete_button.vue'; +import RunnerEditButton from '~/ci/runner/components/runner_edit_button.vue'; +import runnerQuery from '~/ci/runner/graphql/show/runner.query.graphql'; +import GroupRunnerShowApp from '~/ci/runner/group_runner_show/group_runner_show_app.vue'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; + +import { runnerData } from '../mock_data'; + +jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility'); + +const mockRunner = runnerData.data.runner; +const mockRunnerGraphqlId = mockRunner.id; +const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; +const mockRunnersPath = '/groups/group1/-/runners'; +const mockEditGroupRunnerPath = `/groups/group1/-/runners/${mockRunnerId}/edit`; + +Vue.use(VueApollo); + +describe('GroupRunnerShowApp', () => { + let wrapper; + let mockRunnerQuery; + + const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); + const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); + const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); + const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + + const mockRunnerQueryResult = (runner = {}) => { + mockRunnerQuery = jest.fn().mockResolvedValue({ + data: { + runner: { ...mockRunner, ...runner }, + }, + }); + }; + + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(GroupRunnerShowApp, { + apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]), + propsData: { + runnerId: mockRunnerId, + runnersPath: mockRunnersPath, + editGroupRunnerPath: mockEditGroupRunnerPath, + ...props, + }, + ...options, + }); + + return waitForPromises(); + }; + + afterEach(() => { + mockRunnerQuery.mockReset(); + wrapper.destroy(); + }); + + describe('When showing runner details', () => { + beforeEach(async () => { + mockRunnerQueryResult(); + + await createComponent({ mountFn: mountExtended }); + }); + + it('expect GraphQL ID to be requested', async () => { + expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); + }); + + it('displays the header', async () => { + expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); + }); + + it('displays edit, pause, delete buttons', async () => { + expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerPauseButton().exists()).toBe(true); + expect(findRunnerDeleteButton().exists()).toBe(true); + }); + + it('shows basic runner details', () => { + const expected = `Description My Runner + Last contact Never contacted + Version 1.0.0 + IP Address None + Executor None + Architecture None + Platform darwin + Configuration Runs untagged jobs + Maximum job timeout None + Token expiry + Runner authentication token expiration + Runner authentication tokens will expire based on a set interval. + They will automatically rotate once expired. Learn more + Never expires + Tags None`.replace(/\s+/g, ' '); + + expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); + }); + + it('renders runner details component', () => { + expect(findRunnerDetails().props('runner')).toEqual(mockRunner); + }); + + describe('when runner cannot be updated', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + userPermissions: { + ...mockRunner.userPermissions, + updateRunner: false, + }, + }); + + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('does not display edit and pause buttons', () => { + expect(findRunnerEditButton().exists()).toBe(false); + expect(findRunnerPauseButton().exists()).toBe(false); + }); + + it('displays delete button', () => { + expect(findRunnerDeleteButton().exists()).toBe(true); + }); + }); + + describe('when runner cannot be deleted', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + userPermissions: { + ...mockRunner.userPermissions, + deleteRunner: false, + }, + }); + + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('does not display delete button', () => { + expect(findRunnerDeleteButton().exists()).toBe(false); + }); + + it('displays edit and pause buttons', () => { + expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerPauseButton().exists()).toBe(true); + }); + }); + + describe('when runner is deleted', () => { + beforeEach(async () => { + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('redirects to the runner list page', () => { + findRunnerDeleteButton().vm.$emit('deleted', { message: 'Runner deleted' }); + + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: 'Runner deleted', + variant: VARIANT_SUCCESS, + }); + expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); + }); + }); + }); + + describe('When loading', () => { + it('does not show runner details', () => { + mockRunnerQueryResult(); + + createComponent(); + expect(findRunnerDetails().exists()).toBe(false); + }); + }); + + describe('When there is an error', () => { + beforeEach(async () => { + mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!')); + await createComponent(); + }); + + it('does not show runner details', () => { + expect(findRunnerDetails().exists()).toBe(false); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Error!'), + component: 'GroupRunnerShowApp', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalled(); + }); + }); +}); 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 new file mode 100644 index 00000000000..c3493b3c9fd --- /dev/null +++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js @@ -0,0 +1,492 @@ +import Vue, { nextTick } from 'vue'; +import { GlButton, GlLink, GlToast } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { + extendedWrapper, + shallowMountExtended, + mountExtended, +} from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { updateHistory } from '~/lib/utils/url_utility'; +import { upgradeStatusTokenConfig } from 'ee_else_ce/ci/runner/components/search_tokens/upgrade_status_token_config'; +import { createLocalState } from '~/ci/runner/graphql/list/local_state'; + +import RunnerTypeTabs from '~/ci/runner/components/runner_type_tabs.vue'; +import RunnerFilteredSearchBar from '~/ci/runner/components/runner_filtered_search_bar.vue'; +import RunnerList from '~/ci/runner/components/runner_list.vue'; +import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue'; +import RunnerStats from '~/ci/runner/components/stat/runner_stats.vue'; +import RunnerActionsCell from '~/ci/runner/components/cells/runner_actions_cell.vue'; +import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue'; +import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; +import RunnerMembershipToggle from '~/ci/runner/components/runner_membership_toggle.vue'; + +import { + CREATED_ASC, + CREATED_DESC, + DEFAULT_SORT, + I18N_STATUS_ONLINE, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + INSTANCE_TYPE, + GROUP_TYPE, + PARAM_KEY_PAUSED, + PARAM_KEY_STATUS, + PARAM_KEY_TAG, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, + MEMBERSHIP_ALL_AVAILABLE, + MEMBERSHIP_DESCENDANTS, + RUNNER_PAGE_SIZE, + I18N_EDIT, +} from '~/ci/runner/constants'; +import groupRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners.query.graphql'; +import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql'; +import GroupRunnersApp from '~/ci/runner/group_runners/group_runners_app.vue'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { + groupRunnersData, + groupRunnersDataPaginated, + groupRunnersCountData, + onlineContactTimeoutSecs, + staleTimeoutSecs, + 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('~/ci/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +describe('GroupRunnersApp', () => { + let wrapper; + + const findRunnerStats = () => wrapper.findComponent(RunnerStats); + const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); + const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); + const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); + const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); + const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`)); + const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); + const findRunnerPaginationNext = () => findRunnerPagination().findByText(s__('Pagination|Next')); + const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + const findRunnerMembershipToggle = () => wrapper.findComponent(RunnerMembershipToggle); + + const createComponent = ({ + props = {}, + provide = {}, + mountFn = shallowMountExtended, + ...options + } = {}) => { + const { cacheConfig, localMutations } = createLocalState(); + + const handlers = [ + [groupRunnersQuery, mockGroupRunnersHandler], + [groupRunnersCountQuery, mockGroupRunnersCountHandler], + ]; + + wrapper = mountFn(GroupRunnersApp, { + apolloProvider: createMockApollo(handlers, {}, cacheConfig), + propsData: { + registrationToken: mockRegistrationToken, + groupFullPath: mockGroupFullPath, + groupRunnersLimitedCount: mockGroupRunnersCount, + ...props, + }, + provide: { + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, + ...provide, + }, + ...options, + }); + + return waitForPromises(); + }; + + beforeEach(() => { + mockGroupRunnersHandler.mockResolvedValue(groupRunnersData); + mockGroupRunnersCountHandler.mockResolvedValue(groupRunnersCountData); + }); + + afterEach(() => { + mockGroupRunnersHandler.mockReset(); + mockGroupRunnersCountHandler.mockReset(); + wrapper.destroy(); + }); + + it('shows the runner tabs with a runner count for each type', async () => { + await createComponent({ mountFn: mountExtended }); + + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( + `All ${mockGroupRunnersCount} Group ${mockGroupRunnersCount} Project ${mockGroupRunnersCount}`, + ); + }); + + it('shows the runner setup instructions', () => { + createComponent(); + + expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); + }); + + describe('show all available runners toggle', () => { + it('shows the membership toggle', () => { + createComponent(); + expect(findRunnerMembershipToggle().exists()).toBe(true); + }); + + it('sets the membership toggle', () => { + setWindowLocation(`?membership[]=${MEMBERSHIP_ALL_AVAILABLE}`); + + createComponent(); + + expect(findRunnerMembershipToggle().props('value')).toBe(MEMBERSHIP_ALL_AVAILABLE); + }); + + it('requests filter', async () => { + createComponent(); + findRunnerMembershipToggle().vm.$emit('input', MEMBERSHIP_ALL_AVAILABLE); + + await waitForPromises(); + + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + membership: MEMBERSHIP_ALL_AVAILABLE, + }), + ); + }); + }); + + it('shows total runner counts', async () => { + await createComponent({ mountFn: mountExtended }); + + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, + groupFullPath: mockGroupFullPath, + }); + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + status: STATUS_OFFLINE, + membership: MEMBERSHIP_DESCENDANTS, + groupFullPath: mockGroupFullPath, + }); + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + status: STATUS_STALE, + membership: MEMBERSHIP_DESCENDANTS, + groupFullPath: mockGroupFullPath, + }); + + const text = findRunnerStats().text(); + expect(text).toContain(`${I18N_STATUS_ONLINE} ${mockGroupRunnersCount}`); + expect(text).toContain(`${I18N_STATUS_OFFLINE} ${mockGroupRunnersCount}`); + expect(text).toContain(`${I18N_STATUS_STALE} ${mockGroupRunnersCount}`); + }); + + it('shows the runners list', async () => { + await createComponent(); + + const runners = findRunnerList().props('runners'); + expect(runners).toEqual(mockGroupRunnersEdges.map(({ node }) => node)); + }); + + it('requests the runners with group path and no other filters', async () => { + await createComponent(); + + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: undefined, + type: undefined, + membership: MEMBERSHIP_DESCENDANTS, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('sets tokens in the filtered search', () => { + createComponent(); + + const tokens = findRunnerFilteredSearchBar().props('tokens'); + + expect(tokens).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_PAUSED, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + suggestionsDisabled: true, + }), + upgradeStatusTokenConfig, + ]); + }); + + describe('Single runner row', () => { + let showToast; + + const { webUrl, editUrl, node } = mockGroupRunnersEdges[0]; + const { id: graphqlId, shortSha } = node; + const id = getIdFromGraphQLId(graphqlId); + const COUNT_QUERIES = 6; // Smart queries that display a filtered count of runners + const FILTERED_COUNT_QUERIES = 6; // Smart queries that display a count of runners in tabs and single stats + + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); + }); + + it('view link is displayed correctly', () => { + const viewLink = findRunnerRow(id).findByTestId('td-summary').findComponent(GlLink); + + expect(viewLink.text()).toBe(`#${id} (${shortSha})`); + expect(viewLink.attributes('href')).toBe(webUrl); + }); + + it('edit link is displayed correctly', () => { + const editLink = findRunnerRow(id).findByTestId('td-actions').findComponent(GlButton); + + expect(editLink.attributes()).toMatchObject({ + 'aria-label': I18N_EDIT, + href: editUrl, + }); + }); + + it('When runner is paused or unpaused, some data is refetched', async () => { + expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); + + findRunnerActionsCell().vm.$emit('toggledPaused'); + + expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes( + COUNT_QUERIES + FILTERED_COUNT_QUERIES, + ); + + expect(showToast).toHaveBeenCalledTimes(0); + }); + + it('When runner is deleted, data is refetched and a toast message is shown', async () => { + findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Runner deleted'); + }); + }); + + describe('when a filter is preselected', () => { + beforeEach(async () => { + setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}`); + + await createComponent({ mountFn: mountExtended }); + }); + + it('sets the filters in the search bar', () => { + expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + runnerType: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, + filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], + sort: 'CREATED_DESC', + pagination: {}, + }); + }); + + it('requests the runners with filter parameters', () => { + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ONLINE, + type: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('fetches count results for requested status', () => { + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + type: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, + status: STATUS_ONLINE, + }); + }); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + + findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, + membership: MEMBERSHIP_DESCENDANTS, + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + sort: CREATED_ASC, + }); + + await nextTick(); + }); + + it('updates the browser url', () => { + expect(updateHistory).toHaveBeenLastCalledWith({ + title: expect.any(String), + url: expect.stringContaining('?status[]=ONLINE&sort=CREATED_ASC'), + }); + }); + + it('requests the runners with filters', () => { + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, + sort: CREATED_ASC, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('fetches count results for requested status', () => { + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, + }); + }); + }); + + it('when runners have not loaded, shows a loading state', () => { + createComponent(); + expect(findRunnerList().props('loading')).toBe(true); + expect(findRunnerPagination().attributes('disabled')).toBe('true'); + }); + + it('runners can be deleted in bulk', () => { + createComponent(); + expect(findRunnerList().props('checkable')).toBe(true); + }); + + describe('when no runners are found', () => { + beforeEach(async () => { + mockGroupRunnersHandler.mockResolvedValue({ + data: { + group: { + id: '1', + runners: { + edges: [], + pageInfo: emptyPageInfo, + }, + }, + }, + }); + await createComponent(); + }); + + it('shows no errors', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('shows an empty state', async () => { + expect(findRunnerListEmptyState().exists()).toBe(true); + }); + }); + + describe('when runners query fails', () => { + beforeEach(async () => { + mockGroupRunnersHandler.mockRejectedValue(new Error('Error!')); + await createComponent(); + }); + + it('error is shown to the user', async () => { + expect(createAlert).toHaveBeenCalledTimes(1); + }); + + it('error is reported to sentry', async () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Error!'), + component: 'GroupRunnersApp', + }); + }); + }); + + describe('Pagination', () => { + const { pageInfo } = groupRunnersDataPaginated.data.group.runners; + + beforeEach(async () => { + mockGroupRunnersHandler.mockResolvedValue(groupRunnersDataPaginated); + + await createComponent({ mountFn: mountExtended }); + }); + + it('passes the page info', () => { + expect(findRunnerPagination().props('pageInfo')).toEqual(pageInfo); + }); + + it('navigates to the next page', async () => { + await findRunnerPaginationNext().trigger('click'); + + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + membership: MEMBERSHIP_DESCENDANTS, + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: pageInfo.endCursor, + }); + }); + }); + + describe('when user has permission to register group runner', () => { + beforeEach(() => { + createComponent({ + propsData: { + registrationToken: mockRegistrationToken, + groupFullPath: mockGroupFullPath, + groupRunnersLimitedCount: mockGroupRunnersCount, + }, + }); + }); + + it('shows the register group runner button', () => { + expect(findRegistrationDropdown().exists()).toBe(true); + }); + }); + + describe('when user has no permission to register group runner', () => { + beforeEach(() => { + createComponent({ + propsData: { + registrationToken: null, + groupFullPath: mockGroupFullPath, + groupRunnersLimitedCount: mockGroupRunnersCount, + }, + }); + }); + + it('does not show the register group runner button', () => { + expect(findRegistrationDropdown().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/runner/local_storage_alert/save_alert_to_local_storage_spec.js b/spec/frontend/ci/runner/local_storage_alert/save_alert_to_local_storage_spec.js new file mode 100644 index 00000000000..b34ef01eeed --- /dev/null +++ b/spec/frontend/ci/runner/local_storage_alert/save_alert_to_local_storage_spec.js @@ -0,0 +1,24 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; +import { LOCAL_STORAGE_ALERT_KEY } from '~/ci/runner/local_storage_alert/constants'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +const mockAlert = { message: 'Message!' }; + +describe('saveAlertToLocalStorage', () => { + useLocalStorageSpy(); + + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); + }); + + it('saves message to local storage', () => { + saveAlertToLocalStorage(mockAlert); + + expect(localStorage.setItem).toHaveBeenCalledTimes(1); + expect(localStorage.setItem).toHaveBeenCalledWith( + LOCAL_STORAGE_ALERT_KEY, + JSON.stringify(mockAlert), + ); + }); +}); 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 new file mode 100644 index 00000000000..03908891cfd --- /dev/null +++ b/spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js @@ -0,0 +1,40 @@ +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'; + +jest.mock('~/flash'); + +describe('showAlertFromLocalStorage', () => { + useLocalStorageSpy(); + + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); + }); + + it('retrieves message from local storage and displays it', async () => { + const mockAlert = { message: 'Message!' }; + + localStorage.getItem.mockReturnValueOnce(JSON.stringify(mockAlert)); + + await showAlertFromLocalStorage(); + + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith(mockAlert); + + expect(localStorage.removeItem).toHaveBeenCalledTimes(1); + expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_ALERT_KEY); + }); + + it.each(['not a json string', null])('does not fail when stored message is %o', async (item) => { + localStorage.getItem.mockReturnValueOnce(item); + + await showAlertFromLocalStorage(); + + expect(createAlert).not.toHaveBeenCalled(); + + expect(localStorage.removeItem).toHaveBeenCalledTimes(1); + expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_ALERT_KEY); + }); +}); diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js new file mode 100644 index 00000000000..eff5abc21b5 --- /dev/null +++ b/spec/frontend/ci/runner/mock_data.js @@ -0,0 +1,322 @@ +// Fixtures generated by: spec/frontend/fixtures/runner.rb + +// Show runner queries +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'; +import runnerJobsData from 'test_fixtures/graphql/ci/runner/show/runner_jobs.query.graphql.json'; + +// 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'; + +const emptyPageInfo = { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', +}; + +// Other mock data + +// Mock searches and their corresponding urls +export const mockSearchExamples = [ + { + name: 'a default query', + urlQuery: '', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, + isDefault: true, + }, + { + name: 'a single status', + urlQuery: '?status[]=ACTIVE', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + status: 'ACTIVE', + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'a single term text search', + urlQuery: '?search=something', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + ], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + search: 'something', + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'a two terms text search', + urlQuery: '?search=something+else', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + { + type: 'filtered-search-term', + value: { data: 'else' }, + }, + ], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + search: 'something else', + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'single instance type', + urlQuery: '?runner_type[]=INSTANCE_TYPE', + search: { + runnerType: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + type: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'multiple runner status', + urlQuery: '?status[]=ACTIVE&status[]=PAUSED', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'status', value: { data: 'PAUSED', operator: '=' } }, + ], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + status: 'ACTIVE', + membership: DEFAULT_MEMBERSHIP, + 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', + search: { + runnerType: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, + filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + pagination: {}, + sort: 'CREATED_ASC', + }, + graphqlVariables: { + status: 'ACTIVE', + type: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_ASC', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'a tag', + urlQuery: '?tag[]=tag-1', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + tagList: ['tag-1'], + first: 20, + sort: 'CREATED_DESC', + }, + }, + { + name: 'two tags', + urlQuery: '?tag[]=tag-1&tag[]=tag-2', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [ + { type: 'tag', value: { data: 'tag-1', operator: '=' } }, + { type: 'tag', value: { data: 'tag-2', operator: '=' } }, + ], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + tagList: ['tag-1', 'tag-2'], + first: 20, + sort: 'CREATED_DESC', + }, + }, + { + name: 'the next page', + urlQuery: '?after=AFTER_CURSOR', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: { after: 'AFTER_CURSOR' }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + after: 'AFTER_CURSOR', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'the previous page', + urlQuery: '?before=BEFORE_CURSOR', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: { before: 'BEFORE_CURSOR' }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + before: 'BEFORE_CURSOR', + last: RUNNER_PAGE_SIZE, + }, + }, + { + 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', + search: { + runnerType: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'tag', value: { data: 'tag-1', operator: '=' } }, + { type: 'tag', value: { data: 'tag-2', operator: '=' } }, + ], + pagination: { after: 'AFTER_CURSOR' }, + sort: 'CREATED_ASC', + }, + graphqlVariables: { + status: 'ACTIVE', + type: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, + tagList: ['tag-1', 'tag-2'], + sort: 'CREATED_ASC', + after: 'AFTER_CURSOR', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'paused runners', + urlQuery: '?paused[]=true', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + paused: true, + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'active runners', + urlQuery: '?paused[]=false', + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + paused: false, + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, + }, +]; + +export const onlineContactTimeoutSecs = 2 * 60 * 60; +export const staleTimeoutSecs = 7889238; // Ruby's `3.months` + +export const emptyStateSvgPath = 'emptyStateSvgPath.svg'; +export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg'; + +export { + allRunnersData, + allRunnersDataPaginated, + runnersCountData, + groupRunnersData, + groupRunnersDataPaginated, + groupRunnersCountData, + emptyPageInfo, + runnerData, + runnerWithGroupData, + runnerProjectsData, + runnerJobsData, + runnerFormData, +}; 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 new file mode 100644 index 00000000000..a9369a5e626 --- /dev/null +++ b/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js @@ -0,0 +1,114 @@ +import { mount, shallowMount } from '@vue/test-utils'; +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 { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerHeader from '~/ci/runner/components/runner_header.vue'; +import RunnerUpdateForm from '~/ci/runner/components/runner_update_form.vue'; +import runnerFormQuery from '~/ci/runner/graphql/edit/runner_form.query.graphql'; +import RunnerEditApp from '~/ci/runner/runner_edit/runner_edit_app.vue'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { I18N_STATUS_NEVER_CONTACTED, I18N_INSTANCE_TYPE } from '~/ci/runner/constants'; + +import { runnerFormData } from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); + +const mockRunner = runnerFormData.data.runner; +const mockRunnerGraphqlId = mockRunner.id; +const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; +const mockRunnerPath = `/admin/runners/${mockRunnerId}`; + +Vue.use(VueApollo); + +describe('RunnerEditApp', () => { + let wrapper; + let mockRunnerQuery; + + const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + const findRunnerUpdateForm = () => wrapper.findComponent(RunnerUpdateForm); + + const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { + wrapper = mountFn(RunnerEditApp, { + apolloProvider: createMockApollo([[runnerFormQuery, mockRunnerQuery]]), + propsData: { + runnerId: mockRunnerId, + runnerPath: mockRunnerPath, + ...props, + }, + }); + + return waitForPromises(); + }; + + beforeEach(() => { + mockRunnerQuery = jest.fn().mockResolvedValue(runnerFormData); + }); + + afterEach(() => { + mockRunnerQuery.mockReset(); + wrapper.destroy(); + }); + + it('expect GraphQL ID to be requested', async () => { + await createComponentWithApollo(); + + expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); + }); + + it('displays the runner id and creation date', async () => { + await createComponentWithApollo({ mountFn: mount }); + + expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); + expect(findRunnerHeader().text()).toContain('created'); + }); + + it('displays the runner type and status', async () => { + await createComponentWithApollo({ mountFn: mount }); + + expect(findRunnerHeader().text()).toContain(I18N_STATUS_NEVER_CONTACTED); + expect(findRunnerHeader().text()).toContain(I18N_INSTANCE_TYPE); + }); + + it('displays a loading runner form', () => { + createComponentWithApollo(); + + expect(findRunnerUpdateForm().props()).toMatchObject({ + runner: null, + loading: true, + runnerPath: mockRunnerPath, + }); + }); + + it('displays the runner form', async () => { + await createComponentWithApollo(); + + expect(findRunnerUpdateForm().props()).toMatchObject({ + loading: false, + runnerPath: mockRunnerPath, + }); + expect(findRunnerUpdateForm().props('runner')).toEqual(mockRunner); + }); + + describe('When there is an error', () => { + beforeEach(async () => { + mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!')); + await createComponentWithApollo(); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Error!'), + component: 'RunnerEditApp', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/ci/runner/runner_search_utils_spec.js b/spec/frontend/ci/runner/runner_search_utils_spec.js new file mode 100644 index 00000000000..1db8fa1829b --- /dev/null +++ b/spec/frontend/ci/runner/runner_search_utils_spec.js @@ -0,0 +1,138 @@ +import { + searchValidator, + updateOutdatedUrl, + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, + isSearchFiltered, +} from 'ee_else_ce/ci/runner/runner_search_utils'; +import { mockSearchExamples } from './mock_data'; + +describe('search_params.js', () => { + describe('searchValidator', () => { + mockSearchExamples.forEach(({ name, search }) => { + it(`Validates ${name} as a search object`, () => { + expect(searchValidator(search)).toBe(true); + }); + }); + }); + + describe('updateOutdatedUrl', () => { + it('returns null for urls that do not need updating', () => { + expect(updateOutdatedUrl('http://test.host/')).toBe(null); + expect(updateOutdatedUrl('http://test.host/?a=b')).toBe(null); + }); + + it.each` + query | updatedQuery + ${'status[]=ACTIVE'} | ${'paused[]=false'} + ${'status[]=ACTIVE&a=b'} | ${'a=b&paused[]=false'} + ${'status[]=ACTIVE'} | ${'paused[]=false'} + ${'status[]=PAUSED'} | ${'paused[]=true'} + ${'page=2&after=AFTER'} | ${'after=AFTER'} + ${'page=2&before=BEFORE'} | ${'before=BEFORE'} + ${'status[]=PAUSED&page=2&after=AFTER'} | ${'after=AFTER&paused[]=true'} + `('updates "$query" to "$updatedQuery"', ({ query, updatedQuery }) => { + const mockUrl = 'http://test.host/admin/runners?'; + + expect(updateOutdatedUrl(`${mockUrl}${query}`)).toBe(`${mockUrl}${updatedQuery}`); + }); + }); + + describe('fromUrlQueryToSearch', () => { + mockSearchExamples.forEach(({ name, urlQuery, search }) => { + it(`Converts ${name} to a search object`, () => { + expect(fromUrlQueryToSearch(urlQuery)).toEqual(search); + }); + }); + + it('When search params appear as array, they are concatenated', () => { + expect(fromUrlQueryToSearch('?search[]=my&search[]=text').filters).toEqual([ + { type: 'filtered-search-term', value: { data: 'my' } }, + { type: 'filtered-search-term', value: { data: 'text' } }, + ]); + }); + }); + + describe('fromSearchToUrl', () => { + mockSearchExamples.forEach(({ name, urlQuery, search }) => { + it(`Converts ${name} to a url`, () => { + expect(fromSearchToUrl(search)).toBe(`http://test.host/${urlQuery}`); + }); + }); + + it.each([ + 'http://test.host/?status[]=ACTIVE', + 'http://test.host/?runner_type[]=INSTANCE_TYPE', + 'http://test.host/?search=my_text', + ])('When a filter is removed, it is removed from the URL', (initalUrl) => { + const search = { filters: [], sort: 'CREATED_DESC' }; + const expectedUrl = `http://test.host/`; + + expect(fromSearchToUrl(search, initalUrl)).toBe(expectedUrl); + }); + + 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 expectedUrl = `http://test.host/?unrelated=UNRELATED`; + + expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl); + }); + }); + + describe('fromSearchToVariables', () => { + mockSearchExamples.forEach(({ name, graphqlVariables, search }) => { + it(`Converts ${name} to a GraphQL query variables object`, () => { + expect(fromSearchToVariables(search)).toEqual(graphqlVariables); + }); + }); + + it('When a search param is empty, it gets removed', () => { + expect( + fromSearchToVariables({ + filters: [ + { + type: 'filtered-search-term', + value: { data: '' }, + }, + ], + }), + ).toMatchObject({ + search: '', + }); + + expect( + fromSearchToVariables({ + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + { + type: 'filtered-search-term', + value: { data: '' }, + }, + ], + }), + ).toMatchObject({ + search: 'something', + }); + }); + }); + + describe('isSearchFiltered', () => { + mockSearchExamples.forEach(({ name, search, isDefault }) => { + it(`Given ${name}, evaluates to ${isDefault ? 'not ' : ''}filtered`, () => { + expect(isSearchFiltered(search)).toBe(!isDefault); + }); + }); + + it.each([null, undefined, {}])( + 'given a missing pagination, evaluates as not filtered', + (mockPagination) => { + expect(isSearchFiltered({ pagination: mockPagination })).toBe(false); + }, + ); + }); +}); diff --git a/spec/frontend/ci/runner/runner_update_form_utils_spec.js b/spec/frontend/ci/runner/runner_update_form_utils_spec.js new file mode 100644 index 00000000000..b2f7bbc49a9 --- /dev/null +++ b/spec/frontend/ci/runner/runner_update_form_utils_spec.js @@ -0,0 +1,96 @@ +import { ACCESS_LEVEL_NOT_PROTECTED } from '~/ci/runner/constants'; +import { + modelToUpdateMutationVariables, + runnerToModel, +} from '~/ci/runner/runner_update_form_utils'; + +const mockId = 'gid://gitlab/Ci::Runner/1'; +const mockDescription = 'Runner Desc.'; + +const mockRunner = { + id: mockId, + description: mockDescription, + maximumTimeout: 100, + accessLevel: ACCESS_LEVEL_NOT_PROTECTED, + active: true, + locked: true, + runUntagged: true, + tagList: ['tag-1', 'tag-2'], +}; + +const mockModel = { + ...mockRunner, + tagList: 'tag-1, tag-2', +}; + +describe('~/ci/runner/runner_update_form_utils', () => { + describe('runnerToModel', () => { + it('collects all model data', () => { + expect(runnerToModel(mockRunner)).toEqual(mockModel); + }); + + it('does not collect other data', () => { + const model = runnerToModel({ + ...mockRunner, + unrelated: 'unrelatedValue', + }); + + expect(model.unrelated).toEqual(undefined); + }); + + it('tag list defaults to an empty string', () => { + const model = runnerToModel({ + ...mockRunner, + tagList: undefined, + }); + + expect(model.tagList).toEqual(''); + }); + }); + + describe('modelToUpdateMutationVariables', () => { + it('collects all model data', () => { + expect(modelToUpdateMutationVariables(mockModel)).toEqual({ + input: { + ...mockRunner, + }, + }); + }); + + it('collects a nullable timeout from the model', () => { + const variables = modelToUpdateMutationVariables({ + ...mockModel, + maximumTimeout: '', + }); + + expect(variables).toEqual({ + input: { + ...mockRunner, + maximumTimeout: null, + }, + }); + }); + + it.each` + tagList | tagListInput + ${''} | ${[]} + ${'tag1, tag2'} | ${['tag1', 'tag2']} + ${'with spaces'} | ${['with spaces']} + ${',,,,, commas'} | ${['commas']} + ${'more ,,,,, commas'} | ${['more', 'commas']} + ${' trimmed , trimmed2 '} | ${['trimmed', 'trimmed2']} + `('collect tags separated by commas for "$value"', ({ tagList, tagListInput }) => { + const variables = modelToUpdateMutationVariables({ + ...mockModel, + tagList, + }); + + expect(variables).toEqual({ + input: { + ...mockRunner, + tagList: tagListInput, + }, + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/sentry_utils_spec.js b/spec/frontend/ci/runner/sentry_utils_spec.js new file mode 100644 index 00000000000..f7b689272ce --- /dev/null +++ b/spec/frontend/ci/runner/sentry_utils_spec.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; +import { captureException } from '~/ci/runner/sentry_utils'; + +jest.mock('@sentry/browser'); + +describe('~/ci/runner/sentry_utils', () => { + let mockSetTag; + + beforeEach(async () => { + mockSetTag = jest.fn(); + + Sentry.withScope.mockImplementation((fn) => { + const scope = { setTag: mockSetTag }; + fn(scope); + }); + }); + + describe('captureException', () => { + const mockError = new Error('Something went wrong!'); + + it('error is reported to sentry', () => { + captureException({ error: mockError }); + + expect(Sentry.withScope).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(mockError); + }); + + it('error is reported to sentry with a component name', () => { + const mockComponentName = 'MyComponent'; + + captureException({ error: mockError, component: mockComponentName }); + + expect(Sentry.withScope).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(mockError); + + expect(mockSetTag).toHaveBeenCalledWith('vue_component', mockComponentName); + }); + }); +}); diff --git a/spec/frontend/ci/runner/utils_spec.js b/spec/frontend/ci/runner/utils_spec.js new file mode 100644 index 00000000000..56b758f00e4 --- /dev/null +++ b/spec/frontend/ci/runner/utils_spec.js @@ -0,0 +1,85 @@ +import { + formatJobCount, + tableField, + getPaginationVariables, + parseInterval, +} from '~/ci/runner/utils'; + +describe('~/ci/runner/utils', () => { + describe('formatJobCount', () => { + it('formats a number', () => { + expect(formatJobCount(1)).toBe('1'); + expect(formatJobCount(99)).toBe('99'); + }); + + it('formats a large count', () => { + expect(formatJobCount(1000)).toBe('1,000'); + expect(formatJobCount(1001)).toBe('1,000+'); + }); + + it('returns an empty string for non-numeric values', () => { + expect(formatJobCount(undefined)).toBe(''); + expect(formatJobCount(null)).toBe(''); + expect(formatJobCount('number')).toBe(''); + }); + }); + + describe('tableField', () => { + it('a field with options', () => { + expect(tableField({ key: 'name' })).toEqual({ + key: 'name', + label: '', + tdAttr: { 'data-testid': 'td-name' }, + thClass: expect.any(Array), + }); + }); + + it('a field with a label', () => { + const label = 'A field name'; + + expect(tableField({ key: 'name', label })).toMatchObject({ + label, + }); + }); + + it('a field with custom classes', () => { + const mockClasses = ['foo', 'bar']; + + expect(tableField({ thClasses: mockClasses })).toMatchObject({ + thClass: expect.arrayContaining(mockClasses), + }); + }); + + it('a field with custom options', () => { + expect(tableField({ foo: 'bar' })).toMatchObject({ foo: 'bar' }); + }); + }); + + describe('getPaginationVariables', () => { + const after = 'AFTER_CURSOR'; + const before = 'BEFORE_CURSOR'; + + it.each` + case | pagination | pageSize | variables + ${'next page'} | ${{ after }} | ${undefined} | ${{ after, first: 10 }} + ${'prev page'} | ${{ before }} | ${undefined} | ${{ before, last: 10 }} + ${'first page'} | ${{}} | ${undefined} | ${{ first: 10 }} + ${'next page with N items'} | ${{ after }} | ${20} | ${{ after, first: 20 }} + ${'prev page with N items'} | ${{ before }} | ${20} | ${{ before, last: 20 }} + ${'first page with N items'} | ${{}} | ${20} | ${{ first: 20 }} + `('navigates to $case', ({ pagination, pageSize, variables }) => { + expect(getPaginationVariables(pagination, pageSize)).toEqual(variables); + }); + }); + + describe('parseInterval', () => { + it.each` + case | argument | returnValue + ${'parses integer'} | ${'86400'} | ${86400} + ${'returns null for undefined'} | ${undefined} | ${null} + ${'returns null for null'} | ${null} | ${null} + `('$case', ({ argument, returnValue }) => { + expect(parseInterval(argument)).toStrictEqual(returnValue); + }); + }); +}); |