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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/ci')
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js38
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js25
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js280
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js64
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js42
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run_spec.js43
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner_spec.js40
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js41
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/pipeline_schedules_table_spec.js39
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js44
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js40
-rw-r--r--spec/frontend/ci/pipeline_schedules/mock_data.js62
-rw-r--r--spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js266
-rw-r--r--spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js473
-rw-r--r--spec/frontend/ci/runner/components/__snapshots__/runner_status_popover_spec.js.snap3
-rw-r--r--spec/frontend/ci/runner/components/cells/link_cell_spec.js72
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_actions_cell_spec.js138
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_owner_cell_spec.js111
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_stacked_summary_cell_spec.js164
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js77
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_summary_field_spec.js49
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js198
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js209
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_token_spec.js62
-rw-r--r--spec/frontend/ci/runner/components/runner_assigned_item_spec.js68
-rw-r--r--spec/frontend/ci/runner/components/runner_bulk_delete_checkbox_spec.js140
-rw-r--r--spec/frontend/ci/runner/components/runner_bulk_delete_spec.js295
-rw-r--r--spec/frontend/ci/runner/components/runner_delete_button_spec.js275
-rw-r--r--spec/frontend/ci/runner/components/runner_delete_modal_spec.js60
-rw-r--r--spec/frontend/ci/runner/components/runner_details_spec.js130
-rw-r--r--spec/frontend/ci/runner/components/runner_edit_button_spec.js41
-rw-r--r--spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js188
-rw-r--r--spec/frontend/ci/runner/components/runner_groups_spec.js67
-rw-r--r--spec/frontend/ci/runner/components/runner_header_spec.js124
-rw-r--r--spec/frontend/ci/runner/components/runner_jobs_spec.js155
-rw-r--r--spec/frontend/ci/runner/components/runner_jobs_table_spec.js137
-rw-r--r--spec/frontend/ci/runner/components/runner_list_empty_state_spec.js103
-rw-r--r--spec/frontend/ci/runner/components/runner_list_spec.js231
-rw-r--r--spec/frontend/ci/runner/components/runner_membership_toggle_spec.js57
-rw-r--r--spec/frontend/ci/runner/components/runner_pagination_spec.js115
-rw-r--r--spec/frontend/ci/runner/components/runner_pause_button_spec.js263
-rw-r--r--spec/frontend/ci/runner/components/runner_paused_badge_spec.js46
-rw-r--r--spec/frontend/ci/runner/components/runner_projects_spec.js251
-rw-r--r--spec/frontend/ci/runner/components/runner_status_badge_spec.js133
-rw-r--r--spec/frontend/ci/runner/components/runner_status_popover_spec.js36
-rw-r--r--spec/frontend/ci/runner/components/runner_tag_spec.js79
-rw-r--r--spec/frontend/ci/runner/components/runner_tags_spec.js54
-rw-r--r--spec/frontend/ci/runner/components/runner_type_badge_spec.js66
-rw-r--r--spec/frontend/ci/runner/components/runner_type_tabs_spec.js214
-rw-r--r--spec/frontend/ci/runner/components/runner_update_form_spec.js288
-rw-r--r--spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js208
-rw-r--r--spec/frontend/ci/runner/components/stat/runner_count_spec.js148
-rw-r--r--spec/frontend/ci/runner/components/stat/runner_single_stat_spec.js61
-rw-r--r--spec/frontend/ci/runner/components/stat/runner_stats_spec.js81
-rw-r--r--spec/frontend/ci/runner/graphql/local_state_spec.js167
-rw-r--r--spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js215
-rw-r--r--spec/frontend/ci/runner/group_runners/group_runners_app_spec.js492
-rw-r--r--spec/frontend/ci/runner/local_storage_alert/save_alert_to_local_storage_spec.js24
-rw-r--r--spec/frontend/ci/runner/local_storage_alert/show_alert_from_local_storage_spec.js40
-rw-r--r--spec/frontend/ci/runner/mock_data.js322
-rw-r--r--spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js114
-rw-r--r--spec/frontend/ci/runner/runner_search_utils_spec.js138
-rw-r--r--spec/frontend/ci/runner/runner_update_form_utils_spec.js96
-rw-r--r--spec/frontend/ci/runner/sentry_utils_spec.js39
-rw-r--r--spec/frontend/ci/runner/utils_spec.js85
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);
+ });
+ });
+});