diff options
Diffstat (limited to 'spec/frontend/runner')
25 files changed, 1636 insertions, 313 deletions
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 (<dd>) that corresponds to this term (<dt>) + * @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: '<div data-testid="actions-content">My Actions</div>', + }, + 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 <template v-if> to ensure rendering a + // text-less button. Ensure we don't send even empty an + // content slot to prevent a distorted/rectangular button. + expect(wrapper.find('.gl-button-text').exists()).toBe(false); + }); + + it('Display correctly for screen readers', () => { + expect(findBtn().attributes('aria-label')).toBe('Pause'); + expect(getTooltip()).toBe('Pause'); + }); + + describe('Immediately after the 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(''); + }); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js new file mode 100644 index 00000000000..68a2130d6d9 --- /dev/null +++ b/spec/frontend/runner/components/runner_projects_spec.js @@ -0,0 +1,193 @@ +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 { sprintf } from '~/locale'; +import { + I18N_ASSIGNED_PROJECTS, + I18N_NONE, + RUNNER_DETAILS_PROJECTS_PAGE_SIZE, +} from '~/runner/constants'; +import RunnerProjects from '~/runner/components/runner_projects.vue'; +import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; +import { captureException } from '~/runner/sentry_utils'; + +import getRunnerProjectsQuery from '~/runner/graphql/get_runner_projects.query.graphql'; + +import { runnerData, runnerProjectsData } from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); + +const mockRunner = runnerData.data.runner; +const mockRunnerWithProjects = runnerProjectsData.data.runner; +const mockProjects = mockRunnerWithProjects.projects.nodes; + +Vue.use(VueApollo); + +describe('RunnerProjects', () => { + let wrapper; + let mockRunnerProjectsQuery; + + const findHeading = () => wrapper.find('h3'); + const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading); + const findRunnerAssignedItems = () => wrapper.findAllComponents(RunnerAssignedItem); + const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + + const createComponent = ({ mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerProjects, { + apolloProvider: createMockApollo([[getRunnerProjectsQuery, mockRunnerProjectsQuery]]), + propsData: { + runner: mockRunner, + }, + }); + }; + + beforeEach(() => { + mockRunnerProjectsQuery = jest.fn(); + }); + + afterEach(() => { + mockRunnerProjectsQuery.mockReset(); + wrapper.destroy(); + }); + + it('Requests runner projects', async () => { + createComponent(); + + await waitForPromises(); + + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(1); + expect(mockRunnerProjectsQuery).toHaveBeenCalledWith({ + id: mockRunner.id, + first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + }); + }); + + describe('When there are projects assigned', () => { + beforeEach(async () => { + mockRunnerProjectsQuery.mockResolvedValueOnce(runnerProjectsData); + + createComponent(); + await waitForPromises(); + }); + + it('Shows a heading', async () => { + const expected = sprintf(I18N_ASSIGNED_PROJECTS, { projectCount: mockProjects.length }); + + expect(findHeading().text()).toBe(expected); + }); + + it('Shows projects', () => { + expect(findRunnerAssignedItems().length).toBe(mockProjects.length); + }); + + it('Shows a project', () => { + const item = findRunnerAssignedItems().at(0); + const { webUrl, name, nameWithNamespace, avatarUrl } = mockProjects[0]; + + expect(item.props()).toMatchObject({ + href: webUrl, + name, + fullName: nameWithNamespace, + avatarUrl, + }); + }); + + describe('When "Next" page is clicked', () => { + beforeEach(async () => { + findRunnerPagination().vm.$emit('input', { page: 3, after: 'AFTER_CURSOR' }); + + await waitForPromises(); + }); + + it('A new page is requested', () => { + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(2); + expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({ + id: mockRunner.id, + first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + after: 'AFTER_CURSOR', + }); + }); + + it('When "Prev" page is clicked, the previous page is requested', async () => { + findRunnerPagination().vm.$emit('input', { page: 2, before: 'BEFORE_CURSOR' }); + + await waitForPromises(); + + expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(3); + expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({ + id: mockRunner.id, + last: RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + before: 'BEFORE_CURSOR', + }); + }); + }); + }); + + describe('When loading', () => { + it('shows loading indicator and no other content', () => { + createComponent(); + + expect(findGlSkeletonLoading().exists()).toBe(true); + + expect(wrapper.findByText(I18N_NONE).exists()).toBe(false); + expect(findRunnerAssignedItems().length).toBe(0); + + expect(findRunnerPagination().attributes('disabled')).toBe('true'); + }); + }); + + describe('When there are no projects', () => { + beforeEach(async () => { + mockRunnerProjectsQuery.mockResolvedValueOnce({ + data: { + runner: { + id: mockRunner.id, + projectCount: 0, + projects: { + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + }, + }, + }, + }); + + createComponent(); + await waitForPromises(); + }); + + it('Shows a "None" label', () => { + expect(wrapper.findByText(I18N_NONE).exists()).toBe(true); + }); + }); + + describe('When an error occurs', () => { + beforeEach(async () => { + mockRunnerProjectsQuery.mockRejectedValue(new Error('Error!')); + + createComponent(); + await waitForPromises(); + }); + + it('shows an error', () => { + expect(createAlert).toHaveBeenCalled(); + }); + + it('reports an error', () => { + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerProjects', + error: expect.any(Error), + }); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_type_tabs_spec.js b/spec/frontend/runner/components/runner_type_tabs_spec.js index 4871d9c470a..9da5d842d8f 100644 --- a/spec/frontend/runner/components/runner_type_tabs_spec.js +++ b/spec/frontend/runner/components/runner_type_tabs_spec.js @@ -1,7 +1,7 @@ import { GlTab } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; -import { INSTANCE_TYPE, GROUP_TYPE } from '~/runner/constants'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }; @@ -13,6 +13,7 @@ describe('RunnerTypeTabs', () => { findTabs() .filter((tab) => tab.attributes('active') === 'true') .at(0); + const getTabsTitles = () => findTabs().wrappers.map((tab) => tab.text()); const createComponent = ({ props, ...options } = {}) => { wrapper = shallowMount(RunnerTypeTabs, { @@ -35,13 +36,18 @@ describe('RunnerTypeTabs', () => { wrapper.destroy(); }); - it('Renders options to filter runners', () => { - expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([ - 'All', - 'Instance', - 'Group', - 'Project', - ]); + it('Renders all options to filter runners by default', () => { + expect(getTabsTitles()).toEqual(['All', 'Instance', 'Group', 'Project']); + }); + + it('Renders fewer options to filter runners', () => { + createComponent({ + props: { + runnerTypes: [GROUP_TYPE, PROJECT_TYPE], + }, + }); + + expect(getTabsTitles()).toEqual(['All', 'Group', 'Project']); }); it('"All" is selected by default', () => { diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js index ebb2e67d1e2..8b76be396ef 100644 --- a/spec/frontend/runner/components/runner_update_form_spec.js +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -1,9 +1,8 @@ +import Vue, { nextTick } from 'vue'; 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 { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import RunnerUpdateForm from '~/runner/components/runner_update_form.vue'; @@ -23,8 +22,7 @@ jest.mock('~/runner/sentry_utils'); const mockRunner = runnerData.data.runner; -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); describe('RunnerUpdateForm', () => { let wrapper; @@ -61,16 +59,13 @@ describe('RunnerUpdateForm', () => { }); const createComponent = ({ props } = {}) => { - wrapper = extendedWrapper( - mount(RunnerUpdateForm, { - localVue, - propsData: { - runner: mockRunner, - ...props, - }, - apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]), - }), - ); + wrapper = mountExtended(RunnerUpdateForm, { + propsData: { + runner: mockRunner, + ...props, + }, + apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]), + }); }; const expectToHaveSubmittedRunnerContaining = (submittedRunner) => { @@ -126,8 +121,21 @@ describe('RunnerUpdateForm', () => { it('Updates runner with no changes', async () => { await submitFormAndWait(); - // Some fields are not submitted - const { ipAddress, runnerType, createdAt, status, ...submitted } = mockRunner; + // Some read-only fields are not submitted + const { + __typename, + ipAddress, + runnerType, + createdAt, + status, + editAdminUrl, + contactedAt, + userPermissions, + version, + groups, + jobCount, + ...submitted + } = mockRunner; expectToHaveSubmittedRunnerContaining(submitted); }); @@ -239,11 +247,11 @@ describe('RunnerUpdateForm', () => { await submitFormAndWait(); expect(createAlert).toHaveBeenLastCalledWith({ - message: `Network error: ${mockErrorMsg}`, + message: mockErrorMsg, }); expect(captureException).toHaveBeenCalledWith({ component: 'RunnerUpdateForm', - error: new Error(`Network error: ${mockErrorMsg}`), + error: new Error(mockErrorMsg), }); expect(findSubmitDisabledAttr()).toBeUndefined(); }); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 034b7848f35..7cb1f49d4f7 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -1,15 +1,19 @@ -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import { GlLink } from '@gitlab/ui'; -import { createLocalVue, shallowMount, mount } 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'; import { updateHistory } from '~/lib/utils/url_utility'; +import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; @@ -22,6 +26,7 @@ import { DEFAULT_SORT, INSTANCE_TYPE, GROUP_TYPE, + PROJECT_TYPE, PARAM_KEY_STATUS, STATUS_ACTIVE, RUNNER_PAGE_SIZE, @@ -33,8 +38,7 @@ import { captureException } from '~/runner/sentry_utils'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { groupRunnersData, groupRunnersDataPaginated, groupRunnersCountData } from '../mock_data'; -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); const mockGroupFullPath = 'group1'; const mockRegistrationToken = 'AABBCC'; @@ -54,6 +58,7 @@ describe('GroupRunnersApp', () => { const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); + const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationPrev = () => @@ -62,14 +67,18 @@ describe('GroupRunnersApp', () => { const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); - const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + const mockCountQueryResult = (count) => + Promise.resolve({ + data: { group: { id: groupRunnersCountData.data.group.id, runners: { count } } }, + }); + + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { const handlers = [ [getGroupRunnersQuery, mockGroupRunnersQuery], [getGroupRunnersCountQuery, mockGroupRunnersCountQuery], ]; wrapper = mountFn(GroupRunnersApp, { - localVue, apolloProvider: createMockApollo(handlers), propsData: { registrationToken: mockRegistrationToken, @@ -91,7 +100,7 @@ describe('GroupRunnersApp', () => { }); it('shows total runner counts', async () => { - createComponent({ mountFn: mount }); + createComponent({ mountFn: mountExtended }); await waitForPromises(); @@ -102,6 +111,44 @@ describe('GroupRunnersApp', () => { expect(stats).toMatch('Stale runners 2'); }); + it('shows the runner tabs with a runner count for each type', async () => { + mockGroupRunnersCountQuery.mockImplementation(({ type }) => { + switch (type) { + case GROUP_TYPE: + return mockCountQueryResult(2); + case PROJECT_TYPE: + return mockCountQueryResult(1); + default: + return mockCountQueryResult(4); + } + }); + + createComponent({ mountFn: mountExtended }); + await waitForPromises(); + + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText('All 4 Group 2 Project 1'); + }); + + it('shows the runner tabs with a formatted runner count', async () => { + mockGroupRunnersCountQuery.mockImplementation(({ type }) => { + switch (type) { + case GROUP_TYPE: + return mockCountQueryResult(2000); + case PROJECT_TYPE: + return mockCountQueryResult(1000); + default: + return mockCountQueryResult(3000); + } + }); + + createComponent({ mountFn: mountExtended }); + await waitForPromises(); + + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( + 'All 3,000 Group 2,000 Project 1,000', + ); + }); + it('shows the runner setup instructions', () => { expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); @@ -116,7 +163,7 @@ describe('GroupRunnersApp', () => { const { webUrl, node } = groupRunnersData.data.group.runners.edges[0]; const { id, shortSha } = node; - createComponent({ mountFn: mount }); + createComponent({ mountFn: mountExtended }); await waitForPromises(); @@ -136,7 +183,7 @@ describe('GroupRunnersApp', () => { }); it('sets tokens in the filtered search', () => { - createComponent({ mountFn: mount }); + createComponent({ mountFn: mountExtended }); const tokens = findFilteredSearch().props('tokens'); @@ -215,11 +262,13 @@ describe('GroupRunnersApp', () => { mockGroupRunnersQuery = jest.fn().mockResolvedValue({ data: { group: { + id: '1', runners: { nodes: [] }, }, }, }); createComponent(); + await waitForPromises(); }); it('shows a message for no results', async () => { @@ -228,9 +277,10 @@ describe('GroupRunnersApp', () => { }); describe('when runners query fails', () => { - beforeEach(() => { + beforeEach(async () => { mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); createComponent(); + await waitForPromises(); }); it('error is shown to the user', async () => { @@ -239,17 +289,18 @@ describe('GroupRunnersApp', () => { it('error is reported to sentry', async () => { expect(captureException).toHaveBeenCalledWith({ - error: new Error('Network error: Error!'), + error: new Error('Error!'), component: 'GroupRunnersApp', }); }); }); describe('Pagination', () => { - beforeEach(() => { + beforeEach(async () => { mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated); - createComponent({ mountFn: mount }); + createComponent({ mountFn: mountExtended }); + await waitForPromises(); }); it('more pages can be selected', () => { diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 9c430e205ea..d80caa47752 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -5,6 +5,9 @@ import runnersData from 'test_fixtures/graphql/runner/get_runners.query.graphql. import runnersCountData from 'test_fixtures/graphql/runner/get_runners_count.query.graphql.json'; import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query.graphql.paginated.json'; import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.json'; +import runnerWithGroupData from 'test_fixtures/graphql/runner/get_runner.query.graphql.with_group.json'; +import runnerProjectsData from 'test_fixtures/graphql/runner/get_runner_projects.query.graphql.json'; +import runnerJobsData from 'test_fixtures/graphql/runner/get_runner_jobs.query.graphql.json'; // Group queries import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json'; @@ -12,10 +15,13 @@ import groupRunnersCountData from 'test_fixtures/graphql/runner/get_group_runner import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.paginated.json'; export { - runnerData, + runnersData, runnersCountData, runnersDataPaginated, - runnersData, + runnerData, + runnerWithGroupData, + runnerProjectsData, + runnerJobsData, groupRunnersData, groupRunnersCountData, groupRunnersDataPaginated, diff --git a/spec/frontend/runner/utils_spec.js b/spec/frontend/runner/utils_spec.js new file mode 100644 index 00000000000..3fa9784ecdf --- /dev/null +++ b/spec/frontend/runner/utils_spec.js @@ -0,0 +1,65 @@ +import { formatJobCount, tableField, getPaginationVariables } from '~/runner/utils'; + +describe('~/runner/utils', () => { + describe('formatJobCount', () => { + it('formats a number', () => { + expect(formatJobCount(1)).toBe('1'); + expect(formatJobCount(99)).toBe('99'); + }); + + it('formats a large count', () => { + expect(formatJobCount(1000)).toBe('1,000'); + expect(formatJobCount(1001)).toBe('1,000+'); + }); + + it('returns an empty string for non-numeric values', () => { + expect(formatJobCount(undefined)).toBe(''); + expect(formatJobCount(null)).toBe(''); + expect(formatJobCount('number')).toBe(''); + }); + }); + + describe('tableField', () => { + it('a field with options', () => { + expect(tableField({ key: 'name' })).toEqual({ + key: 'name', + label: '', + tdAttr: { 'data-testid': 'td-name' }, + thClass: expect.any(Array), + }); + }); + + it('a field with a label', () => { + const label = 'A field name'; + + expect(tableField({ key: 'name', label })).toMatchObject({ + label, + }); + }); + + it('a field with custom classes', () => { + const mockClasses = ['foo', 'bar']; + + expect(tableField({ thClasses: mockClasses })).toMatchObject({ + thClass: expect.arrayContaining(mockClasses), + }); + }); + }); + + describe('getPaginationVariables', () => { + const after = 'AFTER_CURSOR'; + const before = 'BEFORE_CURSOR'; + + it.each` + case | pagination | pageSize | variables + ${'next page'} | ${{ after }} | ${undefined} | ${{ after, first: 10 }} + ${'prev page'} | ${{ before }} | ${undefined} | ${{ before, last: 10 }} + ${'first page'} | ${{}} | ${undefined} | ${{ first: 10 }} + ${'next page with N items'} | ${{ after }} | ${20} | ${{ after, first: 20 }} + ${'prev page with N items'} | ${{ before }} | ${20} | ${{ before, last: 20 }} + ${'first page with N items'} | ${{}} | ${20} | ${{ first: 20 }} + `('navigates to $case', ({ pagination, pageSize, variables }) => { + expect(getPaginationVariables(pagination, pageSize)).toEqual(variables); + }); + }); +}); |