diff options
Diffstat (limited to 'spec/frontend/ci/runner/group_runners/group_runners_app_spec.js')
-rw-r--r-- | spec/frontend/ci/runner/group_runners/group_runners_app_spec.js | 492 |
1 files changed, 492 insertions, 0 deletions
diff --git a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js new file mode 100644 index 00000000000..c3493b3c9fd --- /dev/null +++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js @@ -0,0 +1,492 @@ +import Vue, { nextTick } from 'vue'; +import { GlButton, GlLink, GlToast } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { + extendedWrapper, + shallowMountExtended, + mountExtended, +} from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { updateHistory } from '~/lib/utils/url_utility'; +import { upgradeStatusTokenConfig } from 'ee_else_ce/ci/runner/components/search_tokens/upgrade_status_token_config'; +import { createLocalState } from '~/ci/runner/graphql/list/local_state'; + +import RunnerTypeTabs from '~/ci/runner/components/runner_type_tabs.vue'; +import RunnerFilteredSearchBar from '~/ci/runner/components/runner_filtered_search_bar.vue'; +import RunnerList from '~/ci/runner/components/runner_list.vue'; +import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue'; +import RunnerStats from '~/ci/runner/components/stat/runner_stats.vue'; +import RunnerActionsCell from '~/ci/runner/components/cells/runner_actions_cell.vue'; +import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue'; +import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; +import RunnerMembershipToggle from '~/ci/runner/components/runner_membership_toggle.vue'; + +import { + CREATED_ASC, + CREATED_DESC, + DEFAULT_SORT, + I18N_STATUS_ONLINE, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + INSTANCE_TYPE, + GROUP_TYPE, + PARAM_KEY_PAUSED, + PARAM_KEY_STATUS, + PARAM_KEY_TAG, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, + MEMBERSHIP_ALL_AVAILABLE, + MEMBERSHIP_DESCENDANTS, + RUNNER_PAGE_SIZE, + I18N_EDIT, +} from '~/ci/runner/constants'; +import groupRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners.query.graphql'; +import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql'; +import GroupRunnersApp from '~/ci/runner/group_runners/group_runners_app.vue'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { + groupRunnersData, + groupRunnersDataPaginated, + groupRunnersCountData, + onlineContactTimeoutSecs, + staleTimeoutSecs, + emptyPageInfo, + emptyStateSvgPath, + emptyStateFilteredSvgPath, +} from '../mock_data'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +const mockGroupFullPath = 'group1'; +const mockRegistrationToken = 'AABBCC'; +const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges; +const mockGroupRunnersCount = mockGroupRunnersEdges.length; + +const mockGroupRunnersHandler = jest.fn(); +const mockGroupRunnersCountHandler = jest.fn(); + +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +describe('GroupRunnersApp', () => { + let wrapper; + + const findRunnerStats = () => wrapper.findComponent(RunnerStats); + const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); + const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); + const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); + const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); + const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`)); + const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); + const findRunnerPaginationNext = () => findRunnerPagination().findByText(s__('Pagination|Next')); + const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + const findRunnerMembershipToggle = () => wrapper.findComponent(RunnerMembershipToggle); + + const createComponent = ({ + props = {}, + provide = {}, + mountFn = shallowMountExtended, + ...options + } = {}) => { + const { cacheConfig, localMutations } = createLocalState(); + + const handlers = [ + [groupRunnersQuery, mockGroupRunnersHandler], + [groupRunnersCountQuery, mockGroupRunnersCountHandler], + ]; + + wrapper = mountFn(GroupRunnersApp, { + apolloProvider: createMockApollo(handlers, {}, cacheConfig), + propsData: { + registrationToken: mockRegistrationToken, + groupFullPath: mockGroupFullPath, + groupRunnersLimitedCount: mockGroupRunnersCount, + ...props, + }, + provide: { + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, + ...provide, + }, + ...options, + }); + + return waitForPromises(); + }; + + beforeEach(() => { + mockGroupRunnersHandler.mockResolvedValue(groupRunnersData); + mockGroupRunnersCountHandler.mockResolvedValue(groupRunnersCountData); + }); + + afterEach(() => { + mockGroupRunnersHandler.mockReset(); + mockGroupRunnersCountHandler.mockReset(); + wrapper.destroy(); + }); + + it('shows the runner tabs with a runner count for each type', async () => { + await createComponent({ mountFn: mountExtended }); + + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( + `All ${mockGroupRunnersCount} Group ${mockGroupRunnersCount} Project ${mockGroupRunnersCount}`, + ); + }); + + it('shows the runner setup instructions', () => { + createComponent(); + + expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); + }); + + describe('show all available runners toggle', () => { + it('shows the membership toggle', () => { + createComponent(); + expect(findRunnerMembershipToggle().exists()).toBe(true); + }); + + it('sets the membership toggle', () => { + setWindowLocation(`?membership[]=${MEMBERSHIP_ALL_AVAILABLE}`); + + createComponent(); + + expect(findRunnerMembershipToggle().props('value')).toBe(MEMBERSHIP_ALL_AVAILABLE); + }); + + it('requests filter', async () => { + createComponent(); + findRunnerMembershipToggle().vm.$emit('input', MEMBERSHIP_ALL_AVAILABLE); + + await waitForPromises(); + + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + membership: MEMBERSHIP_ALL_AVAILABLE, + }), + ); + }); + }); + + it('shows total runner counts', async () => { + await createComponent({ mountFn: mountExtended }); + + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, + groupFullPath: mockGroupFullPath, + }); + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + status: STATUS_OFFLINE, + membership: MEMBERSHIP_DESCENDANTS, + groupFullPath: mockGroupFullPath, + }); + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + status: STATUS_STALE, + membership: MEMBERSHIP_DESCENDANTS, + groupFullPath: mockGroupFullPath, + }); + + const text = findRunnerStats().text(); + expect(text).toContain(`${I18N_STATUS_ONLINE} ${mockGroupRunnersCount}`); + expect(text).toContain(`${I18N_STATUS_OFFLINE} ${mockGroupRunnersCount}`); + expect(text).toContain(`${I18N_STATUS_STALE} ${mockGroupRunnersCount}`); + }); + + it('shows the runners list', async () => { + await createComponent(); + + const runners = findRunnerList().props('runners'); + expect(runners).toEqual(mockGroupRunnersEdges.map(({ node }) => node)); + }); + + it('requests the runners with group path and no other filters', async () => { + await createComponent(); + + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: undefined, + type: undefined, + membership: MEMBERSHIP_DESCENDANTS, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('sets tokens in the filtered search', () => { + createComponent(); + + const tokens = findRunnerFilteredSearchBar().props('tokens'); + + expect(tokens).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_PAUSED, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + suggestionsDisabled: true, + }), + upgradeStatusTokenConfig, + ]); + }); + + describe('Single runner row', () => { + let showToast; + + const { webUrl, editUrl, node } = mockGroupRunnersEdges[0]; + const { id: graphqlId, shortSha } = node; + const id = getIdFromGraphQLId(graphqlId); + const COUNT_QUERIES = 6; // Smart queries that display a filtered count of runners + const FILTERED_COUNT_QUERIES = 6; // Smart queries that display a count of runners in tabs and single stats + + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); + }); + + it('view link is displayed correctly', () => { + const viewLink = findRunnerRow(id).findByTestId('td-summary').findComponent(GlLink); + + expect(viewLink.text()).toBe(`#${id} (${shortSha})`); + expect(viewLink.attributes('href')).toBe(webUrl); + }); + + it('edit link is displayed correctly', () => { + const editLink = findRunnerRow(id).findByTestId('td-actions').findComponent(GlButton); + + expect(editLink.attributes()).toMatchObject({ + 'aria-label': I18N_EDIT, + href: editUrl, + }); + }); + + it('When runner is paused or unpaused, some data is refetched', async () => { + expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); + + findRunnerActionsCell().vm.$emit('toggledPaused'); + + expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes( + COUNT_QUERIES + FILTERED_COUNT_QUERIES, + ); + + expect(showToast).toHaveBeenCalledTimes(0); + }); + + it('When runner is deleted, data is refetched and a toast message is shown', async () => { + findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Runner deleted'); + }); + }); + + describe('when a filter is preselected', () => { + beforeEach(async () => { + setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}`); + + await createComponent({ mountFn: mountExtended }); + }); + + it('sets the filters in the search bar', () => { + expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + runnerType: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, + filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], + sort: 'CREATED_DESC', + pagination: {}, + }); + }); + + it('requests the runners with filter parameters', () => { + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ONLINE, + type: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('fetches count results for requested status', () => { + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + type: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, + status: STATUS_ONLINE, + }); + }); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + + findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, + membership: MEMBERSHIP_DESCENDANTS, + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + sort: CREATED_ASC, + }); + + await nextTick(); + }); + + it('updates the browser url', () => { + expect(updateHistory).toHaveBeenLastCalledWith({ + title: expect.any(String), + url: expect.stringContaining('?status[]=ONLINE&sort=CREATED_ASC'), + }); + }); + + it('requests the runners with filters', () => { + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, + sort: CREATED_ASC, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('fetches count results for requested status', () => { + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, + }); + }); + }); + + it('when runners have not loaded, shows a loading state', () => { + createComponent(); + expect(findRunnerList().props('loading')).toBe(true); + expect(findRunnerPagination().attributes('disabled')).toBe('true'); + }); + + it('runners can be deleted in bulk', () => { + createComponent(); + expect(findRunnerList().props('checkable')).toBe(true); + }); + + describe('when no runners are found', () => { + beforeEach(async () => { + mockGroupRunnersHandler.mockResolvedValue({ + data: { + group: { + id: '1', + runners: { + edges: [], + pageInfo: emptyPageInfo, + }, + }, + }, + }); + await createComponent(); + }); + + it('shows no errors', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('shows an empty state', async () => { + expect(findRunnerListEmptyState().exists()).toBe(true); + }); + }); + + describe('when runners query fails', () => { + beforeEach(async () => { + mockGroupRunnersHandler.mockRejectedValue(new Error('Error!')); + await createComponent(); + }); + + it('error is shown to the user', async () => { + expect(createAlert).toHaveBeenCalledTimes(1); + }); + + it('error is reported to sentry', async () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Error!'), + component: 'GroupRunnersApp', + }); + }); + }); + + describe('Pagination', () => { + const { pageInfo } = groupRunnersDataPaginated.data.group.runners; + + beforeEach(async () => { + mockGroupRunnersHandler.mockResolvedValue(groupRunnersDataPaginated); + + await createComponent({ mountFn: mountExtended }); + }); + + it('passes the page info', () => { + expect(findRunnerPagination().props('pageInfo')).toEqual(pageInfo); + }); + + it('navigates to the next page', async () => { + await findRunnerPaginationNext().trigger('click'); + + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + membership: MEMBERSHIP_DESCENDANTS, + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: pageInfo.endCursor, + }); + }); + }); + + describe('when user has permission to register group runner', () => { + beforeEach(() => { + createComponent({ + propsData: { + registrationToken: mockRegistrationToken, + groupFullPath: mockGroupFullPath, + groupRunnersLimitedCount: mockGroupRunnersCount, + }, + }); + }); + + it('shows the register group runner button', () => { + expect(findRegistrationDropdown().exists()).toBe(true); + }); + }); + + describe('when user has no permission to register group runner', () => { + beforeEach(() => { + createComponent({ + propsData: { + registrationToken: null, + groupFullPath: mockGroupFullPath, + groupRunnersLimitedCount: mockGroupRunnersCount, + }, + }); + }); + + it('does not show the register group runner button', () => { + expect(findRegistrationDropdown().exists()).toBe(false); + }); + }); +}); |