Commit e4071f72 authored by Miguel Rincon's avatar Miguel Rincon Committed by Peter Hegman

Move register runner information to a dropdown

This change moves the runner registration information to a number of
separate steps in a single dropdown in the upper-right corner of the
admin and groups runner list view.

Changelog: changed
parent c7c927c6
......@@ -4,11 +4,13 @@ import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, __ } from '~/locale';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
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 RunnerName from '../components/runner_name.vue';
import RunnerPagination from '../components/runner_pagination.vue';
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';
......@@ -25,9 +27,9 @@ export default {
name: 'AdminRunnersApp',
components: {
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
RunnerList,
RunnerManualSetupHelp,
RunnerName,
RunnerPagination,
},
......@@ -126,10 +128,14 @@ export default {
</script>
<template>
<div>
<runner-manual-setup-help
<div class="gl-py-3 gl-display-flex">
<registration-dropdown
class="gl-ml-auto"
:registration-token="registrationToken"
:type="$options.INSTANCE_TYPE"
right
/>
</div>
<runner-filtered-search-bar
v-model="search"
......
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import AdminRunnersApp from './admin_runners_app.vue';
Vue.use(GlToast);
Vue.use(VueApollo);
export const initAdminRunners = (selector = '#js-admin-runners') => {
......
<script>
import {
GlFormGroup,
GlDropdown,
GlDropdownForm,
GlDropdownItem,
GlDropdownDivider,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
import RegistrationToken from './registration_token.vue';
import RegistrationTokenResetDropdownItem from './registration_token_reset_dropdown_item.vue';
export default {
i18n: {
showInstallationInstructions: s__(
'Runners|Show runner installation and registration instructions',
),
registrationToken: s__('Runners|Registration token'),
},
components: {
GlFormGroup,
GlDropdown,
GlDropdownForm,
GlDropdownItem,
GlDropdownDivider,
RegistrationToken,
RunnerInstructionsModal,
RegistrationTokenResetDropdownItem,
},
props: {
registrationToken: {
type: String,
required: true,
},
type: {
type: String,
required: true,
validator(type) {
return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type);
},
},
},
data() {
return {
currentRegistrationToken: this.registrationToken,
instructionsModalOpened: false,
};
},
computed: {
dropdownText() {
switch (this.type) {
case INSTANCE_TYPE:
return s__('Runners|Register an instance runner');
case GROUP_TYPE:
return s__('Runners|Register a group runner');
case PROJECT_TYPE:
return s__('Runners|Register a project runner');
default:
return s__('Runners|Register a runner');
}
},
},
methods: {
onShowInstructionsClick() {
// Rendering the modal on demand, to avoid
// loading instructions prematurely from API.
this.instructionsModalOpened = true;
this.$nextTick(() => {
// $refs.runnerInstructionsModal is defined in
// the tick after the modal is rendered
this.$refs.runnerInstructionsModal.show();
});
},
onTokenReset(token) {
this.currentRegistrationToken = token;
},
},
};
</script>
<template>
<gl-dropdown menu-class="gl-w-auto!" :text="dropdownText" variant="confirm" v-bind="$attrs">
<gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick">
{{ $options.i18n.showInstallationInstructions }}
<runner-instructions-modal
v-if="instructionsModalOpened"
ref="runnerInstructionsModal"
data-testid="runner-instructions-modal"
/>
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-form class="gl-p-4!">
<gl-form-group class="gl-mb-0" :label="$options.i18n.registrationToken">
<registration-token :value="currentRegistrationToken" />
</gl-form-group>
</gl-dropdown-form>
<gl-dropdown-divider />
<registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" />
</gl-dropdown>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
export default {
components: {
GlButtonGroup,
GlButton,
ModalCopyButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
value: {
......@@ -19,13 +25,13 @@ export default {
};
},
computed: {
label() {
maskLabel() {
if (this.isMasked) {
return __('Click to reveal');
}
return __('Click to hide');
},
icon() {
maskIcon() {
if (this.isMasked) {
return 'eye';
}
......@@ -39,22 +45,39 @@ export default {
},
},
methods: {
toggleMasked() {
onToggleMasked() {
this.isMasked = !this.isMasked;
},
onCopied() {
// value already in the clipboard, simply notify the user
this.$toast?.show(s__('Runners|Registration token copied!'));
},
},
i18n: {
copyLabel: s__('Runners|Copy registration token'),
},
};
</script>
<template>
<span
>{{ displayedValue }}
<gl-button-group>
<gl-button class="gl-font-monospace" data-testid="token-value" label>
{{ displayedValue }}
</gl-button>
<gl-button
:aria-label="label"
:icon="icon"
class="gl-text-body!"
v-gl-tooltip
:aria-label="maskLabel"
:title="maskLabel"
:icon="maskIcon"
class="gl-w-auto! gl-flex-shrink-0!"
data-testid="toggle-masked"
variant="link"
@click="toggleMasked"
@click.stop="onToggleMasked"
/>
<modal-copy-button
class="gl-w-auto! gl-flex-shrink-0!"
:aria-label="$options.i18n.copyLabel"
:title="$options.i18n.copyLabel"
:text="value"
@success="onCopied"
/>
</span>
</gl-button-group>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import createFlash, { FLASH_TYPES } from '~/flash';
import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
export default {
name: 'RunnerRegistrationTokenReset',
components: {
GlButton,
GlDropdownItem,
GlLoadingIcon,
},
inject: {
groupId: {
......@@ -95,10 +96,7 @@ export default {
this.reportToSentry(error);
},
onSuccess(token) {
createFlash({
message: s__('Runners|New registration token generated!'),
type: FLASH_TYPES.SUCCESS,
});
this.$toast?.show(s__('Runners|New registration token generated!'));
this.$emit('tokenReset', token);
},
reportToSentry(error) {
......@@ -108,7 +106,8 @@ export default {
};
</script>
<template>
<gl-button :loading="loading" @click="resetToken">
<gl-dropdown-item @click.capture.native.stop="resetToken">
{{ __('Reset registration token') }}
</gl-button>
<gl-loading-icon v-if="loading" inline />
</gl-dropdown-item>
</template>
<script>
import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import MaskedValue from '~/runner/components/helpers/masked_value.vue';
import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
export default {
components: {
GlLink,
GlSprintf,
ClipboardButton,
MaskedValue,
RunnerInstructions,
RunnerRegistrationTokenReset,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
runnerInstallHelpPage: {
default: null,
},
},
props: {
registrationToken: {
type: String,
required: true,
},
type: {
type: String,
required: true,
validator(type) {
return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type);
},
},
},
data() {
return {
currentRegistrationToken: this.registrationToken,
};
},
computed: {
rootUrl() {
return gon.gitlab_url || '';
},
typeName() {
switch (this.type) {
case INSTANCE_TYPE:
return s__('Runners|shared');
case GROUP_TYPE:
return s__('Runners|group');
case PROJECT_TYPE:
return s__('Runners|specific');
default:
return '';
}
},
},
methods: {
onTokenReset(token) {
this.currentRegistrationToken = token;
},
},
};
</script>
<template>
<div class="bs-callout">
<h5 data-testid="runner-help-title">
<gl-sprintf :message="__('Set up a %{type} runner manually')">
<template #type>
{{ typeName }}
</template>
</gl-sprintf>
</h5>
<ol>
<li>
<gl-link :href="runnerInstallHelpPage" data-testid="runner-help-link" target="_blank">
{{ __("Install GitLab Runner and ensure it's running.") }}
</gl-link>
</li>
<li>
{{ __('Register the runner with this URL:') }}
<br />
<code data-testid="coordinator-url">{{ rootUrl }}</code>
<clipboard-button :title="__('Copy URL')" :text="rootUrl" />
</li>
<li>
{{ __('And this registration token:') }}
<br />
<code data-testid="registration-token"
><masked-value :value="currentRegistrationToken"
/></code>
<clipboard-button :title="__('Copy token')" :text="currentRegistrationToken" />
</li>
</ol>
<runner-registration-token-reset :type="type" @tokenReset="onTokenReset" />
<runner-instructions />
</div>
</template>
......@@ -5,9 +5,9 @@ import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, s__ } from '~/locale';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
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 RunnerName from '../components/runner_name.vue';
import RunnerPagination from '../components/runner_pagination.vue';
......@@ -31,9 +31,9 @@ export default {
name: 'GroupRunnersApp',
components: {
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
RunnerList,
RunnerManualSetupHelp,
RunnerName,
RunnerPagination,
},
......@@ -144,7 +144,14 @@ export default {
<template>
<div>
<runner-manual-setup-help :registration-token="registrationToken" :type="$options.GROUP_TYPE" />
<div class="gl-py-3 gl-display-flex">
<registration-dropdown
class="gl-ml-auto"
:registration-token="registrationToken"
:type="$options.GROUP_TYPE"
right
/>
</div>
<runner-filtered-search-bar
v-model="search"
......
......@@ -41,7 +41,8 @@ export default {
props: {
modalId: {
type: String,
required: true,
required: false,
default: 'runner-instructions-modal',
},
},
apollo: {
......@@ -119,6 +120,9 @@ export default {
},
},
methods: {
show() {
this.$refs.modal.show();
},
selectPlatform(platform) {
this.selectedPlatform = platform;
......@@ -158,9 +162,11 @@ export default {
</script>
<template>
<gl-modal
ref="modal"
:modal-id="modalId"
:title="$options.i18n.installARunner"
:action-secondary="$options.closeButton"
v-bind="$attrs"
>
<gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
{{ $options.i18n.fetchError }}
......
......@@ -29376,6 +29376,9 @@ msgstr ""
msgid "Runners|Copy instructions"
msgstr ""
msgid "Runners|Copy registration token"
msgstr ""
msgid "Runners|Deploy GitLab Runner in AWS"
msgstr ""
......@@ -29451,6 +29454,24 @@ msgstr ""
msgid "Runners|Protected"
msgstr ""
msgid "Runners|Register a group runner"
msgstr ""
msgid "Runners|Register a project runner"
msgstr ""
msgid "Runners|Register a runner"
msgstr ""
msgid "Runners|Register an instance runner"
msgstr ""
msgid "Runners|Registration token"
msgstr ""
msgid "Runners|Registration token copied!"
msgstr ""
msgid "Runners|Revision"
msgstr ""
......@@ -29490,6 +29511,9 @@ msgstr ""
msgid "Runners|Show Runner installation instructions"
msgstr ""
msgid "Runners|Show runner installation and registration instructions"
msgstr ""
msgid "Runners|Something went wrong while fetching runner data."
msgstr ""
......@@ -31192,9 +31216,6 @@ msgstr ""
msgid "Set up a %{type} Runner for a project"
msgstr ""
msgid "Set up a %{type} runner manually"
msgstr ""
msgid "Set up a hardware device as a second factor to sign in."
msgstr ""
......
......@@ -24,7 +24,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
expect(page).to have_text "Set up a shared runner manually"
expect(page).to have_text "Register an instance runner"
expect(page).to have_text "Runners currently online: 1"
end
......@@ -267,29 +267,55 @@ RSpec.describe "Admin Runners" do
end
it 'has all necessary texts including no runner message' do
expect(page).to have_text "Set up a shared runner manually"
expect(page).to have_text "Register an instance runner"
expect(page).to have_text "Runners currently online: 0"
expect(page).to have_text 'No runners found'
end
end
describe 'runners registration token' do
describe 'runners registration' do
let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
before do
visit admin_runners_path
click_on 'Register an instance runner'
end
describe 'show registration instructions' do
before do
click_on 'Show runner installation and registration instructions'
wait_for_requests
end
it 'opens runner installation modal' do
expect(page).to have_text "Install a runner"
expect(page).to have_text "Environment"
expect(page).to have_text "Architecture"
expect(page).to have_text "Download and install binary"
end
it 'dismisses runner installation modal' do
page.within('[role="dialog"]') do
click_button('Close', match: :first)
end
expect(page).not_to have_text "Install a runner"
end
end
it 'has a registration token' do
click_on 'Click to reveal'
expect(page.find('[data-testid="registration-token"]')).to have_content(token)
expect(page.find('[data-testid="token-value"]')).to have_content(token)
end
describe 'reset registration token' do
let(:page_token) { find('[data-testid="registration-token"]').text }
let(:page_token) { find('[data-testid="token-value"]').text }
before do
click_button 'Reset registration token'
click_on 'Reset registration token'
page.accept_alert
......
......@@ -12,7 +12,7 @@ import { updateHistory } from '~/lib/utils/url_utility';
import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue';
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 RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
import {
......@@ -50,7 +50,7 @@ describe('AdminRunnersApp', () => {
let wrapper;
let mockRunnersQuery;
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () =>
......@@ -87,7 +87,8 @@ describe('AdminRunnersApp', () => {
});
it('shows the runner setup instructions', () => {
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE);
});
it('shows the runners list', () => {
......
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MaskedValue from '~/runner/components/helpers/masked_value.vue';
const mockSecret = '01234567890';
const mockMasked = '***********';
describe('MaskedValue', () => {
let wrapper;
const findButton = () => wrapper.findComponent(GlButton);
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(MaskedValue, {
propsData: {
value: mockSecret,
...props,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays masked value by default', () => {
expect(wrapper.text()).toBe(mockMasked);
});
describe('When the icon is clicked', () => {
beforeEach(() => {
findButton().vm.$emit('click');
});
it('Displays the actual value', () => {
expect(wrapper.text()).toBe(mockSecret);
expect(wrapper.text()).not.toBe(mockMasked);
});
it('When user clicks again, displays masked value', async () => {
await findButton().vm.$emit('click');
expect(wrapper.text()).toBe(mockMasked);
expect(wrapper.text()).not.toBe(mockSecret);
});
});
});
import { GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui';
import { createLocalVue, mount, shallowMount, createWrapper } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql';
import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql';
import {
mockGraphqlRunnerPlatforms,
mockGraphqlInstructions,
} from 'jest/vue_shared/components/runner_instructions/mock_data';
const mockToken = '0123456789';
const maskToken = '**********';
describe('RegistrationDropdown', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem);
const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm);
const findTokenResetDropdownItem = () =>
wrapper.findComponent(RegistrationTokenResetDropdownItem);
const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked');
const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => {
wrapper = extendedWrapper(
mountFn(RegistrationDropdown, {
propsData: {
registrationToken: mockToken,
type: INSTANCE_TYPE,
...props,
},
...options,
}),
);
};
it.each`
type | text
${INSTANCE_TYPE} | ${'Register an instance runner'}
${GROUP_TYPE} | ${'Register a group runner'}
${PROJECT_TYPE} | ${'Register a project runner'}
`('Dropdown text for type $type is "$text"', () => {
createComponent({ props: { type: INSTANCE_TYPE } }, mount);
expect(wrapper.text()).toContain('Register an instance runner');
});
it('Passes attributes to the dropdown component', () => {
createComponent({ attrs: { right: true } });
expect(findDropdown().attributes()).toMatchObject({ right: 'true' });
});
describe('Instructions dropdown item', () => {
it('Displays "Show runner" dropdown item', () => {
createComponent();
expect(findRegistrationInstructionsDropdownItem().text()).toBe(
'Show runner installation and registration instructions',
);
});
describe('When the dropdown item is clicked', () => {
const localVue = createLocalVue();
localVue.use(VueApollo);
const requestHandlers = [
[getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)],
[getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)],
];
const findModalInBody = () =>
createWrapper(document.body).find('[data-testid="runner-instructions-modal"]');
beforeEach(() => {
createComponent(
{
localVue,
// Mock load modal contents from API
apolloProvider: createMockApollo(requestHandlers),
// Use `attachTo` to find the modal
attachTo: document.body,
},
mount,
);
findRegistrationInstructionsDropdownItem().trigger('click');
});
afterEach(() => {
wrapper.destroy();
});
it('opens the modal with contents', () => {
const modalText = findModalInBody()
.text()
.replace(/[\n\t\s]+/g, ' ');
expect(modalText).toContain('Install a runner');
// Environment selector
expect(modalText).toContain('Environment');
expect(modalText).toContain('Linux macOS Windows Docker Kubernetes');
// Architecture selector
expect(modalText).toContain('Architecture');
expect(modalText).toContain('amd64 amd64 386 arm arm64');
expect(modalText).toContain('Download and install binary');
});
});
});
describe('Registration token', () => {
it('Displays dropdown form for the registration token', () => {
createComponent();
expect(findTokenDropdownItem().exists()).toBe(true);
});
it('Displays masked value by default', () => {
createComponent({}, mount);
expect(findTokenDropdownItem().text()).toMatchInterpolatedText(
`Registration token ${maskToken}`,
);
});
});
describe('Reset token item', () => {
it('Displays registration token reset item', () => {
createComponent();
expect(findTokenResetDropdownItem().exists()).toBe(true);
});
it.each([INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE])('Set up token reset for %s', (type) => {
createComponent({ props: { type } });
expect(findTokenResetDropdownItem().props('type')).toBe(type);
});
});
it('Updates the token when it gets reset', async () => {
createComponent({}, mount);
const newToken = 'mock1';
findTokenResetDropdownItem().vm.$emit('tokenReset', newToken);
findToggleMaskButton().vm.$emit('click', { stopPropagation: jest.fn() });
await nextTick();
expect(findTokenDropdownItem().text()).toMatchInterpolatedText(
`Registration token ${newToken}`,
);
});
});
import { GlButton } from '@gitlab/ui';
import { GlDropdownItem, GlLoadingIcon, GlToast } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash, { FLASH_TYPES } from '~/flash';
import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
import createFlash from '~/flash';
import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
......@@ -15,17 +15,20 @@ jest.mock('~/runner/sentry_utils');
const localVue = createLocalVue();
localVue.use(VueApollo);
localVue.use(GlToast);
const mockNewToken = 'NEW_TOKEN';
describe('RunnerRegistrationTokenReset', () => {
describe('RegistrationTokenResetDropdownItem', () => {
let wrapper;
let runnersRegistrationTokenResetMutationHandler;
let showToast;
const findButton = () => wrapper.findComponent(GlButton);
const findDropdownItem = () => wrapper.findComponent(GlDropdownItem);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const createComponent = ({ props, provide = {} } = {}) => {
wrapper = shallowMount(RunnerRegistrationTokenReset, {
wrapper = shallowMount(RegistrationTokenResetDropdownItem, {
localVue,
provide,
propsData: {
......@@ -36,6 +39,8 @@ describe('RunnerRegistrationTokenReset', () => {
[runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler],
]),
});
showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null;
};
beforeEach(() => {
......@@ -58,7 +63,7 @@ describe('RunnerRegistrationTokenReset', () => {
});
it('Displays reset button', () => {
expect(findButton().exists()).toBe(true);
expect(findDropdownItem().exists()).toBe(true);
});
describe('On click and confirmation', () => {
......@@ -78,7 +83,8 @@ describe('RunnerRegistrationTokenReset', () => {
});
window.confirm.mockReturnValueOnce(true);
findButton().vm.$emit('click');
findDropdownItem().trigger('click');
await waitForPromises();
});
......@@ -95,14 +101,13 @@ describe('RunnerRegistrationTokenReset', () => {
});
it('does not show a loading state', () => {
expect(findButton().props('loading')).toBe(false);
expect(findLoadingIcon().exists()).toBe(false);
});
it('shows confirmation', () => {
expect(createFlash).toHaveBeenLastCalledWith({
message: expect.stringContaining('registration token generated'),
type: FLASH_TYPES.SUCCESS,
});
expect(showToast).toHaveBeenLastCalledWith(
expect.stringContaining('registration token generated'),
);
});
});
});
......@@ -110,7 +115,7 @@ describe('RunnerRegistrationTokenReset', () => {
describe('On click without confirmation', () => {
beforeEach(async () => {
window.confirm.mockReturnValueOnce(false);
findButton().vm.$emit('click');
findDropdownItem().vm.$emit('click');
await waitForPromises();
});
......@@ -123,11 +128,11 @@ describe('RunnerRegistrationTokenReset', () => {
});
it('does not show a loading state', () => {
expect(findButton().props('loading')).toBe(false);
expect(findLoadingIcon().exists()).toBe(false);
});
it('does not shows confirmation', () => {
expect(createFlash).not.toHaveBeenCalled();
expect(showToast).not.toHaveBeenCalled();
});
});
......@@ -138,7 +143,7 @@ describe('RunnerRegistrationTokenReset', () => {
runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
window.confirm.mockReturnValueOnce(true);
findButton().vm.$emit('click');
findDropdownItem().trigger('click');
await waitForPromises();
expect(createFlash).toHaveBeenLastCalledWith({
......@@ -164,7 +169,7 @@ describe('RunnerRegistrationTokenReset', () => {
});
window.confirm.mockReturnValueOnce(true);
findButton().vm.$emit('click');
findDropdownItem().trigger('click');
await waitForPromises();
expect(createFlash).toHaveBeenLastCalledWith({
......@@ -180,10 +185,10 @@ describe('RunnerRegistrationTokenReset', () => {
describe('Immediately after click', () => {
it('shows loading state', async () => {
window.confirm.mockReturnValue(true);
findButton().vm.$emit('click');
findDropdownItem().trigger('click');
await nextTick();
expect(findButton().props('loading')).toBe(true);
expect(findLoadingIcon().exists()).toBe(true);
});
});
});
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 RegistrationToken from '~/runner/components/registration/registration_token.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
const mockToken = '01234567890';
const mockMasked = '***********';
describe('RegistrationToken', () => {
let wrapper;
let stopPropagation;
let showToast;
const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked');
const findCopyButton = () => wrapper.findComponent(ModalCopyButton);
const vueWithGlToast = () => {
const localVue = createLocalVue();
localVue.use(GlToast);
return localVue;
};
const createComponent = ({ props = {}, withGlToast = true } = {}) => {
const localVue = withGlToast ? vueWithGlToast() : undefined;
wrapper = extendedWrapper(
shallowMount(RegistrationToken, {
propsData: {
value: mockToken,
...props,
},
localVue,
}),
);
showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null;
};
beforeEach(() => {
stopPropagation = jest.fn();
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays masked value by default', () => {
expect(wrapper.text()).toBe(mockMasked);
});
it('Displays button to reveal token', () => {
expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal');
});
it('Can copy the original token value', () => {
expect(findCopyButton().props('text')).toBe(mockToken);
});
describe('When the reveal icon is clicked', () => {
beforeEach(() => {
findToggleMaskButton().vm.$emit('click', { stopPropagation });
});
it('Click event is not propagated', async () => {
expect(stopPropagation).toHaveBeenCalledTimes(1);
});
it('Displays the actual value', () => {
expect(wrapper.text()).toBe(mockToken);
});
it('Can copy the original token value', () => {
expect(findCopyButton().props('text')).toBe(mockToken);
});
it('Displays button to mask token', () => {
expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to hide');
});
it('When user clicks again, displays masked value', async () => {
findToggleMaskButton().vm.$emit('click', { stopPropagation });
await nextTick();
expect(wrapper.text()).toBe(mockMasked);
expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal');
});
});
describe('When the copy to clipboard button is clicked', () => {
it('shows a copied message', () => {
findCopyButton().vm.$emit('success');
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('Registration token copied!');
});
it('does not fail when toast is not defined', () => {
createComponent({ withGlToast: false });
findCopyButton().vm.$emit('success');
// This block also tests for unhandled errors
expect(showToast).toBeNull();
});
});
});
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import MaskedValue from '~/runner/components/helpers/masked_value.vue';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockRunnerInstallHelpPage = 'https://docs.gitlab.com/runner/install/';
describe('RunnerManualSetupHelp', () => {
let wrapper;
let originalGon;
const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions);
const findRunnerRegistrationTokenReset = () =>
wrapper.findComponent(RunnerRegistrationTokenReset);
const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton);
const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title');
const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url');
const findRegistrationToken = () => wrapper.findByTestId('registration-token');
const findRunnerHelpLink = () => wrapper.findByTestId('runner-help-link');
const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(RunnerManualSetupHelp, {
provide: {
runnerInstallHelpPage: mockRunnerInstallHelpPage,
},
propsData: {
registrationToken: mockRegistrationToken,
type: INSTANCE_TYPE,
...props,
},
stubs: {
MaskedValue,
GlSprintf,
},
}),
);
};
beforeAll(() => {
originalGon = global.gon;
global.gon = { gitlab_url: TEST_HOST };
});
afterAll(() => {
global.gon = originalGon;
});
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Title contains the shared runner type', () => {
createComponent({ props: { type: INSTANCE_TYPE } });
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually');
});
it('Title contains the group runner type', () => {
createComponent({ props: { type: GROUP_TYPE } });
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually');
});
it('Title contains the specific runner type', () => {
createComponent({ props: { type: PROJECT_TYPE } });
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText(
'Set up a specific runner manually',
);
});
it('Runner Install Page link', () => {
expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage);
});
it('Displays the coordinator URL token', () => {
expect(findCoordinatorUrl().text()).toBe(TEST_HOST);
expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST);
});
it('Displays the runner instructions', () => {
expect(findRunnerInstructions().exists()).toBe(true);
});
it('Displays the registration token', async () => {
findRegistrationToken().find('[data-testid="toggle-masked"]').vm.$emit('click');
await nextTick();
expect(findRegistrationToken().text()).toBe(mockRegistrationToken);
expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken);
});
it('Displays the runner registration token reset button', () => {
expect(findRunnerRegistrationTokenReset().exists()).toBe(true);
});
it('Replaces the runner reset button', async () => {
const mockNewRegistrationToken = 'NEW_MOCK_REGISTRATION_TOKEN';
findRegistrationToken().find('[data-testid="toggle-masked"]').vm.$emit('click');
findRunnerRegistrationTokenReset().vm.$emit('tokenReset', mockNewRegistrationToken);
await nextTick();
expect(findRegistrationToken().text()).toBe(mockNewRegistrationToken);
expect(findClipboardButtons().at(1).props('text')).toBe(mockNewRegistrationToken);
});
});
......@@ -11,7 +11,7 @@ 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 RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
import {
......@@ -19,6 +19,7 @@ import {
CREATED_DESC,
DEFAULT_SORT,
INSTANCE_TYPE,
GROUP_TYPE,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
STATUS_ACTIVE,
......@@ -48,7 +49,7 @@ describe('GroupRunnersApp', () => {
let wrapper;
let mockGroupRunnersQuery;
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () =>
......@@ -82,7 +83,8 @@ describe('GroupRunnersApp', () => {
});
it('shows the runner setup instructions', () => {
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE);
});
it('shows the runners list', () => {
......
import { GlAlert, GlButton, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
import { GlAlert, GlModal, GlButton, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
......@@ -52,7 +52,7 @@ describe('RunnerInstructionsModal component', () => {
const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions');
const findRegisterCommand = () => wrapper.findByTestId('register-command');
const createComponent = () => {
const createComponent = (options = {}) => {
const requestHandlers = [
[getRunnerPlatformsQuery, runnerPlatformsHandler],
[getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler],
......@@ -67,6 +67,7 @@ describe('RunnerInstructionsModal component', () => {
},
localVue,
apolloProvider: fakeApollo,
...options,
}),
);
};
......@@ -217,4 +218,36 @@ describe('RunnerInstructionsModal component', () => {
expect(findRegisterCommand().exists()).toBe(false);
});
});
describe('GlModal API', () => {
const getGlModalStub = (methods) => {
return {
...GlModal,
methods: {
...GlModal.methods,
...methods,
},
};
};
describe('show()', () => {
let mockShow;
beforeEach(() => {
mockShow = jest.fn();
createComponent({
stubs: {
GlModal: getGlModalStub({ show: mockShow }),
},
});
});
it('delegates show()', () => {
wrapper.vm.show();
expect(mockShow).toHaveBeenCalledTimes(1);
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment