From a7b3560714b4d9cc4ab32dffcd1f74a284b93580 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Fri, 18 Feb 2022 09:45:46 +0000 Subject: Add latest changes from gitlab-org/gitlab@14-8-stable-ee --- .../admin_runner_edit_app_spec.js | 14 +- .../admin_runner_show_app_spec.js | 146 +++++++++++++ .../runner/admin_runners/admin_runners_app_spec.js | 51 ++--- .../runner/components/cells/link_cell_spec.js | 72 +++++++ .../components/cells/runner_actions_cell_spec.js | 201 ++++------------- .../registration/registration_dropdown_spec.js | 15 +- .../registration_token_reset_dropdown_item_spec.js | 15 +- .../registration/registration_token_spec.js | 20 +- .../runner/components/runner_assigned_item_spec.js | 53 +++++ .../runner/components/runner_details_spec.js | 189 ++++++++++++++++ .../runner/components/runner_edit_button_spec.js | 41 ++++ .../components/runner_filtered_search_bar_spec.js | 41 ++-- .../runner/components/runner_groups_spec.js | 67 ++++++ .../runner/components/runner_header_spec.js | 44 +++- .../frontend/runner/components/runner_jobs_spec.js | 156 ++++++++++++++ .../runner/components/runner_jobs_table_spec.js | 119 ++++++++++ .../frontend/runner/components/runner_list_spec.js | 50 +++-- .../runner/components/runner_pagination_spec.js | 1 - .../runner/components/runner_pause_button_spec.js | 239 +++++++++++++++++++++ .../runner/components/runner_projects_spec.js | 193 +++++++++++++++++ .../runner/components/runner_type_tabs_spec.js | 22 +- .../runner/components/runner_update_form_spec.js | 46 ++-- .../runner/group_runners/group_runners_app_spec.js | 79 +++++-- spec/frontend/runner/mock_data.js | 10 +- spec/frontend/runner/utils_spec.js | 65 ++++++ 25 files changed, 1636 insertions(+), 313 deletions(-) create mode 100644 spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js create mode 100644 spec/frontend/runner/components/cells/link_cell_spec.js create mode 100644 spec/frontend/runner/components/runner_assigned_item_spec.js create mode 100644 spec/frontend/runner/components/runner_details_spec.js create mode 100644 spec/frontend/runner/components/runner_edit_button_spec.js create mode 100644 spec/frontend/runner/components/runner_groups_spec.js create mode 100644 spec/frontend/runner/components/runner_jobs_spec.js create mode 100644 spec/frontend/runner/components/runner_jobs_table_spec.js create mode 100644 spec/frontend/runner/components/runner_pause_button_spec.js create mode 100644 spec/frontend/runner/components/runner_projects_spec.js create mode 100644 spec/frontend/runner/utils_spec.js (limited to 'spec/frontend/runner') diff --git a/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js index ad0bce5c9af..ff6a632a4f8 100644 --- a/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js +++ b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js @@ -1,4 +1,5 @@ -import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +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'; @@ -18,8 +19,7 @@ jest.mock('~/runner/sentry_utils'); const mockRunnerGraphqlId = runnerData.data.runner.id; const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); describe('AdminRunnerEditApp', () => { let wrapper; @@ -29,7 +29,6 @@ describe('AdminRunnerEditApp', () => { const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { wrapper = mountFn(AdminRunnerEditApp, { - localVue, apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]), propsData: { runnerId: mockRunnerId, @@ -55,10 +54,11 @@ describe('AdminRunnerEditApp', () => { expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); }); - it('displays the runner id', async () => { + it('displays the runner id and creation date', async () => { await createComponentWithApollo({ mountFn: mount }); - expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId} created`); + expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); + expect(findRunnerHeader().text()).toContain('created'); }); it('displays the runner type and status', async () => { @@ -76,7 +76,7 @@ describe('AdminRunnerEditApp', () => { it('error is reported to sentry', () => { expect(captureException).toHaveBeenCalledWith({ - error: new Error('Network error: Error!'), + error: new Error('Error!'), component: 'AdminRunnerEditApp', }); }); diff --git a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js new file mode 100644 index 00000000000..4b651961112 --- /dev/null +++ b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -0,0 +1,146 @@ +import Vue from 'vue'; +import { mount, shallowMount } from '@vue/test-utils'; +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 '~/runner/components/runner_header.vue'; +import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; +import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; +import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql'; +import AdminRunnerShowApp from '~/runner/admin_runner_show/admin_runner_show_app.vue'; +import { captureException } from '~/runner/sentry_utils'; + +import { runnerData } from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); + +const mockRunner = runnerData.data.runner; +const mockRunnerGraphqlId = mockRunner.id; +const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; + +Vue.use(VueApollo); + +describe('AdminRunnerShowApp', () => { + let wrapper; + let mockRunnerQuery; + + const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + 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 = shallowMount } = {}) => { + wrapper = mountFn(AdminRunnerShowApp, { + apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]), + propsData: { + runnerId: mockRunnerId, + ...props, + }, + }); + + return waitForPromises(); + }; + + afterEach(() => { + mockRunnerQuery.mockReset(); + wrapper.destroy(); + }); + + describe('When showing runner details', () => { + beforeEach(async () => { + mockRunnerQueryResult(); + + await createComponent({ mountFn: mount }); + }); + + 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); + }); + + it('shows basic runner details', async () => { + const expected = `Description Instance runner + Last contact Never contacted + Version 1.0.0 + IP Address 127.0.0.1 + Configuration Runs untagged jobs + Maximum job timeout None + 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: mount, + }); + }); + + it('does not display the runner edit and pause buttons', () => { + expect(findRunnerEditButton().exists()).toBe(false); + expect(findRunnerPauseButton().exists()).toBe(false); + }); + }); + + describe('when runner does not have an edit url ', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + editAdminUrl: null, + }); + + await createComponent({ + mountFn: mount, + }); + }); + + it('does not display the runner edit button', () => { + expect(findRunnerEditButton().exists()).toBe(false); + expect(findRunnerPauseButton().exists()).toBe(true); + }); + }); + }); + + describe('When there is an error', () => { + beforeEach(async () => { + mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!')); + await createComponent(); + }); + + 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(); + }); + }); +}); diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 42be691ba4c..995f0cf7ba1 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -1,9 +1,13 @@ +import Vue from 'vue'; import { GlLink } from '@gitlab/ui'; -import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { + extendedWrapper, + shallowMountExtended, + mountExtended, +} from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -46,8 +50,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ updateHistory: jest.fn(), })); -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); describe('AdminRunnersApp', () => { let wrapper; @@ -65,22 +68,19 @@ describe('AdminRunnersApp', () => { const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); - const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { const handlers = [ [getRunnersQuery, mockRunnersQuery], [getRunnersCountQuery, mockRunnersCountQuery], ]; - wrapper = extendedWrapper( - mountFn(AdminRunnersApp, { - localVue, - apolloProvider: createMockApollo(handlers), - propsData: { - registrationToken: mockRegistrationToken, - ...props, - }, - }), - ); + wrapper = mountFn(AdminRunnersApp, { + apolloProvider: createMockApollo(handlers), + propsData: { + registrationToken: mockRegistrationToken, + ...props, + }, + }); }; beforeEach(async () => { @@ -98,7 +98,7 @@ describe('AdminRunnersApp', () => { }); it('shows total runner counts', async () => { - createComponent({ mountFn: mount }); + createComponent({ mountFn: mountExtended }); await waitForPromises(); @@ -129,7 +129,7 @@ describe('AdminRunnersApp', () => { return Promise.resolve({ data: { runners: { count } } }); }); - createComponent({ mountFn: mount }); + createComponent({ mountFn: mountExtended }); await waitForPromises(); expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( @@ -157,7 +157,7 @@ describe('AdminRunnersApp', () => { return Promise.resolve({ data: { runners: { count } } }); }); - createComponent({ mountFn: mount }); + createComponent({ mountFn: mountExtended }); await waitForPromises(); expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( @@ -175,7 +175,7 @@ describe('AdminRunnersApp', () => { }); it('runner item links to the runner admin page', async () => { - createComponent({ mountFn: mount }); + createComponent({ mountFn: mountExtended }); await waitForPromises(); @@ -198,7 +198,7 @@ describe('AdminRunnersApp', () => { }); it('sets tokens in the filtered search', () => { - createComponent({ mountFn: mount }); + createComponent({ mountFn: mountExtended }); expect(findFilteredSearch().props('tokens')).toEqual([ expect.objectContaining({ @@ -281,6 +281,7 @@ describe('AdminRunnersApp', () => { }, }); createComponent(); + await waitForPromises(); }); it('shows a message for no results', async () => { @@ -289,9 +290,10 @@ describe('AdminRunnersApp', () => { }); describe('when runners query fails', () => { - beforeEach(() => { + beforeEach(async () => { mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); createComponent(); + await waitForPromises(); }); it('error is shown to the user', async () => { @@ -300,17 +302,18 @@ describe('AdminRunnersApp', () => { it('error is reported to sentry', async () => { expect(captureException).toHaveBeenCalledWith({ - error: new Error('Network error: Error!'), + error: new Error('Error!'), component: 'AdminRunnersApp', }); }); }); describe('Pagination', () => { - beforeEach(() => { + beforeEach(async () => { mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated); - createComponent({ mountFn: mount }); + createComponent({ mountFn: mountExtended }); + await waitForPromises(); }); it('more pages can be selected', () => { diff --git a/spec/frontend/runner/components/cells/link_cell_spec.js b/spec/frontend/runner/components/cells/link_cell_spec.js new file mode 100644 index 00000000000..a59a0eaa5d8 --- /dev/null +++ b/spec/frontend/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 '~/runner/components/cells/link_cell.vue'; + +describe('LinkCell', () => { + let wrapper; + + const findGlLink = () => wrapper.find(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/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index 4233d86c24c..dcb0af67784 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -1,7 +1,7 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createAlert } from '~/flash'; @@ -9,11 +9,12 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { captureException } from '~/runner/sentry_utils'; import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; +import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; +import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; -import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import { runnersData } from '../../mock_data'; const mockRunner = runnersData.data.runners.nodes[0]; @@ -21,8 +22,7 @@ const mockRunner = runnersData.data.runners.nodes[0]; const getRunnersQueryName = getRunnersQuery.definitions[0].name.value; const getGroupRunnersQueryName = getGroupRunnersQuery.definitions[0].name.value; -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -32,44 +32,37 @@ describe('RunnerTypeCell', () => { const mockToastShow = jest.fn(); const runnerDeleteMutationHandler = jest.fn(); - const runnerActionsUpdateMutationHandler = jest.fn(); - const findEditBtn = () => wrapper.findByTestId('edit-runner'); - const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); + const findEditBtn = () => wrapper.findComponent(RunnerEditButton); + const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton); const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal); const findDeleteBtn = () => wrapper.findByTestId('delete-runner'); const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value; const createComponent = (runner = {}, options) => { - wrapper = extendedWrapper( - shallowMount(RunnerActionCell, { - propsData: { - runner: { - id: mockRunner.id, - shortSha: mockRunner.shortSha, - editAdminUrl: mockRunner.editAdminUrl, - userPermissions: mockRunner.userPermissions, - active: mockRunner.active, - ...runner, - }, + wrapper = shallowMountExtended(RunnerActionCell, { + propsData: { + runner: { + id: mockRunner.id, + shortSha: mockRunner.shortSha, + editAdminUrl: mockRunner.editAdminUrl, + userPermissions: mockRunner.userPermissions, + active: mockRunner.active, + ...runner, }, - localVue, - apolloProvider: createMockApollo([ - [runnerDeleteMutation, runnerDeleteMutationHandler], - [runnerActionsUpdateMutation, runnerActionsUpdateMutationHandler], - ]), - directives: { - GlTooltip: createMockDirective(), - GlModal: createMockDirective(), - }, - mocks: { - $toast: { - show: mockToastShow, - }, + }, + apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteMutationHandler]]), + directives: { + GlTooltip: createMockDirective(), + GlModal: createMockDirective(), + }, + mocks: { + $toast: { + show: mockToastShow, }, - ...options, - }), - ); + }, + ...options, + }); }; beforeEach(() => { @@ -80,21 +73,11 @@ describe('RunnerTypeCell', () => { }, }, }); - - runnerActionsUpdateMutationHandler.mockResolvedValue({ - data: { - runnerUpdate: { - runner: mockRunner, - errors: [], - }, - }, - }); }); afterEach(() => { mockToastShow.mockReset(); runnerDeleteMutationHandler.mockReset(); - runnerActionsUpdateMutationHandler.mockReset(); wrapper.destroy(); }); @@ -126,116 +109,14 @@ describe('RunnerTypeCell', () => { }); }); - describe('Toggle active action', () => { - 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(() => { - createComponent({ active: isActive }); - }); - - it(`Displays a ${icon} button`, () => { - expect(findToggleActiveBtn().props('loading')).toBe(false); - expect(findToggleActiveBtn().props('icon')).toBe(icon); - expect(getTooltip(findToggleActiveBtn())).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(getTooltip(findToggleActiveBtn())).toBe(''); - expect(findToggleActiveBtn().attributes('aria-label')).toBe(''); - }); - - describe(`When clicking on the ${icon} button`, () => { - it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => { - expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0); - - await findToggleActiveBtn().vm.$emit('click'); - - expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1); - expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({ - input: { - id: mockRunner.id, - active: newActiveValue, - }, - }); - }); - - it('The button does not have a loading state after the mutation occurs', async () => { - await findToggleActiveBtn().vm.$emit('click'); - - expect(findToggleActiveBtn().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findToggleActiveBtn().props('loading')).toBe(false); - }); - }); - - describe('When update fails', () => { - describe('On a network error', () => { - const mockErrorMsg = 'Update error!'; - - beforeEach(async () => { - runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); - - await findToggleActiveBtn().vm.$emit('click'); - }); - - it('error is reported to sentry', () => { - expect(captureException).toHaveBeenCalledWith({ - error: new Error(`Network error: ${mockErrorMsg}`), - component: 'RunnerActionsCell', - }); - }); - - 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 () => { - runnerActionsUpdateMutationHandler.mockResolvedValue({ - data: { - runnerUpdate: { - runner: mockRunner, - errors: [mockErrorMsg, mockErrorMsg2], - }, - }, - }); - - await findToggleActiveBtn().vm.$emit('click'); - }); - - it('error is reported to sentry', () => { - expect(captureException).toHaveBeenCalledWith({ - error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), - component: 'RunnerActionsCell', - }); - }); + describe('Pause action', () => { + it('Renders a compact pause button', () => { + createComponent(); - it('error is shown to the user', () => { - expect(createAlert).toHaveBeenCalledTimes(1); - }); - }); - }); + expect(findRunnerPauseBtn().props('compact')).toBe(true); }); - it('Does not render the runner toggle active button when user cannot update', () => { + it('Does not render the runner pause button when user cannot update', () => { createComponent({ userPermissions: { ...mockRunner.userPermissions, @@ -243,7 +124,7 @@ describe('RunnerTypeCell', () => { }, }); - expect(findToggleActiveBtn().exists()).toBe(false); + expect(findRunnerPauseBtn().exists()).toBe(false); }); }); @@ -308,8 +189,9 @@ describe('RunnerTypeCell', () => { }); describe('When delete is clicked', () => { - beforeEach(() => { + beforeEach(async () => { findRunnerDeleteModal().vm.$emit('primary'); + await waitForPromises(); }); it('The delete mutation is called correctly', () => { @@ -324,7 +206,8 @@ describe('RunnerTypeCell', () => { expect(getTooltip(findDeleteBtn())).toBe(''); }); - it('The toast notification is shown', () => { + it('The toast notification is shown', async () => { + await waitForPromises(); expect(mockToastShow).toHaveBeenCalledTimes(1); expect(mockToastShow).toHaveBeenCalledWith( expect.stringContaining(`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`), @@ -336,15 +219,16 @@ describe('RunnerTypeCell', () => { describe('On a network error', () => { const mockErrorMsg = 'Delete error!'; - beforeEach(() => { + beforeEach(async () => { runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); findRunnerDeleteModal().vm.$emit('primary'); + await waitForPromises(); }); it('error is reported to sentry', () => { expect(captureException).toHaveBeenCalledWith({ - error: new Error(`Network error: ${mockErrorMsg}`), + error: new Error(mockErrorMsg), component: 'RunnerActionsCell', }); }); @@ -362,7 +246,7 @@ describe('RunnerTypeCell', () => { const mockErrorMsg = 'Runner not found!'; const mockErrorMsg2 = 'User not allowed!'; - beforeEach(() => { + beforeEach(async () => { runnerDeleteMutationHandler.mockResolvedValue({ data: { runnerDelete: { @@ -372,6 +256,7 @@ describe('RunnerTypeCell', () => { }); findRunnerDeleteModal().vm.$emit('primary'); + await waitForPromises(); }); it('error is reported to sentry', () => { diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js index d18d2bec18e..da8ef7c3af0 100644 --- a/spec/frontend/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js @@ -1,9 +1,11 @@ import { GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; -import { createLocalVue, mount, shallowMount, createWrapper } from '@vue/test-utils'; -import { nextTick } from 'vue'; +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 '~/runner/components/registration/registration_dropdown.vue'; import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; @@ -73,8 +75,7 @@ describe('RegistrationDropdown', () => { }); describe('When the dropdown item is clicked', () => { - const localVue = createLocalVue(); - localVue.use(VueApollo); + Vue.use(VueApollo); const requestHandlers = [ [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], @@ -84,10 +85,9 @@ describe('RegistrationDropdown', () => { const findModalInBody = () => createWrapper(document.body).find('[data-testid="runner-instructions-modal"]'); - beforeEach(() => { + beforeEach(async () => { createComponent( { - localVue, // Mock load modal contents from API apolloProvider: createMockApollo(requestHandlers), // Use `attachTo` to find the modal @@ -96,7 +96,8 @@ describe('RegistrationDropdown', () => { mount, ); - findRegistrationInstructionsDropdownItem().trigger('click'); + await findRegistrationInstructionsDropdownItem().trigger('click'); + await waitForPromises(); }); afterEach(() => { diff --git a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js index e75decddf70..d2deb49a5f7 100644 --- a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js +++ b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -1,6 +1,7 @@ import { GlDropdownItem, GlLoadingIcon, GlToast, GlModal } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +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'; @@ -14,9 +15,8 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); -const localVue = createLocalVue(); -localVue.use(VueApollo); -localVue.use(GlToast); +Vue.use(VueApollo); +Vue.use(GlToast); const mockNewToken = 'NEW_TOKEN'; const modalID = 'token-reset-modal'; @@ -34,7 +34,6 @@ describe('RegistrationTokenResetDropdownItem', () => { const createComponent = ({ props, provide = {} } = {}) => { wrapper = shallowMount(RegistrationTokenResetDropdownItem, { - localVue, provide, propsData: { type: INSTANCE_TYPE, @@ -163,10 +162,10 @@ describe('RegistrationTokenResetDropdownItem', () => { await waitForPromises(); expect(createAlert).toHaveBeenLastCalledWith({ - message: `Network error: ${mockErrorMsg}`, + message: mockErrorMsg, }); expect(captureException).toHaveBeenCalledWith({ - error: new Error(`Network error: ${mockErrorMsg}`), + error: new Error(mockErrorMsg), component: 'RunnerRegistrationTokenReset', }); }); diff --git a/spec/frontend/runner/components/registration/registration_token_spec.js b/spec/frontend/runner/components/registration/registration_token_spec.js index f53ae165344..6b9708cc525 100644 --- a/spec/frontend/runner/components/registration/registration_token_spec.js +++ b/spec/frontend/runner/components/registration/registration_token_spec.js @@ -1,7 +1,7 @@ import { nextTick } from 'vue'; import { GlToast } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { createLocalVue } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RegistrationToken from '~/runner/components/registration/registration_token.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -25,15 +25,13 @@ describe('RegistrationToken', () => { const createComponent = ({ props = {}, withGlToast = true } = {}) => { const localVue = withGlToast ? vueWithGlToast() : undefined; - wrapper = extendedWrapper( - shallowMount(RegistrationToken, { - propsData: { - value: mockToken, - ...props, - }, - localVue, - }), - ); + wrapper = shallowMountExtended(RegistrationToken, { + propsData: { + value: mockToken, + ...props, + }, + localVue, + }); showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; }; diff --git a/spec/frontend/runner/components/runner_assigned_item_spec.js b/spec/frontend/runner/components/runner_assigned_item_spec.js new file mode 100644 index 00000000000..c6156c16d4a --- /dev/null +++ b/spec/frontend/runner/components/runner_assigned_item_spec.js @@ -0,0 +1,53 @@ +import { GlAvatar } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue'; + +const mockHref = '/group/project'; +const mockName = 'Project'; +const mockFullName = 'Group / Project'; +const mockAvatarUrl = '/avatar.png'; + +describe('RunnerAssignedItem', () => { + let wrapper; + + const findAvatar = () => wrapper.findByTestId('item-avatar'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(RunnerAssignedItem, { + propsData: { + href: mockHref, + name: mockName, + fullName: mockFullName, + avatarUrl: mockAvatarUrl, + ...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: 'rect', + size: 48, + }); + }); + + it('Shows an item link', () => { + const groupFullName = wrapper.findByText(mockFullName); + + expect(groupFullName.attributes('href')).toBe(mockHref); + }); +}); diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js new file mode 100644 index 00000000000..6bf4a52a799 --- /dev/null +++ b/spec/frontend/runner/components/runner_details_spec.js @@ -0,0 +1,189 @@ +import { GlSprintf, GlIntersperse, GlTab } from '@gitlab/ui'; +import { createWrapper, ErrorWrapper } from '@vue/test-utils'; +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 { ACCESS_LEVEL_REF_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED } from '~/runner/constants'; + +import RunnerDetails from '~/runner/components/runner_details.vue'; +import RunnerDetail from '~/runner/components/runner_detail.vue'; +import RunnerGroups from '~/runner/components/runner_groups.vue'; +import RunnersJobs from '~/runner/components/runner_jobs.vue'; +import RunnerTags from '~/runner/components/runner_tags.vue'; +import RunnerTag from '~/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); + + /** + * Find the definition (
) that corresponds to this term (
) + * @param {string} dtLabel - Label for this value + * @returns Wrapper + */ + const findDd = (dtLabel) => { + const dt = wrapper.findByText(dtLabel).element; + const dd = dt.nextElementSibling; + if (dt.tagName === 'DT' && dd.tagName === 'DD') { + return createWrapper(dd, {}); + } + return ErrorWrapper(dtLabel); + }; + + const findDetailGroups = () => wrapper.findComponent(RunnerGroups); + const findRunnersJobs = () => wrapper.findComponent(RunnersJobs); + const findJobCountBadge = () => wrapper.findByTestId('job-count-badge'); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended, stubs } = {}) => { + wrapper = mountFn(RunnerDetails, { + propsData: { + ...props, + }, + stubs: { + RunnerDetail, + ...stubs, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('when no runner is present, no contents are shown', () => { + createComponent({ + props: { + runner: null, + }, + }); + + expect(wrapper.text()).toBe(''); + }); + + 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'} + ${'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'} + `('"$field" field', ({ field, runner, expectedValue }) => { + beforeEach(() => { + createComponent({ + props: { + runner: { + ...mockRunner, + ...runner, + }, + }, + stubs: { + GlIntersperse, + GlSprintf, + TimeAgo, + }, + }); + }); + + it(`displays expected value "${expectedValue}"`, () => { + expect(findDd(field).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').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').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); + }); + }); + }); + + describe('Jobs tab', () => { + const stubs = { GlTab }; + + it('without a runner, shows no jobs', () => { + createComponent({ + props: { runner: null }, + stubs, + }); + + expect(findJobCountBadge().exists()).toBe(false); + expect(findRunnersJobs().exists()).toBe(false); + }); + + it('without a job count, shows no jobs count', () => { + createComponent({ + props: { + runner: { ...mockRunner, jobCount: undefined }, + }, + stubs, + }); + + expect(findJobCountBadge().exists()).toBe(false); + }); + + it('with a job count, shows jobs count', () => { + const runner = { ...mockRunner, jobCount: 3 }; + + createComponent({ + props: { runner }, + stubs, + }); + + expect(findJobCountBadge().text()).toBe('3'); + expect(findRunnersJobs().props('runner')).toBe(runner); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_edit_button_spec.js b/spec/frontend/runner/components/runner_edit_button_spec.js new file mode 100644 index 00000000000..428c1ef07e9 --- /dev/null +++ b/spec/frontend/runner/components/runner_edit_button_spec.js @@ -0,0 +1,41 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerEditButton from '~/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/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js index 5ab0db019a3..fda96e5918e 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -1,6 +1,5 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config'; import TagToken from '~/runner/components/search_tokens/tag_token.vue'; @@ -29,27 +28,25 @@ describe('RunnerList', () => { }; const createComponent = ({ props = {}, options = {} } = {}) => { - wrapper = extendedWrapper( - shallowMount(RunnerFilteredSearchBar, { - propsData: { - namespace: 'runners', - tokens: [], - value: { - runnerType: null, - filters: [], - sort: mockDefaultSort, - }, - ...props, + wrapper = shallowMountExtended(RunnerFilteredSearchBar, { + propsData: { + namespace: 'runners', + tokens: [], + value: { + runnerType: null, + filters: [], + sort: mockDefaultSort, }, - stubs: { - FilteredSearch, - GlFilteredSearch, - GlDropdown, - GlDropdownItem, - }, - ...options, - }), - ); + ...props, + }, + stubs: { + FilteredSearch, + GlFilteredSearch, + GlDropdown, + GlDropdownItem, + }, + ...options, + }); }; beforeEach(() => { diff --git a/spec/frontend/runner/components/runner_groups_spec.js b/spec/frontend/runner/components/runner_groups_spec.js new file mode 100644 index 00000000000..b83733b9972 --- /dev/null +++ b/spec/frontend/runner/components/runner_groups_spec.js @@ -0,0 +1,67 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import RunnerGroups from '~/runner/components/runner_groups.vue'; +import RunnerAssignedItem from '~/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/runner/components/runner_header_spec.js b/spec/frontend/runner/components/runner_header_spec.js index 50699df3a44..8799c218b06 100644 --- a/spec/frontend/runner/components/runner_header_spec.js +++ b/spec/frontend/runner/components/runner_header_spec.js @@ -1,5 +1,5 @@ import { GlSprintf } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { GROUP_TYPE, STATUS_ONLINE } from '~/runner/constants'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -18,9 +18,10 @@ describe('RunnerHeader', () => { const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge); const findRunnerStatusBadge = () => wrapper.findComponent(RunnerStatusBadge); + const findRunnerLockedIcon = () => wrapper.findByTestId('lock-icon'); const findTimeAgo = () => wrapper.findComponent(TimeAgo); - const createComponent = ({ runner = {}, mountFn = shallowMount } = {}) => { + const createComponent = ({ runner = {}, options = {}, mountFn = shallowMountExtended } = {}) => { wrapper = mountFn(RunnerHeader, { propsData: { runner: { @@ -32,6 +33,7 @@ describe('RunnerHeader', () => { GlSprintf, TimeAgo, }, + ...options, }); }; @@ -41,24 +43,24 @@ describe('RunnerHeader', () => { it('displays the runner status', () => { createComponent({ - mountFn: mount, + mountFn: mountExtended, runner: { status: STATUS_ONLINE, }, }); - expect(findRunnerStatusBadge().text()).toContain(`online`); + expect(findRunnerStatusBadge().text()).toContain('online'); }); it('displays the runner type', () => { createComponent({ - mountFn: mount, + mountFn: mountExtended, runner: { runnerType: GROUP_TYPE, }, }); - expect(findRunnerTypeBadge().text()).toContain(`group`); + expect(findRunnerTypeBadge().text()).toContain('group'); }); it('displays the runner id', () => { @@ -68,7 +70,18 @@ describe('RunnerHeader', () => { }, }); - expect(wrapper.text()).toContain(`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', () => { @@ -78,7 +91,7 @@ describe('RunnerHeader', () => { expect(findTimeAgo().props('time')).toBe(mockRunner.createdAt); }); - it('does not display runner creation time if createdAt missing', () => { + it('does not display runner creation time if "createdAt" is missing', () => { createComponent({ runner: { id: convertToGraphQLId(TYPE_CI_RUNNER, 99), @@ -86,8 +99,21 @@ describe('RunnerHeader', () => { }, }); - expect(wrapper.text()).toContain(`Runner #99`); + 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: '
My Actions
', + }, + mountFn: mountExtended, + }, + }); + + expect(wrapper.findByTestId('actions-content').text()).toBe('My Actions'); + }); }); diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js new file mode 100644 index 00000000000..97339056370 --- /dev/null +++ b/spec/frontend/runner/components/runner_jobs_spec.js @@ -0,0 +1,156 @@ +import { GlSkeletonLoading } 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 '~/runner/components/runner_jobs.vue'; +import RunnerJobsTable from '~/runner/components/runner_jobs_table.vue'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; +import { captureException } from '~/runner/sentry_utils'; +import { I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '~/runner/constants'; + +import getRunnerJobsQuery from '~/runner/graphql/get_runner_jobs.query.graphql'; + +import { runnerData, runnerJobsData } from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/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(GlSkeletonLoading); + const findRunnerJobsTable = () => wrapper.findComponent(RunnerJobsTable); + const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + + const createComponent = ({ mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerJobs, { + apolloProvider: createMockApollo([[getRunnerJobsQuery, 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).toHaveLength(mockJobs.length); + expect(jobs[0]).toMatchObject(mockJobs[0]); + }); + + 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/runner/components/runner_jobs_table_spec.js b/spec/frontend/runner/components/runner_jobs_table_spec.js new file mode 100644 index 00000000000..5f4905ad2a8 --- /dev/null +++ b/spec/frontend/runner/components/runner_jobs_table_spec.js @@ -0,0 +1,119 @@ +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 '~/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__('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 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/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 452430b7237..42d6ecca09e 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -1,8 +1,13 @@ import { GlTable, GlSkeletonLoader } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { + extendedWrapper, + shallowMountExtended, + mountExtended, +} from 'helpers/vue_test_utils_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; +import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; import { runnersData } from '../mock_data'; const mockRunners = runnersData.data.runners.nodes; @@ -18,20 +23,18 @@ describe('RunnerList', () => { 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, - }, - }), - ); + const createComponent = ({ props = {} } = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(RunnerList, { + propsData: { + runners: mockRunners, + activeRunnersCount: mockActiveRunnersCount, + ...props, + }, + }); }; beforeEach(() => { - createComponent({}, mount); + createComponent({}, mountExtended); }); afterEach(() => { @@ -43,9 +46,9 @@ describe('RunnerList', () => { expect(headerLabels).toEqual([ 'Status', - 'Runner ID', + 'Runner', 'Version', - 'IP Address', + 'IP', 'Jobs', 'Tags', 'Last contact', @@ -54,7 +57,7 @@ describe('RunnerList', () => { }); it('Sets runner id as a row key', () => { - createComponent({}, shallowMount); + createComponent({}); expect(findTable().attributes('primary-key')).toBe('id'); }); @@ -89,8 +92,9 @@ describe('RunnerList', () => { // Actions const actions = findCell({ fieldKey: 'actions' }); - expect(actions.findByTestId('edit-runner').exists()).toBe(true); - expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true); + expect(actions.findComponent(RunnerEditButton).exists()).toBe(true); + expect(actions.findComponent(RunnerPauseButton).exists()).toBe(true); + expect(actions.findByTestId('delete-runner').exists()).toBe(true); }); describe('Table data formatting', () => { @@ -107,7 +111,7 @@ describe('RunnerList', () => { it('Formats job counts', () => { mockRunnersCopy[0].jobCount = 1; - createComponent({ props: { runners: mockRunnersCopy } }, mount); + createComponent({ props: { runners: mockRunnersCopy } }, mountExtended); expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1'); }); @@ -115,7 +119,7 @@ describe('RunnerList', () => { it('Formats large job counts', () => { mockRunnersCopy[0].jobCount = 1000; - createComponent({ props: { runners: mockRunnersCopy } }, mount); + createComponent({ props: { runners: mockRunnersCopy } }, mountExtended); expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000'); }); @@ -123,7 +127,7 @@ describe('RunnerList', () => { it('Formats large job counts with a plus symbol', () => { mockRunnersCopy[0].jobCount = 1001; - createComponent({ props: { runners: mockRunnersCopy } }, mount); + createComponent({ props: { runners: mockRunnersCopy } }, mountExtended); expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000+'); }); @@ -143,13 +147,13 @@ describe('RunnerList', () => { }); it('when there are no runners, shows an skeleton loader', () => { - createComponent({ props: { runners: [], loading: true } }, mount); + 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 } }, mount); + createComponent({ props: { loading: true } }, mountExtended); expect(findSkeletonLoader().exists()).toBe(false); }); diff --git a/spec/frontend/runner/components/runner_pagination_spec.js b/spec/frontend/runner/components/runner_pagination_spec.js index 59feb32dd2a..ecd6e6bd7f9 100644 --- a/spec/frontend/runner/components/runner_pagination_spec.js +++ b/spec/frontend/runner/components/runner_pagination_spec.js @@ -104,7 +104,6 @@ describe('RunnerPagination', () => { expect(wrapper.emitted('input')[0]).toEqual([ { - before: mockStartCursor, page: 1, }, ]); diff --git a/spec/frontend/runner/components/runner_pause_button_spec.js b/spec/frontend/runner/components/runner_pause_button_spec.js new file mode 100644 index 00000000000..278f3dec2ee --- /dev/null +++ b/spec/frontend/runner/components/runner_pause_button_spec.js @@ -0,0 +1,239 @@ +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 runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import { captureException } from '~/runner/sentry_utils'; +import { createAlert } from '~/flash'; + +import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; +import { runnersData } from '../mock_data'; + +const mockRunner = runnersData.data.runners.nodes[0]; + +Vue.use(VueApollo); + +jest.mock('~/flash'); +jest.mock('~/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 | isActive | newActiveValue + ${'paused'} | ${'play'} | ${'Resume'} | ${false} | ${true} + ${'active'} | ${'pause'} | ${'Pause'} | ${true} | ${false} + `('When the runner is $runnerState', ({ icon, content, 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); + expect(findBtn().text()).toBe(content); + }); + + 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`, () => { + beforeEach(async () => { + findBtn().vm.$emit('click'); + }); + + 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 ${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); + }); + }); + + 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