summaryrefslogtreecommitdiff
path: root/spec/frontend/runner/components
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/runner/components')
-rw-r--r--spec/frontend/runner/components/cells/runner_actions_cell_spec.js201
-rw-r--r--spec/frontend/runner/components/cells/runner_name_cell_spec.js42
-rw-r--r--spec/frontend/runner/components/cells/runner_type_cell_spec.js48
-rw-r--r--spec/frontend/runner/components/runner_filtered_search_bar_spec.js137
-rw-r--r--spec/frontend/runner/components/runner_list_spec.js130
-rw-r--r--spec/frontend/runner/components/runner_manual_setup_help_spec.js84
-rw-r--r--spec/frontend/runner/components/runner_pagination_spec.js160
-rw-r--r--spec/frontend/runner/components/runner_tags_spec.js64
-rw-r--r--spec/frontend/runner/components/runner_type_alert_spec.js61
-rw-r--r--spec/frontend/runner/components/runner_type_badge_spec.js10
-rw-r--r--spec/frontend/runner/components/runner_type_help_spec.js32
-rw-r--r--spec/frontend/runner/components/runner_update_form_spec.js263
12 files changed, 1230 insertions, 2 deletions
diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
new file mode 100644
index 00000000000..12651a82a0c
--- /dev/null
+++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
@@ -0,0 +1,201 @@
+import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
+import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql';
+import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
+import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
+
+const mockId = '1';
+
+const getRunnersQueryName = getRunnersQuery.definitions[0].name.value;
+
+describe('RunnerTypeCell', () => {
+ let wrapper;
+ let mutate;
+
+ const findEditBtn = () => wrapper.findByTestId('edit-runner');
+ const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner');
+ const findDeleteBtn = () => wrapper.findByTestId('delete-runner');
+
+ const createComponent = ({ active = true } = {}, options) => {
+ wrapper = extendedWrapper(
+ shallowMount(RunnerActionCell, {
+ propsData: {
+ runner: {
+ id: `gid://gitlab/Ci::Runner/${mockId}`,
+ active,
+ },
+ },
+ mocks: {
+ $apollo: {
+ mutate,
+ },
+ },
+ ...options,
+ }),
+ );
+ };
+
+ beforeEach(() => {
+ mutate = jest.fn();
+ });
+
+ afterEach(() => {
+ mutate.mockReset();
+ wrapper.destroy();
+ });
+
+ it('Displays the runner edit link with the correct href', () => {
+ createComponent();
+
+ expect(findEditBtn().attributes('href')).toBe('/admin/runners/1');
+ });
+
+ describe.each`
+ state | label | icon | isActive | newActiveValue
+ ${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false}
+ ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true}
+ `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => {
+ beforeEach(() => {
+ mutate.mockResolvedValue({
+ data: {
+ runnerUpdate: {
+ runner: {
+ id: `gid://gitlab/Ci::Runner/1`,
+ __typename: 'CiRunner',
+ },
+ },
+ },
+ });
+
+ createComponent({ active: isActive });
+ });
+
+ it(`Displays a ${icon} button`, () => {
+ expect(findToggleActiveBtn().props('loading')).toBe(false);
+ expect(findToggleActiveBtn().props('icon')).toBe(icon);
+ expect(findToggleActiveBtn().attributes('title')).toBe(label);
+ expect(findToggleActiveBtn().attributes('aria-label')).toBe(label);
+ });
+
+ it(`After clicking the ${icon} button, the button has a loading state`, async () => {
+ await findToggleActiveBtn().vm.$emit('click');
+
+ expect(findToggleActiveBtn().props('loading')).toBe(true);
+ });
+
+ it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => {
+ await findToggleActiveBtn().vm.$emit('click');
+
+ expect(findToggleActiveBtn().attributes('title')).toBe('');
+ expect(findToggleActiveBtn().attributes('aria-label')).toBe('');
+ });
+
+ describe(`When clicking on the ${icon} button`, () => {
+ beforeEach(async () => {
+ await findToggleActiveBtn().vm.$emit('click');
+ await waitForPromises();
+ });
+
+ it(`The apollo mutation to set active to ${newActiveValue} is called`, () => {
+ expect(mutate).toHaveBeenCalledTimes(1);
+ expect(mutate).toHaveBeenCalledWith({
+ mutation: runnerUpdateMutation,
+ variables: {
+ input: {
+ id: `gid://gitlab/Ci::Runner/${mockId}`,
+ active: newActiveValue,
+ },
+ },
+ });
+ });
+
+ it('The button does not have a loading state', () => {
+ expect(findToggleActiveBtn().props('loading')).toBe(false);
+ });
+ });
+ });
+
+ describe('When the user clicks a runner', () => {
+ beforeEach(() => {
+ createComponent();
+
+ mutate.mockResolvedValue({
+ data: {
+ runnerDelete: {
+ runner: {
+ id: `gid://gitlab/Ci::Runner/1`,
+ __typename: 'CiRunner',
+ },
+ },
+ },
+ });
+
+ jest.spyOn(window, 'confirm');
+ });
+
+ describe('When the user confirms deletion', () => {
+ beforeEach(async () => {
+ window.confirm.mockReturnValue(true);
+ await findDeleteBtn().vm.$emit('click');
+ });
+
+ it('The user sees a confirmation alert', async () => {
+ expect(window.confirm).toHaveBeenCalledTimes(1);
+ expect(window.confirm).toHaveBeenCalledWith(expect.any(String));
+ });
+
+ it('The delete mutation is called correctly', () => {
+ expect(mutate).toHaveBeenCalledTimes(1);
+ expect(mutate).toHaveBeenCalledWith({
+ mutation: deleteRunnerMutation,
+ variables: {
+ input: {
+ id: `gid://gitlab/Ci::Runner/${mockId}`,
+ },
+ },
+ awaitRefetchQueries: true,
+ refetchQueries: [getRunnersQueryName],
+ });
+ });
+
+ it('The delete button does not have a loading state', () => {
+ expect(findDeleteBtn().props('loading')).toBe(false);
+ expect(findDeleteBtn().attributes('title')).toBe('Remove');
+ });
+
+ it('After the delete button is clicked, loading state is shown', async () => {
+ await findDeleteBtn().vm.$emit('click');
+
+ expect(findDeleteBtn().props('loading')).toBe(true);
+ });
+
+ it('After the delete button is clicked, stale tooltip is removed', async () => {
+ await findDeleteBtn().vm.$emit('click');
+
+ expect(findDeleteBtn().attributes('title')).toBe('');
+ });
+ });
+
+ describe('When the user does not confirm deletion', () => {
+ beforeEach(async () => {
+ window.confirm.mockReturnValue(false);
+ await findDeleteBtn().vm.$emit('click');
+ });
+
+ it('The user sees a confirmation alert', () => {
+ expect(window.confirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('The delete mutation is not called', () => {
+ expect(mutate).toHaveBeenCalledTimes(0);
+ });
+
+ it('The delete button does not have a loading state', () => {
+ expect(findDeleteBtn().props('loading')).toBe(false);
+ expect(findDeleteBtn().attributes('title')).toBe('Remove');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/cells/runner_name_cell_spec.js b/spec/frontend/runner/components/cells/runner_name_cell_spec.js
new file mode 100644
index 00000000000..26055fc0faf
--- /dev/null
+++ b/spec/frontend/runner/components/cells/runner_name_cell_spec.js
@@ -0,0 +1,42 @@
+import { GlLink } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import RunnerNameCell from '~/runner/components/cells/runner_name_cell.vue';
+
+const mockId = '1';
+const mockShortSha = '2P6oDVDm';
+const mockDescription = 'runner-1';
+
+describe('RunnerTypeCell', () => {
+ let wrapper;
+
+ const findLink = () => wrapper.findComponent(GlLink);
+
+ const createComponent = () => {
+ wrapper = mount(RunnerNameCell, {
+ propsData: {
+ runner: {
+ id: `gid://gitlab/Ci::Runner/${mockId}`,
+ shortSha: mockShortSha,
+ description: mockDescription,
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Displays the runner link with id and short token', () => {
+ expect(findLink().text()).toBe(`#${mockId} (${mockShortSha})`);
+ expect(findLink().attributes('href')).toBe(`/admin/runners/${mockId}`);
+ });
+
+ it('Displays the runner description', () => {
+ expect(wrapper.text()).toContain(mockDescription);
+ });
+});
diff --git a/spec/frontend/runner/components/cells/runner_type_cell_spec.js b/spec/frontend/runner/components/cells/runner_type_cell_spec.js
new file mode 100644
index 00000000000..48958a282fc
--- /dev/null
+++ b/spec/frontend/runner/components/cells/runner_type_cell_spec.js
@@ -0,0 +1,48 @@
+import { GlBadge } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import RunnerTypeCell from '~/runner/components/cells/runner_type_cell.vue';
+import { INSTANCE_TYPE } from '~/runner/constants';
+
+describe('RunnerTypeCell', () => {
+ let wrapper;
+
+ const findBadges = () => wrapper.findAllComponents(GlBadge);
+
+ const createComponent = ({ runner = {} } = {}) => {
+ wrapper = mount(RunnerTypeCell, {
+ propsData: {
+ runner: {
+ runnerType: INSTANCE_TYPE,
+ active: true,
+ locked: false,
+ ...runner,
+ },
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Displays the runner type', () => {
+ createComponent();
+
+ expect(findBadges()).toHaveLength(1);
+ expect(findBadges().at(0).text()).toBe('shared');
+ });
+
+ it('Displays locked and paused states', () => {
+ createComponent({
+ runner: {
+ active: false,
+ locked: true,
+ },
+ });
+
+ expect(findBadges()).toHaveLength(3);
+ expect(findBadges().at(0).text()).toBe('shared');
+ expect(findBadges().at(1).text()).toBe('locked');
+ expect(findBadges().at(2).text()).toBe('paused');
+ });
+});
diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
new file mode 100644
index 00000000000..61a8f821b30
--- /dev/null
+++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
@@ -0,0 +1,137 @@
+import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
+import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE } from '~/runner/constants';
+import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+
+describe('RunnerList', () => {
+ let wrapper;
+
+ const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
+ const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
+ const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem);
+
+ const mockDefaultSort = 'CREATED_DESC';
+ const mockOtherSort = 'CONTACTED_DESC';
+ const mockFilters = [
+ { type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } },
+ { type: 'filtered-search-term', value: { data: '' } },
+ ];
+
+ const createComponent = ({ props = {}, options = {} } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(RunnerFilteredSearchBar, {
+ propsData: {
+ value: {
+ filters: [],
+ sort: mockDefaultSort,
+ },
+ ...props,
+ },
+ attrs: { namespace: 'runners' },
+ 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', () => {
+ expect(findFilteredSearch().props('tokens')).toEqual([
+ expect.objectContaining({
+ type: PARAM_KEY_STATUS,
+ options: expect.any(Array),
+ }),
+ expect.objectContaining({
+ type: PARAM_KEY_RUNNER_TYPE,
+ options: expect.any(Array),
+ }),
+ ]);
+ });
+
+ 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: {
+ sort: mockOtherSort,
+ filters: mockFilters,
+ },
+ },
+ });
+ });
+
+ it('filter values are shown', () => {
+ expect(findGlFilteredSearch().props('value')).toEqual(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" is emitted with filters', () => {
+ findGlFilteredSearch().vm.$emit('input', mockFilters);
+ findGlFilteredSearch().vm.$emit('submit');
+
+ expect(wrapper.emitted('input')[0]).toEqual([
+ {
+ filters: mockFilters,
+ sort: mockDefaultSort,
+ pagination: { page: 1 },
+ },
+ ]);
+ });
+
+ it('when the user sets a sorting method, the "search" is emitted with the sort', () => {
+ findSortOptions().at(1).vm.$emit('click');
+
+ expect(wrapper.emitted('input')[0]).toEqual([
+ {
+ filters: [],
+ sort: mockOtherSort,
+ pagination: { page: 1 },
+ },
+ ]);
+ });
+});
diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js
new file mode 100644
index 00000000000..d88d7b3fbee
--- /dev/null
+++ b/spec/frontend/runner/components/runner_list_spec.js
@@ -0,0 +1,130 @@
+import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import RunnerList from '~/runner/components/runner_list.vue';
+import { runnersData } from '../mock_data';
+
+const mockRunners = runnersData.data.runners.nodes;
+const mockActiveRunnersCount = mockRunners.length;
+
+describe('RunnerList', () => {
+ let wrapper;
+
+ const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message');
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const findTable = () => wrapper.findComponent(GlTable);
+ 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 createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
+ wrapper = extendedWrapper(
+ mountFn(RunnerList, {
+ propsData: {
+ runners: mockRunners,
+ activeRunnersCount: mockActiveRunnersCount,
+ ...props,
+ },
+ }),
+ );
+ };
+
+ beforeEach(() => {
+ createComponent({}, mount);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Displays active runner count', () => {
+ expect(findActiveRunnersMessage().text()).toBe(
+ `Runners currently online: ${mockActiveRunnersCount}`,
+ );
+ });
+
+ it('Displays a large active runner count', () => {
+ createComponent({ props: { activeRunnersCount: 2000 } });
+
+ expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000');
+ });
+
+ it('Displays headers', () => {
+ const headerLabels = findHeaders().wrappers.map((w) => w.text());
+
+ expect(headerLabels).toEqual([
+ 'Type/State',
+ 'Runner',
+ 'Version',
+ 'IP Address',
+ 'Projects',
+ 'Jobs',
+ 'Tags',
+ 'Last contact',
+ '', // actions has no label
+ ]);
+ });
+
+ it('Displays a list of runners', () => {
+ expect(findRows()).toHaveLength(3);
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('Displays details of a runner', () => {
+ const { id, description, version, ipAddress, shortSha } = mockRunners[0];
+
+ // Badges
+ expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('specific paused');
+
+ // Runner identifier
+ expect(findCell({ fieldKey: 'name' }).text()).toContain(
+ `#${getIdFromGraphQLId(id)} (${shortSha})`,
+ );
+ expect(findCell({ fieldKey: 'name' }).text()).toContain(description);
+
+ // Other fields: some cells are empty in the first iteration
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/329658#pending-features
+ expect(findCell({ fieldKey: 'version' }).text()).toBe(version);
+ expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress);
+ expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('');
+ expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('');
+ expect(findCell({ fieldKey: 'tagList' }).text()).toBe('');
+ expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String));
+
+ // Actions
+ const actions = findCell({ fieldKey: 'actions' });
+
+ expect(actions.findByTestId('edit-runner').exists()).toBe(true);
+ expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true);
+ });
+
+ it('Links to the runner page', () => {
+ const { id } = mockRunners[0];
+
+ expect(findCell({ fieldKey: 'name' }).find(GlLink).attributes('href')).toBe(
+ `/admin/runners/${getIdFromGraphQLId(id)}`,
+ );
+ });
+
+ describe('When data is loading', () => {
+ it('shows a busy state', () => {
+ createComponent({ props: { runners: [], loading: true } });
+ expect(findTable().attributes('busy')).toBeTruthy();
+ });
+
+ it('when there are no runners, shows an skeleton loader', () => {
+ createComponent({ props: { runners: [], loading: true } }, mount);
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('when there are runners, shows a busy indicator skeleton loader', () => {
+ createComponent({ props: { loading: true } }, mount);
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/runner_manual_setup_help_spec.js b/spec/frontend/runner/components/runner_manual_setup_help_spec.js
new file mode 100644
index 00000000000..ca5c88f6e28
--- /dev/null
+++ b/spec/frontend/runner/components/runner_manual_setup_help_spec.js
@@ -0,0 +1,84 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { TEST_HOST } from 'helpers/test_constants';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
+
+const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
+const mockRunnerInstallHelpPage = 'https://docs.gitlab.com/runner/install/';
+
+describe('RunnerManualSetupHelp', () => {
+ let wrapper;
+ let originalGon;
+
+ const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions);
+ const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton);
+ const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title');
+ const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url');
+ const findRegistrationToken = () => wrapper.findByTestId('registration-token');
+ const findRunnerHelpLink = () => wrapper.findByTestId('runner-help-link');
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(RunnerManualSetupHelp, {
+ provide: {
+ runnerInstallHelpPage: mockRunnerInstallHelpPage,
+ },
+ propsData: {
+ registrationToken: mockRegistrationToken,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ }),
+ );
+ };
+
+ beforeAll(() => {
+ originalGon = global.gon;
+ global.gon = { gitlab_url: TEST_HOST };
+ });
+
+ afterAll(() => {
+ global.gon = originalGon;
+ });
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Title contains the default runner type', () => {
+ expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually');
+ });
+
+ it('Title contains the group runner type', () => {
+ createComponent({ props: { typeName: 'group' } });
+
+ expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually');
+ });
+
+ it('Runner Install Page link', () => {
+ expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage);
+ });
+
+ it('Displays the coordinator URL token', () => {
+ expect(findCoordinatorUrl().text()).toBe(TEST_HOST);
+ expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST);
+ });
+
+ it('Displays the registration token', () => {
+ expect(findRegistrationToken().text()).toBe(mockRegistrationToken);
+ expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken);
+ });
+
+ it('Displays the runner instructions', () => {
+ expect(findRunnerInstructions().exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/runner/components/runner_pagination_spec.js b/spec/frontend/runner/components/runner_pagination_spec.js
new file mode 100644
index 00000000000..59feb32dd2a
--- /dev/null
+++ b/spec/frontend/runner/components/runner_pagination_spec.js
@@ -0,0 +1,160 @@
+import { GlPagination } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import RunnerPagination from '~/runner/components/runner_pagination.vue';
+
+const mockStartCursor = 'START_CURSOR';
+const mockEndCursor = 'END_CURSOR';
+
+describe('RunnerPagination', () => {
+ let wrapper;
+
+ const findPagination = () => wrapper.findComponent(GlPagination);
+
+ const createComponent = ({ page = 1, hasPreviousPage = false, hasNextPage = true } = {}) => {
+ wrapper = mount(RunnerPagination, {
+ propsData: {
+ value: {
+ page,
+ },
+ pageInfo: {
+ hasPreviousPage,
+ hasNextPage,
+ startCursor: mockStartCursor,
+ endCursor: mockEndCursor,
+ },
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('When on the first page', () => {
+ beforeEach(() => {
+ createComponent({
+ page: 1,
+ hasPreviousPage: false,
+ hasNextPage: true,
+ });
+ });
+
+ it('Contains the current page information', () => {
+ expect(findPagination().props('value')).toBe(1);
+ expect(findPagination().props('prevPage')).toBe(null);
+ expect(findPagination().props('nextPage')).toBe(2);
+ });
+
+ it('Shows prev page disabled', () => {
+ expect(findPagination().find('[aria-disabled]').text()).toBe('Prev');
+ });
+
+ it('Shows next page link', () => {
+ expect(findPagination().find('a').text()).toBe('Next');
+ });
+
+ it('Goes to the second page', () => {
+ findPagination().vm.$emit('input', 2);
+
+ expect(wrapper.emitted('input')[0]).toEqual([
+ {
+ after: mockEndCursor,
+ page: 2,
+ },
+ ]);
+ });
+ });
+
+ describe('When in between pages', () => {
+ beforeEach(() => {
+ createComponent({
+ page: 2,
+ hasPreviousPage: true,
+ hasNextPage: true,
+ });
+ });
+
+ it('Contains the current page information', () => {
+ expect(findPagination().props('value')).toBe(2);
+ expect(findPagination().props('prevPage')).toBe(1);
+ expect(findPagination().props('nextPage')).toBe(3);
+ });
+
+ it('Shows the next and previous pages', () => {
+ const links = findPagination().findAll('a');
+
+ expect(links).toHaveLength(2);
+ expect(links.at(0).text()).toBe('Prev');
+ expect(links.at(1).text()).toBe('Next');
+ });
+
+ it('Goes to the last page', () => {
+ findPagination().vm.$emit('input', 3);
+
+ expect(wrapper.emitted('input')[0]).toEqual([
+ {
+ after: mockEndCursor,
+ page: 3,
+ },
+ ]);
+ });
+
+ it('Goes to the first page', () => {
+ findPagination().vm.$emit('input', 1);
+
+ expect(wrapper.emitted('input')[0]).toEqual([
+ {
+ before: mockStartCursor,
+ page: 1,
+ },
+ ]);
+ });
+ });
+
+ describe('When in the last page', () => {
+ beforeEach(() => {
+ createComponent({
+ page: 3,
+ hasPreviousPage: true,
+ hasNextPage: false,
+ });
+ });
+
+ it('Contains the current page', () => {
+ expect(findPagination().props('value')).toBe(3);
+ expect(findPagination().props('prevPage')).toBe(2);
+ expect(findPagination().props('nextPage')).toBe(null);
+ });
+
+ it('Shows next page link', () => {
+ expect(findPagination().find('a').text()).toBe('Prev');
+ });
+
+ it('Shows next page disabled', () => {
+ expect(findPagination().find('[aria-disabled]').text()).toBe('Next');
+ });
+ });
+
+ describe('When only one page', () => {
+ beforeEach(() => {
+ createComponent({
+ page: 1,
+ hasPreviousPage: false,
+ hasNextPage: false,
+ });
+ });
+
+ it('does not display pagination', () => {
+ expect(wrapper.html()).toBe('');
+ });
+
+ it('Contains the current page', () => {
+ expect(findPagination().props('value')).toBe(1);
+ });
+
+ it('Shows no more page buttons', () => {
+ expect(findPagination().props('prevPage')).toBe(null);
+ expect(findPagination().props('nextPage')).toBe(null);
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/runner_tags_spec.js b/spec/frontend/runner/components/runner_tags_spec.js
new file mode 100644
index 00000000000..7bb3f65e4ba
--- /dev/null
+++ b/spec/frontend/runner/components/runner_tags_spec.js
@@ -0,0 +1,64 @@
+import { GlBadge } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import RunnerTags from '~/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 = shallowMount(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('md');
+ expect(findBadge().props('variant')).toBe('info');
+ });
+
+ it('Displays tags with small size', () => {
+ createComponent({
+ props: { size: 'sm' },
+ });
+
+ expect(findBadge().props('size')).toBe('sm');
+ });
+
+ it('Displays tags with a variant', () => {
+ createComponent({
+ props: { variant: 'warning' },
+ });
+
+ expect(findBadge().props('variant')).toBe('warning');
+ });
+
+ it('Is empty when there are no tags', () => {
+ createComponent({
+ props: { tagList: null },
+ });
+
+ expect(wrapper.text()).toBe('');
+ expect(findBadge().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/runner/components/runner_type_alert_spec.js b/spec/frontend/runner/components/runner_type_alert_spec.js
new file mode 100644
index 00000000000..5b136a77eeb
--- /dev/null
+++ b/spec/frontend/runner/components/runner_type_alert_spec.js
@@ -0,0 +1,61 @@
+import { GlAlert, GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import RunnerTypeAlert from '~/runner/components/runner_type_alert.vue';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
+
+describe('RunnerTypeAlert', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLink = () => wrapper.findComponent(GlLink);
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = shallowMount(RunnerTypeAlert, {
+ propsData: {
+ type: INSTANCE_TYPE,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe.each`
+ type | exampleText | anchor | variant
+ ${INSTANCE_TYPE} | ${'Shared runners are available to every project'} | ${'#shared-runners'} | ${'success'}
+ ${GROUP_TYPE} | ${'Use Group runners when you want all projects in a group'} | ${'#group-runners'} | ${'success'}
+ ${PROJECT_TYPE} | ${'You can set up a specific runner to be used by multiple projects'} | ${'#specific-runners'} | ${'info'}
+ `('When it is an $type level runner', ({ type, exampleText, anchor, variant }) => {
+ beforeEach(() => {
+ createComponent({ props: { type } });
+ });
+
+ it('Describes runner type', () => {
+ expect(wrapper.text()).toMatch(exampleText);
+ });
+
+ it(`Shows a ${variant} variant`, () => {
+ expect(findAlert().props('variant')).toBe(variant);
+ });
+
+ it(`Links to anchor "${anchor}"`, () => {
+ expect(findLink().attributes('href')).toBe(`/help/ci/runners/runners_scope${anchor}`);
+ });
+ });
+
+ describe('When runner type is not correct', () => {
+ it('Does not render content when type is missing', () => {
+ createComponent({ props: { type: undefined } });
+
+ expect(wrapper.html()).toBe('');
+ });
+
+ it('Validation fails for an incorrect type', () => {
+ expect(() => {
+ createComponent({ props: { type: 'NOT_A_TYPE' } });
+ }).toThrow();
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/runner_type_badge_spec.js b/spec/frontend/runner/components/runner_type_badge_spec.js
index 8e52d3398bd..ab5ccf6390f 100644
--- a/spec/frontend/runner/components/runner_type_badge_spec.js
+++ b/spec/frontend/runner/components/runner_type_badge_spec.js
@@ -32,8 +32,14 @@ describe('RunnerTypeBadge', () => {
expect(findBadge().props('variant')).toBe(variant);
});
- it('does not display a badge when type is unknown', () => {
- createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } });
+ 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/runner/components/runner_type_help_spec.js b/spec/frontend/runner/components/runner_type_help_spec.js
new file mode 100644
index 00000000000..f0d03282f8e
--- /dev/null
+++ b/spec/frontend/runner/components/runner_type_help_spec.js
@@ -0,0 +1,32 @@
+import { GlBadge } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
+
+describe('RunnerTypeHelp', () => {
+ let wrapper;
+
+ const findBadges = () => wrapper.findAllComponents(GlBadge);
+
+ const createComponent = () => {
+ wrapper = mount(RunnerTypeHelp);
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Displays each of the runner types', () => {
+ expect(findBadges().at(0).text()).toBe('shared');
+ expect(findBadges().at(1).text()).toBe('group');
+ expect(findBadges().at(2).text()).toBe('specific');
+ });
+
+ it('Displays runner states', () => {
+ expect(findBadges().at(3).text()).toBe('locked');
+ expect(findBadges().at(4).text()).toBe('paused');
+ });
+});
diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js
new file mode 100644
index 00000000000..6333ed7118a
--- /dev/null
+++ b/spec/frontend/runner/components/runner_update_form_spec.js
@@ -0,0 +1,263 @@
+import { GlForm } from '@gitlab/ui';
+import { createLocalVue, mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import RunnerUpdateForm from '~/runner/components/runner_update_form.vue';
+import {
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ ACCESS_LEVEL_REF_PROTECTED,
+ ACCESS_LEVEL_NOT_PROTECTED,
+} from '~/runner/constants';
+import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
+import { runnerData } from '../mock_data';
+
+jest.mock('~/flash');
+
+const mockRunner = runnerData.data.runner;
+
+const localVue = createLocalVue();
+localVue.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 findIpInput = () => wrapper.findByTestId('runner-field-ip-address').find('input');
+
+ 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 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,
+ ipAddress: findIpInput().element.value,
+ maximumTimeout: findMaxJobTimeoutInput().element.value || null,
+ tagList: findTagsInput().element.value.split(',').filter(Boolean),
+ });
+
+ const createComponent = ({ props } = {}) => {
+ wrapper = extendedWrapper(
+ mount(RunnerUpdateForm, {
+ localVue,
+ propsData: {
+ runner: mockRunner,
+ ...props,
+ },
+ apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]),
+ }),
+ );
+ };
+
+ const expectToHaveSubmittedRunnerContaining = (submittedRunner) => {
+ expect(runnerUpdateHandler).toHaveBeenCalledTimes(1);
+ expect(runnerUpdateHandler).toHaveBeenCalledWith({
+ input: expect.objectContaining(submittedRunner),
+ });
+
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: expect.stringContaining('saved'),
+ type: FLASH_TYPES.SUCCESS,
+ });
+
+ expect(findSubmitDisabledAttr()).toBeUndefined();
+ };
+
+ 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 prevent multiple submissions', async () => {
+ await submitForm();
+
+ expect(findSubmitDisabledAttr()).toBe('disabled');
+ });
+
+ it('Updates runner with no changes', async () => {
+ await submitFormAndWait();
+
+ // Some fields are not submitted
+ const { ipAddress, runnerType, ...submitted } = mockRunner;
+
+ expectToHaveSubmittedRunnerContaining(submitted);
+ });
+
+ describe('When data is being loaded', () => {
+ beforeEach(() => {
+ createComponent({ props: { runner: null } });
+ });
+
+ it('Form cannot be submitted', () => {
+ expect(findSubmit().props('loading')).toBe(true);
+ });
+
+ it('Form is updated when data loads', async () => {
+ wrapper.setProps({
+ runner: mockRunner,
+ });
+
+ await nextTick();
+
+ expect(mockRunner).toMatchObject(getFieldsModel());
+ });
+ });
+
+ it.each`
+ runnerType | attrDisabled | outcome
+ ${INSTANCE_TYPE} | ${'disabled'} | ${'disabled'}
+ ${GROUP_TYPE} | ${'disabled'} | ${'disabled'}
+ ${PROJECT_TYPE} | ${undefined} | ${'enabled'}
+ `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, attrDisabled }) => {
+ const runner = { ...mockRunner, runnerType };
+ createComponent({ props: { runner } });
+
+ expect(findLockedCheckbox().attributes('disabled')).toBe(attrDisabled);
+ });
+
+ 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'] }}
+ ${',,,,, commas'} | ${{ tagList: ['commas'] }}
+ ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }}
+ ${' trimmed , trimmed2 '} | ${{ tagList: ['trimmed', 'trimmed2'] }}
+ `('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 () => {
+ runnerUpdateHandler.mockRejectedValue(new Error('Something went wrong'));
+
+ await submitFormAndWait();
+
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: 'Network error: Something went wrong',
+ });
+ expect(findSubmitDisabledAttr()).toBeUndefined();
+ });
+
+ it('On validation error, error message is shown', async () => {
+ runnerUpdateHandler.mockResolvedValue({
+ data: {
+ runnerUpdate: {
+ runner: mockRunner,
+ errors: ['A value is invalid'],
+ },
+ },
+ });
+
+ await submitFormAndWait();
+
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: 'A value is invalid',
+ });
+ expect(findSubmitDisabledAttr()).toBeUndefined();
+ });
+ });
+});