Skip to content
Snippets Groups Projects
Commit 6f2ecbd3 authored by Miguel Rincon's avatar Miguel Rincon
Browse files

Filter and search a list of group runners

This change adds the capability of filtering and pagination for group
runners, group administrators can search and locate their runners.
parent 61508b55
No related branches found
No related tags found
No related merge requests found
Showing
with 529 additions and 144 deletions
Loading
Loading
@@ -2,12 +2,16 @@
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, __ } from '~/locale';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
import { INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
import { typeTokenConfig } from '../components/search_tokens/type_token_config';
import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
import {
fromUrlQueryToSearch,
Loading
Loading
@@ -78,6 +82,21 @@ export default {
noRunnersFound() {
return !this.runnersLoading && !this.runners.items.length;
},
activeRunnersMessage() {
return sprintf(__('Runners currently online: %{active_runners_count}'), {
active_runners_count: formatNumber(this.activeRunnersCount),
});
},
searchTokens() {
return [
statusTokenConfig,
typeTokenConfig,
{
...tagTokenConfig,
recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`,
},
];
},
},
watch: {
search: {
Loading
Loading
@@ -99,6 +118,7 @@ export default {
captureException({ error, component: this.$options.name });
},
},
filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE,
INSTANCE_TYPE,
};
</script>
Loading
Loading
@@ -118,9 +138,13 @@ export default {
 
<runner-filtered-search-bar
v-model="search"
namespace="admin_runners"
:active-runners-count="activeRunnersCount"
/>
:tokens="searchTokens"
:namespace="$options.filteredSearchNamespace"
>
<template #runner-count>
{{ activeRunnersMessage }}
</template>
</runner-filtered-search-bar>
 
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
Loading
Loading
<script>
import { cloneDeep } from 'lodash';
import { formatNumber, sprintf, __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import { __ } from '~/locale';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import {
STATUS_ACTIVE,
STATUS_PAUSED,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NOT_CONNECTED,
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
CREATED_DESC,
CREATED_ASC,
CONTACTED_DESC,
CONTACTED_ASC,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_TAG,
} from '../constants';
import TagToken from './search_tokens/tag_token.vue';
import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants';
 
const sortOptions = [
{
Loading
Loading
@@ -58,10 +39,6 @@ export default {
type: String,
required: true,
},
activeRunnersCount: {
type: Number,
required: true,
},
},
data() {
// filtered_search_bar_root.vue may mutate the inital
Loading
Loading
@@ -73,62 +50,6 @@ export default {
initialSortBy: sort,
};
},
computed: {
searchTokens() {
return [
{
icon: 'status',
title: __('Status'),
type: PARAM_KEY_STATUS,
token: BaseToken,
unique: true,
options: [
{ value: STATUS_ACTIVE, title: s__('Runners|Active') },
{ value: STATUS_PAUSED, title: s__('Runners|Paused') },
{ value: STATUS_ONLINE, title: s__('Runners|Online') },
{ value: STATUS_OFFLINE, title: s__('Runners|Offline') },
// Added extra quotes in this title to avoid splitting this value:
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
{ value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
],
// TODO In principle we could support more complex search rules,
// this can be added to a separate issue.
operators: OPERATOR_IS_ONLY,
},
{
icon: 'file-tree',
title: __('Type'),
type: PARAM_KEY_RUNNER_TYPE,
token: BaseToken,
unique: true,
options: [
{ value: INSTANCE_TYPE, title: s__('Runners|instance') },
{ value: GROUP_TYPE, title: s__('Runners|group') },
{ value: PROJECT_TYPE, title: s__('Runners|project') },
],
// TODO We should support more complex search rules,
// search for multiple states (OR) or have NOT operators
operators: OPERATOR_IS_ONLY,
},
{
icon: 'tag',
title: s__('Runners|Tags'),
type: PARAM_KEY_TAG,
token: TagToken,
recentTokenValuesStorageKey: `${this.namespace}-recent-tags`,
operators: OPERATOR_IS_ONLY,
},
];
},
activeRunnersMessage() {
return sprintf(__('Runners currently online: %{active_runners_count}'), {
active_runners_count: formatNumber(this.activeRunnersCount),
});
},
},
methods: {
onFilter(filters) {
const { sort } = this.value;
Loading
Loading
@@ -161,12 +82,13 @@ export default {
:sort-options="$options.sortOptions"
:initial-filter-value="initialFilterValue"
:initial-sort-by="initialSortBy"
:tokens="searchTokens"
:search-input-placeholder="__('Search or filter results...')"
data-testid="runners-filtered-search"
@onFilter="onFilter"
@onSort="onSort"
/>
<div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div>
<div class="gl-text-right" data-testid="runner-count">
<slot name="runner-count"></slot>
</div>
</div>
</template>
import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import {
STATUS_ACTIVE,
STATUS_PAUSED,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NOT_CONNECTED,
PARAM_KEY_STATUS,
} from '../../constants';
export const statusTokenConfig = {
icon: 'status',
title: __('Status'),
type: PARAM_KEY_STATUS,
token: BaseToken,
unique: true,
options: [
{ value: STATUS_ACTIVE, title: s__('Runners|Active') },
{ value: STATUS_PAUSED, title: s__('Runners|Paused') },
{ value: STATUS_ONLINE, title: s__('Runners|Online') },
{ value: STATUS_OFFLINE, title: s__('Runners|Offline') },
// Added extra quotes in this title to avoid splitting this value:
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
{ value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
],
// TODO In principle we could support more complex search rules,
// this can be added to a separate issue.
operators: OPERATOR_IS_ONLY,
};
Loading
Loading
@@ -33,6 +33,7 @@ export default {
// The API should
// 1) scope to the rights of the user
// 2) stay up to date to the removal of old tags
// 3) consider the scope of search, like searching within the tags of a group
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796
return axios
.get(TAG_SUGGESTIONS_PATH, {
Loading
Loading
import { s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import { PARAM_KEY_TAG } from '../../constants';
import TagToken from './tag_token.vue';
export const tagTokenConfig = {
icon: 'tag',
title: s__('Runners|Tags'),
type: PARAM_KEY_TAG,
token: TagToken,
operators: OPERATOR_IS_ONLY,
};
import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, PARAM_KEY_RUNNER_TYPE } from '../../constants';
export const typeTokenConfig = {
icon: 'file-tree',
title: __('Type'),
type: PARAM_KEY_RUNNER_TYPE,
token: BaseToken,
unique: true,
options: [
{ value: INSTANCE_TYPE, title: s__('Runners|instance') },
{ value: GROUP_TYPE, title: s__('Runners|group') },
{ value: PROJECT_TYPE, title: s__('Runners|project') },
],
// TODO We should support more complex search rules,
// search for multiple states (OR) or have NOT operators
operators: OPERATOR_IS_ONLY,
};
Loading
Loading
@@ -2,6 +2,7 @@ import { s__ } from '~/locale';
 
export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000;
export const GROUP_RUNNER_COUNT_LIMIT = 1000;
 
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
Loading
Loading
@@ -50,3 +51,8 @@ export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API
export const CONTACTED_ASC = 'CONTACTED_ASC';
 
export const DEFAULT_SORT = CREATED_DESC;
// Local storage namespaces
export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners';
export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners';
#import "~/runner/graphql/runner_node.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
 
query getGroupRunners($groupFullPath: ID!) {
query getGroupRunners(
$groupFullPath: ID!
$before: String
$after: String
$first: Int
$last: Int
$status: CiRunnerStatus
$type: CiRunnerType
$search: String
$sort: CiRunnerSort
) {
group(fullPath: $groupFullPath) {
runners(membership: DESCENDANTS) {
runners(
membership: DESCENDANTS
before: $before
after: $after
first: $first
last: $last
status: $status
type: $type
search: $search
sort: $sort
) {
nodes {
...RunnerNode
}
pageInfo {
...PageInfo
}
}
}
}
<script>
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, s__ } from '~/locale';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
import { I18N_FETCH_ERROR, GROUP_TYPE } from '../constants';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { typeTokenConfig } from '../components/search_tokens/type_token_config';
import {
I18N_FETCH_ERROR,
GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE,
GROUP_RUNNER_COUNT_LIMIT,
} from '../constants';
import getGroupRunnersQuery from '../graphql/get_group_runners.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
 
export default {
name: 'GroupRunnersApp',
components: {
RunnerFilteredSearchBar,
RunnerList,
RunnerManualSetupHelp,
RunnerTypeHelp,
RunnerPagination,
},
props: {
registrationToken: {
Loading
Loading
@@ -24,11 +42,17 @@ export default {
type: String,
required: true,
},
groupRunnersLimitedCount: {
type: Number,
required: true,
},
},
data() {
return {
search: fromUrlQueryToSearch(),
runners: {
items: [],
pageInfo: {},
},
};
},
Loading
Loading
@@ -43,9 +67,10 @@ export default {
return this.variables;
},
update(data) {
const { runners } = data?.group;
const { runners } = data?.group || {};
return {
items: runners?.nodes || [],
pageInfo: runners?.pageInfo || {},
};
},
error(error) {
Loading
Loading
@@ -58,6 +83,7 @@ export default {
computed: {
variables() {
return {
...fromSearchToVariables(this.search),
groupFullPath: this.groupFullPath,
};
},
Loading
Loading
@@ -67,6 +93,35 @@ export default {
noRunnersFound() {
return !this.runnersLoading && !this.runners.items.length;
},
groupRunnersCount() {
if (this.groupRunnersLimitedCount > GROUP_RUNNER_COUNT_LIMIT) {
return `${formatNumber(GROUP_RUNNER_COUNT_LIMIT)}+`;
}
return formatNumber(this.groupRunnersLimitedCount);
},
runnerCountMessage() {
return sprintf(s__('Runners|Runners in this group: %{groupRunnersCount}'), {
groupRunnersCount: this.groupRunnersCount,
});
},
searchTokens() {
return [statusTokenConfig, typeTokenConfig];
},
filteredSearchNamespace() {
return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`;
},
},
watch: {
search: {
deep: true,
handler() {
// TODO Implement back button reponse using onpopstate
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
});
},
},
},
errorCaptured(error) {
this.reportToSentry(error);
Loading
Loading
@@ -94,11 +149,22 @@ export default {
</div>
</div>
 
<runner-filtered-search-bar
v-model="search"
:tokens="searchTokens"
:namespace="filteredSearchNamespace"
>
<template #runner-count>
{{ runnerCountMessage }}
</template>
</runner-filtered-search-bar>
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>
<template v-else>
<runner-list :runners="runners.items" :loading="runnersLoading" />
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
</template>
</div>
</template>
Loading
Loading
@@ -12,7 +12,13 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
return null;
}
 
const { registrationToken, runnerInstallHelpPage, groupId, groupFullPath } = el.dataset;
const {
registrationToken,
runnerInstallHelpPage,
groupId,
groupFullPath,
groupRunnersLimitedCount,
} = el.dataset;
 
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
Loading
Loading
@@ -35,6 +41,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
props: {
registrationToken,
groupFullPath,
groupRunnersLimitedCount: parseInt(groupRunnersLimitedCount, 10),
},
});
},
Loading
Loading
Loading
Loading
@@ -10,6 +10,8 @@ class Groups::RunnersController < Groups::ApplicationController
feature_category :runner
 
def index
finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group })
@group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000)
end
 
def runner_list_group_view_vue_ui_enabled
Loading
Loading
Loading
Loading
@@ -3,4 +3,4 @@
%h2.page-title
= s_('Runners|Group Runners')
 
#js-group-runners{ data: { registration_token: @group.runners_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', group_id: @group.id, group_full_path: @group.full_path } }
#js-group-runners{ data: { registration_token: @group.runners_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', group_id: @group.id, group_full_path: @group.full_path, group_runners_limited_count: @group_runners_limited_count } }
Loading
Loading
@@ -28892,6 +28892,9 @@ msgstr ""
msgid "Runners|Runners"
msgstr ""
 
msgid "Runners|Runners in this group: %{groupRunnersCount}"
msgstr ""
msgid "Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner."
msgstr ""
 
Loading
Loading
Loading
Loading
@@ -3,11 +3,13 @@
require 'spec_helper'
 
RSpec.describe Groups::RunnersController do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:runner) { create(:ci_runner, :group, groups: [group]) }
let(:project) { create(:project, group: group) }
let(:runner_project) { create(:ci_runner, :project, projects: [project]) }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let!(:runner) { create(:ci_runner, :group, groups: [group]) }
let!(:runner_project) { create(:ci_runner, :project, projects: [project]) }
let(:params_runner_project) { { group_id: group, id: runner_project } }
let(:params) { { group_id: group, id: runner } }
 
Loading
Loading
@@ -26,6 +28,7 @@
 
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
expect(assigns(:group_runners_limited_count)).to be(2)
end
end
 
Loading
Loading
Loading
Loading
@@ -94,5 +94,14 @@
 
expect_graphql_errors_to_be_empty
end
it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do
post_graphql(query, current_user: group_owner, variables: {
groupFullPath: group.full_path,
first: 1
})
expect_graphql_errors_to_be_empty
end
end
end
Loading
Loading
@@ -2,6 +2,7 @@ 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 waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
Loading
Loading
@@ -14,16 +15,20 @@ import RunnerPagination from '~/runner/components/runner_pagination.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
 
import {
ADMIN_FILTERED_SEARCH_NAMESPACE,
CREATED_ASC,
CREATED_DESC,
DEFAULT_SORT,
INSTANCE_TYPE,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_TAG,
STATUS_ACTIVE,
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import { captureException } from '~/runner/sentry_utils';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
 
import { runnersData, runnersDataPaginated } from '../mock_data';
 
Loading
Loading
@@ -47,10 +52,14 @@ describe('AdminRunnersApp', () => {
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => wrapper.findComponent(RunnerPagination);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () =>
findRunnerPagination().findByLabelText('Go to previous page');
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
 
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getRunnersQuery, mockRunnersQuery]];
 
wrapper = mountFn(AdminRunnersApp, {
Loading
Loading
@@ -68,7 +77,7 @@ describe('AdminRunnersApp', () => {
setWindowLocation('/admin/runners');
 
mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
createComponentWithApollo();
createComponent();
await waitForPromises();
});
 
Loading
Loading
@@ -77,6 +86,14 @@ describe('AdminRunnersApp', () => {
wrapper.destroy();
});
 
it('shows the runner type help', () => {
expect(findRunnerTypeHelp().exists()).toBe(true);
});
it('shows the runner setup instructions', () => {
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
});
it('shows the runners list', () => {
expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes);
});
Loading
Loading
@@ -90,20 +107,38 @@ describe('AdminRunnersApp', () => {
});
});
 
it('shows the runner type help', () => {
expect(findRunnerTypeHelp().exists()).toBe(true);
it('sets tokens in the filtered search', () => {
createComponent({ mountFn: mount });
expect(findFilteredSearch().props('tokens')).toEqual([
expect.objectContaining({
type: PARAM_KEY_STATUS,
options: expect.any(Array),
}),
expect.objectContaining({
type: PARAM_KEY_RUNNER_TYPE,
options: expect.any(Array),
}),
expect.objectContaining({
type: PARAM_KEY_TAG,
recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`,
}),
]);
});
 
it('shows the runner setup instructions', () => {
expect(findRunnerManualSetupHelp().exists()).toBe(true);
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
it('shows the active runner count', () => {
createComponent({ mountFn: mount });
expect(findRunnerFilteredSearchBar().text()).toMatch(
`Runners currently online: ${mockActiveRunnersCount}`,
);
});
 
describe('when a filter is preselected', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
 
createComponentWithApollo();
createComponent();
await waitForPromises();
});
 
Loading
Loading
@@ -133,7 +168,7 @@ describe('AdminRunnersApp', () => {
describe('when a filter is selected by the user', () => {
beforeEach(() => {
findRunnerFilteredSearchBar().vm.$emit('input', {
filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }],
filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }],
sort: CREATED_ASC,
});
});
Loading
Loading
@@ -154,11 +189,19 @@ describe('AdminRunnersApp', () => {
});
});
 
it('when runners have not loaded, shows a loading state', () => {
createComponent();
expect(findRunnerList().props('loading')).toBe(true);
});
describe('when no runners are found', () => {
beforeEach(async () => {
mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } });
createComponentWithApollo();
await waitForPromises();
mockRunnersQuery = jest.fn().mockResolvedValue({
data: {
runners: { nodes: [] },
},
});
createComponent();
});
 
it('shows a message for no results', async () => {
Loading
Loading
@@ -166,17 +209,14 @@ describe('AdminRunnersApp', () => {
});
});
 
it('when runners have not loaded, shows a loading state', () => {
createComponentWithApollo();
expect(findRunnerList().props('loading')).toBe(true);
});
describe('when runners query fails', () => {
beforeEach(async () => {
beforeEach(() => {
mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
createComponentWithApollo();
createComponent();
});
 
await waitForPromises();
it('error is shown to the user', async () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
 
it('error is reported to sentry', async () => {
Loading
Loading
@@ -185,17 +225,13 @@ describe('AdminRunnersApp', () => {
component: 'AdminRunnersApp',
});
});
it('error is shown to the user', async () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
 
describe('Pagination', () => {
beforeEach(() => {
mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated);
 
createComponentWithApollo({ mountFn: mount });
createComponent({ mountFn: mount });
});
 
it('more pages can be selected', () => {
Loading
Loading
@@ -203,14 +239,11 @@ describe('AdminRunnersApp', () => {
});
 
it('cannot navigate to the previous page', () => {
expect(findRunnerPagination().find('[aria-disabled]').text()).toBe('Prev');
expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true');
});
 
it('navigates to the next page', async () => {
const nextPageBtn = findRunnerPagination().find('a');
expect(nextPageBtn.text()).toBe('Next');
await nextPageBtn.trigger('click');
await findRunnerPaginationNext().trigger('click');
 
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
sort: CREATED_DESC,
Loading
Loading
Loading
Loading
@@ -2,8 +2,16 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config';
import TagToken from '~/runner/components/search_tokens/tag_token.vue';
import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG } from '~/runner/constants';
import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config';
import { typeTokenConfig } from '~/runner/components/search_tokens/type_token_config';
import {
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_TAG,
STATUS_ACTIVE,
} from '~/runner/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
 
Loading
Loading
@@ -13,12 +21,12 @@ describe('RunnerList', () => {
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem);
const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message');
const findActiveRunnersMessage = () => wrapper.findByTestId('runner-count');
 
const mockDefaultSort = 'CREATED_DESC';
const mockOtherSort = 'CONTACTED_DESC';
const mockFilters = [
{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } },
{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
];
const mockActiveRunnersCount = 2;
Loading
Loading
@@ -28,13 +36,16 @@ describe('RunnerList', () => {
shallowMount(RunnerFilteredSearchBar, {
propsData: {
namespace: 'runners',
tokens: [],
value: {
filters: [],
sort: mockDefaultSort,
},
activeRunnersCount: mockActiveRunnersCount,
...props,
},
slots: {
'runner-count': `Runners currently online: ${mockActiveRunnersCount}`,
},
stubs: {
FilteredSearch,
GlFilteredSearch,
Loading
Loading
@@ -64,12 +75,6 @@ describe('RunnerList', () => {
);
});
 
it('Displays a large active runner count', () => {
createComponent({ props: { activeRunnersCount: 2000 } });
expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000');
});
it('sets sorting options', () => {
const SORT_OPTIONS_COUNT = 2;
 
Loading
Loading
@@ -78,7 +83,13 @@ describe('RunnerList', () => {
expect(findSortOptions().at(1).text()).toBe('Last contact');
});
 
it('sets tokens', () => {
it('sets tokens to the filtered search', () => {
createComponent({
props: {
tokens: [statusTokenConfig, typeTokenConfig, tagTokenConfig],
},
});
expect(findFilteredSearch().props('tokens')).toEqual([
expect.objectContaining({
type: PARAM_KEY_STATUS,
Loading
Loading
import { createLocalVue, shallowMount } from '@vue/test-utils';
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 waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
import {
CREATED_ASC,
CREATED_DESC,
DEFAULT_SORT,
INSTANCE_TYPE,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
STATUS_ACTIVE,
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue';
import { groupRunnersData } from '../mock_data';
import { captureException } from '~/runner/sentry_utils';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { groupRunnersData, groupRunnersDataPaginated } from '../mock_data';
 
const localVue = createLocalVue();
localVue.use(VueApollo);
 
const mockGroupFullPath = 'group1';
const mockRegistrationToken = 'AABBCC';
const mockRunners = groupRunnersData.data.group.runners.nodes;
const mockGroupRunnersLimitedCount = mockRunners.length;
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
updateHistory: jest.fn(),
}));
 
describe('GroupRunnersApp', () => {
let wrapper;
Loading
Loading
@@ -22,8 +51,14 @@ describe('GroupRunnersApp', () => {
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () =>
findRunnerPagination().findByLabelText('Go to previous page');
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
 
const createComponent = ({ mountFn = shallowMount } = {}) => {
const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getGroupRunnersQuery, mockGroupRunnersQuery]];
 
wrapper = mountFn(GroupRunnersApp, {
Loading
Loading
@@ -32,11 +67,15 @@ describe('GroupRunnersApp', () => {
propsData: {
registrationToken: mockRegistrationToken,
groupFullPath: mockGroupFullPath,
groupRunnersLimitedCount: mockGroupRunnersLimitedCount,
...props,
},
});
};
 
beforeEach(async () => {
setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`);
mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData);
 
createComponent();
Loading
Loading
@@ -48,11 +87,179 @@ describe('GroupRunnersApp', () => {
});
 
it('shows the runner setup instructions', () => {
expect(findRunnerManualSetupHelp().exists()).toBe(true);
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
});
 
it('shows the runners list', () => {
expect(findRunnerList().props('runners')).toEqual(groupRunnersData.data.group.runners.nodes);
});
it('requests the runners with group path and no other filters', () => {
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: undefined,
type: undefined,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
});
it('sets tokens in the filtered search', () => {
createComponent({ mountFn: mount });
expect(findFilteredSearch().props('tokens')).toEqual([
expect.objectContaining({
type: PARAM_KEY_STATUS,
options: expect.any(Array),
}),
expect.objectContaining({
type: PARAM_KEY_RUNNER_TYPE,
options: expect.any(Array),
}),
]);
});
describe('shows the active runner count', () => {
it('with a regular value', () => {
createComponent({ mountFn: mount });
expect(findRunnerFilteredSearchBar().text()).toMatch(
`Runners in this group: ${mockGroupRunnersLimitedCount}`,
);
});
it('at the limit', () => {
createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount });
expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000`);
});
it('over the limit', () => {
createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount });
expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000+`);
});
});
describe('when a filter is preselected', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`);
createComponent();
await waitForPromises();
});
it('sets the filters in the search bar', () => {
expect(findRunnerFilteredSearchBar().props('value')).toEqual({
filters: [
{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } },
{ type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } },
],
sort: 'CREATED_DESC',
pagination: { page: 1 },
});
});
it('requests the runners with filter parameters', () => {
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: STATUS_ACTIVE,
type: INSTANCE_TYPE,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
});
});
describe('when a filter is selected by the user', () => {
beforeEach(() => {
findRunnerFilteredSearchBar().vm.$emit('input', {
filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }],
sort: CREATED_ASC,
});
});
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
url: 'http://test.host/groups/group1/-/runners?status[]=ACTIVE&sort=CREATED_ASC',
});
});
it('requests the runners with filters', () => {
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: STATUS_ACTIVE,
sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
});
});
});
it('when runners have not loaded, shows a loading state', () => {
createComponent();
expect(findRunnerList().props('loading')).toBe(true);
});
describe('when no runners are found', () => {
beforeEach(async () => {
mockGroupRunnersQuery = jest.fn().mockResolvedValue({
data: {
group: {
runners: { nodes: [] },
},
},
});
createComponent();
});
it('shows a message for no results', async () => {
expect(wrapper.text()).toContain('No runners found');
});
});
describe('when runners query fails', () => {
beforeEach(() => {
mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
createComponent();
});
it('error is shown to the user', async () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
it('error is reported to sentry', async () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error('Network error: Error!'),
component: 'GroupRunnersApp',
});
});
});
describe('Pagination', () => {
beforeEach(() => {
mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated);
createComponent({ mountFn: mount });
});
it('more pages can be selected', () => {
expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next');
});
it('cannot navigate to the previous page', () => {
expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true');
});
it('navigates to the next page', async () => {
await findRunnerPaginationNext().trigger('click');
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
after: groupRunnersDataPaginated.data.group.runners.pageInfo.endCursor,
});
});
});
});
Loading
Loading
@@ -9,3 +9,6 @@ export const runnerData = runnerFixture('get_runner.query.graphql.json');
 
// Group queries
export const groupRunnersData = runnerFixture('get_group_runners.query.graphql.json');
export const groupRunnersDataPaginated = runnerFixture(
'get_group_runners.query.graphql.paginated.json',
);
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment