diff options
Diffstat (limited to 'spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js')
-rw-r--r-- | spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js | 473 |
1 files changed, 473 insertions, 0 deletions
diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js new file mode 100644 index 00000000000..9778a6fe66c --- /dev/null +++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js @@ -0,0 +1,473 @@ +import Vue, { nextTick } from 'vue'; +import { GlToast, GlLink } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { + extendedWrapper, + shallowMountExtended, + mountExtended, +} from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { updateHistory } from '~/lib/utils/url_utility'; + +import { upgradeStatusTokenConfig } from 'ee_else_ce/ci/runner/components/search_tokens/upgrade_status_token_config'; +import { createLocalState } from '~/ci/runner/graphql/list/local_state'; +import AdminRunnersApp from '~/ci/runner/admin_runners/admin_runners_app.vue'; +import RunnerTypeTabs from '~/ci/runner/components/runner_type_tabs.vue'; +import RunnerFilteredSearchBar from '~/ci/runner/components/runner_filtered_search_bar.vue'; +import RunnerList from '~/ci/runner/components/runner_list.vue'; +import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue'; +import RunnerStats from '~/ci/runner/components/stat/runner_stats.vue'; +import RunnerActionsCell from '~/ci/runner/components/cells/runner_actions_cell.vue'; +import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue'; +import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; + +import { + ADMIN_FILTERED_SEARCH_NAMESPACE, + CREATED_ASC, + CREATED_DESC, + DEFAULT_SORT, + I18N_STATUS_ONLINE, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + I18N_INSTANCE_TYPE, + I18N_GROUP_TYPE, + I18N_PROJECT_TYPE, + INSTANCE_TYPE, + PARAM_KEY_PAUSED, + PARAM_KEY_STATUS, + PARAM_KEY_TAG, + STATUS_ONLINE, + DEFAULT_MEMBERSHIP, + RUNNER_PAGE_SIZE, +} from '~/ci/runner/constants'; +import allRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners.query.graphql'; +import allRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners_count.query.graphql'; +import { captureException } from '~/ci/runner/sentry_utils'; + +import { + allRunnersData, + runnersCountData, + allRunnersDataPaginated, + onlineContactTimeoutSecs, + staleTimeoutSecs, + emptyPageInfo, + emptyStateSvgPath, + emptyStateFilteredSvgPath, +} from '../mock_data'; + +const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +const mockRunners = allRunnersData.data.runners.nodes; +const mockRunnersCount = runnersCountData.data.runners.count; + +const mockRunnersHandler = jest.fn(); +const mockRunnersCountHandler = jest.fn(); + +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +Vue.use(VueApollo); +Vue.use(GlToast); + +const COUNT_QUERIES = 7; // 4 tabs + 3 status queries + +describe('AdminRunnersApp', () => { + let wrapper; + let showToast; + + const findRunnerStats = () => wrapper.findComponent(RunnerStats); + const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); + const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); + const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); + const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); + const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); + const findRunnerPaginationNext = () => findRunnerPagination().findByText(s__('Pagination|Next')); + const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + + const createComponent = ({ + props = {}, + mountFn = shallowMountExtended, + provide, + ...options + } = {}) => { + const { cacheConfig, localMutations } = createLocalState(); + + const handlers = [ + [allRunnersQuery, mockRunnersHandler], + [allRunnersCountQuery, mockRunnersCountHandler], + ]; + + wrapper = mountFn(AdminRunnersApp, { + apolloProvider: createMockApollo(handlers, {}, cacheConfig), + propsData: { + registrationToken: mockRegistrationToken, + ...props, + }, + provide: { + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, + ...provide, + }, + ...options, + }); + + showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); + + return waitForPromises(); + }; + + beforeEach(() => { + mockRunnersHandler.mockResolvedValue(allRunnersData); + mockRunnersCountHandler.mockResolvedValue(runnersCountData); + }); + + afterEach(() => { + mockRunnersHandler.mockReset(); + mockRunnersCountHandler.mockReset(); + showToast.mockReset(); + wrapper.destroy(); + }); + + it('shows the runner setup instructions', () => { + createComponent(); + + expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE); + }); + + describe('shows total runner counts', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + }); + + it('fetches counts', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); + }); + + it('shows the runner tabs', () => { + const tabs = findRunnerTypeTabs().text(); + expect(tabs).toMatchInterpolatedText( + `All ${mockRunnersCount} ${I18N_INSTANCE_TYPE} ${mockRunnersCount} ${I18N_GROUP_TYPE} ${mockRunnersCount} ${I18N_PROJECT_TYPE} ${mockRunnersCount}`, + ); + }); + + it('shows the total', () => { + expect(findRunnerStats().text()).toContain(`${I18N_STATUS_ONLINE} ${mockRunnersCount}`); + expect(findRunnerStats().text()).toContain(`${I18N_STATUS_OFFLINE} ${mockRunnersCount}`); + expect(findRunnerStats().text()).toContain(`${I18N_STATUS_STALE} ${mockRunnersCount}`); + }); + }); + + it('shows the runners list', async () => { + await createComponent(); + + expect(mockRunnersHandler).toHaveBeenCalledTimes(1); + expect(findRunnerList().props('runners')).toEqual(mockRunners); + }); + + it('runner item links to the runner admin page', async () => { + await createComponent({ mountFn: mountExtended }); + + const { id, shortSha } = mockRunners[0]; + const numericId = getIdFromGraphQLId(id); + + const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink); + + expect(runnerLink.text()).toBe(`#${numericId} (${shortSha})`); + expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`); + }); + + it('renders runner actions for each runner', async () => { + await createComponent({ mountFn: mountExtended }); + + const runnerActions = wrapper + .find('tr [data-testid="td-actions"]') + .findComponent(RunnerActionsCell); + const runner = mockRunners[0]; + + expect(runnerActions.props()).toEqual({ + runner, + editUrl: runner.editAdminUrl, + }); + }); + + it('requests the runners with no filters', async () => { + await createComponent(); + + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ + status: undefined, + type: undefined, + membership: DEFAULT_MEMBERSHIP, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('sets tokens in the filtered search', () => { + createComponent(); + + expect(findRunnerFilteredSearchBar().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_PAUSED, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + recentSuggestionsStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, + }), + upgradeStatusTokenConfig, + ]); + }); + + describe('Single runner row', () => { + const { id: graphqlId, shortSha } = mockRunners[0]; + const id = getIdFromGraphQLId(graphqlId); + + beforeEach(async () => { + mockRunnersCountHandler.mockClear(); + + await createComponent({ mountFn: mountExtended }); + }); + + it('Links to the runner page', async () => { + const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink); + + expect(runnerLink.text()).toBe(`#${id} (${shortSha})`); + expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`); + }); + + it('When runner is paused or unpaused, some data is refetched', async () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); + + findRunnerActionsCell().vm.$emit('toggledPaused'); + + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); + expect(showToast).toHaveBeenCalledTimes(0); + }); + + it('When runner is deleted, data is refetched and a toast message is shown', async () => { + findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Runner deleted'); + }); + }); + + describe('when a filter is preselected', () => { + beforeEach(async () => { + setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}&paused[]=true`); + + await createComponent({ mountFn: mountExtended }); + }); + + it('sets the filters in the search bar', () => { + expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + runnerType: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, + filters: [ + { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, + { type: PARAM_KEY_PAUSED, value: { data: 'true', operator: '=' } }, + ], + sort: 'CREATED_DESC', + pagination: {}, + }); + }); + + it('requests the runners with filter parameters', () => { + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ + status: STATUS_ONLINE, + type: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, + paused: true, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('fetches count results for requested status', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledWith({ + type: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, + status: STATUS_ONLINE, + paused: true, + }); + }); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + + findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + sort: CREATED_ASC, + }); + + await nextTick(); + }); + + it('updates the browser url', () => { + expect(updateHistory).toHaveBeenLastCalledWith({ + title: expect.any(String), + url: expect.stringContaining('?status[]=ONLINE&sort=CREATED_ASC'), + }); + }); + + it('requests the runners with filters', () => { + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ + status: STATUS_ONLINE, + membership: DEFAULT_MEMBERSHIP, + sort: CREATED_ASC, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('fetches count results for requested status', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledWith({ + status: STATUS_ONLINE, + membership: DEFAULT_MEMBERSHIP, + }); + }); + }); + + it('when runners have not loaded, shows a loading state', () => { + createComponent(); + expect(findRunnerList().props('loading')).toBe(true); + expect(findRunnerPagination().attributes('disabled')).toBe('true'); + }); + + describe('Bulk delete', () => { + describe('Before runners are deleted', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + }); + + it('runner list is checkable', () => { + expect(findRunnerList().props('checkable')).toBe(true); + }); + }); + + describe('When runners are deleted', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + }); + + it('count data is refetched', async () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); + + findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); + + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); + }); + + it('toast is shown', async () => { + expect(showToast).toHaveBeenCalledTimes(0); + + findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Runners deleted'); + }); + }); + }); + + describe('when no runners are found', () => { + beforeEach(async () => { + mockRunnersHandler.mockResolvedValue({ + data: { + runners: { + nodes: [], + pageInfo: emptyPageInfo, + }, + }, + }); + + await createComponent(); + }); + + it('shows no errors', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('shows an empty state', () => { + expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(false); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(async () => { + findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], + sort: CREATED_ASC, + }); + await waitForPromises(); + }); + + it('shows an empty state for a filtered search', () => { + expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(true); + }); + }); + }); + + describe('when runners query fails', () => { + beforeEach(async () => { + mockRunnersHandler.mockRejectedValue(new Error('Error!')); + await createComponent(); + }); + + it('error is shown to the user', async () => { + expect(createAlert).toHaveBeenCalledTimes(1); + }); + + it('error is reported to sentry', async () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Error!'), + component: 'AdminRunnersApp', + }); + }); + }); + + describe('Pagination', () => { + const { pageInfo } = allRunnersDataPaginated.data.runners; + + beforeEach(async () => { + mockRunnersHandler.mockResolvedValue(allRunnersDataPaginated); + + await createComponent({ mountFn: mountExtended }); + }); + + it('passes the page info', () => { + expect(findRunnerPagination().props('pageInfo')).toEqual(pageInfo); + }); + + it('navigates to the next page', async () => { + await findRunnerPaginationNext().trigger('click'); + + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ + membership: DEFAULT_MEMBERSHIP, + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: pageInfo.endCursor, + }); + }); + }); +}); |