Commit 541be85f authored by Miguel Rincon's avatar Miguel Rincon Committed by Frédéric Caplette

Add pause runner button to runner view

This change adds a "Pause" button to the runner read-only view,
it does this by repurposing the "Pause" button in the runner list so
it can be reused.
parent 16f5b43f
......@@ -4,6 +4,7 @@ import { createAlert } from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import RunnerEditButton from '../components/runner_edit_button.vue';
import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
import RunnerDetails from '../components/runner_details.vue';
import { I18N_FETCH_ERROR } from '../constants';
......@@ -14,6 +15,7 @@ export default {
name: 'AdminRunnerShowApp',
components: {
RunnerEditButton,
RunnerPauseButton,
RunnerHeader,
RunnerDetails,
},
......@@ -66,6 +68,7 @@ export default {
<runner-header v-if="runner" :runner="runner">
<template #actions>
<runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
<runner-pause-button v-if="canUpdate" :runner="runner" />
</template>
</runner-header>
......
<script>
import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { __, s__, sprintf } from '~/locale';
import { s__, sprintf } from '~/locale';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerEditButton from '../runner_edit_button.vue';
import RunnerPauseButton from '../runner_pause_button.vue';
import RunnerDeleteModal from '../runner_delete_modal.vue';
const I18N_PAUSE = __('Pause');
const I18N_RESUME = __('Resume');
const I18N_DELETE = s__('Runners|Delete runner');
const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
......@@ -20,6 +18,7 @@ export default {
GlButton,
GlButtonGroup,
RunnerEditButton,
RunnerPauseButton,
RunnerDeleteModal,
},
directives: {
......@@ -39,20 +38,6 @@ export default {
};
},
computed: {
isActive() {
return this.runner.active;
},
toggleActiveIcon() {
return this.isActive ? 'pause' : 'play';
},
toggleActiveTitle() {
if (this.updating) {
// Prevent a "sticky" tooltip: If this button is disabled,
// mouseout listeners don't run leaving the tooltip stuck
return '';
}
return this.isActive ? I18N_PAUSE : I18N_RESUME;
},
deleteTitle() {
if (this.deleting) {
// Prevent a "sticky" tooltip: If this button is disabled,
......@@ -78,35 +63,6 @@ export default {
},
},
methods: {
async onToggleActive() {
this.updating = true;
try {
const toggledActive = !this.runner.active;
const {
data: {
runnerUpdate: { errors },
},
} = await this.$apollo.mutate({
mutation: runnerActionsUpdateMutation,
variables: {
input: {
id: this.runner.id,
active: toggledActive,
},
},
});
if (errors && errors.length) {
throw new Error(errors.join(' '));
}
} catch (e) {
this.onError(e);
} finally {
this.updating = false;
}
},
async onDelete() {
// Deleting stays "true" until this row is removed,
// should only change back if the operation fails.
......@@ -162,15 +118,7 @@ export default {
See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
-->
<runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
<gl-button
v-if="canUpdate"
v-gl-tooltip.hover.viewport="toggleActiveTitle"
:aria-label="toggleActiveTitle"
:icon="toggleActiveIcon"
:loading="updating"
data-testid="toggle-active-runner"
@click="onToggleActive"
/>
<runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
<gl-button
v-if="canDelete"
v-gl-tooltip.hover.viewport="deleteTitle"
......
......@@ -63,6 +63,6 @@ export default {
<strong>{{ heading }}</strong>
</template>
</div>
<div class="gl-ml-auto"><slot name="actions"></slot></div>
<div class="gl-ml-auto gl-flex-shrink-0"><slot name="actions"></slot></div>
</div>
</template>
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql';
import { createAlert } from '~/flash';
import { captureException } from '~/runner/sentry_utils';
import { I18N_PAUSE, I18N_RESUME } from '../constants';
export default {
name: 'RunnerPauseButton',
components: {
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
runner: {
type: Object,
required: true,
},
compact: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
updating: false,
};
},
computed: {
isActive() {
return this.runner.active;
},
icon() {
return this.isActive ? 'pause' : 'play';
},
label() {
return this.isActive ? I18N_PAUSE : I18N_RESUME;
},
buttonContent() {
if (this.compact) {
return null;
}
return this.label;
},
ariaLabel() {
if (this.compact) {
return this.label;
}
return null;
},
tooltip() {
// Only show tooltip when compact.
// Also prevent a "sticky" tooltip: If this button is
// disabled, mouseout listeners don't run leaving the tooltip stuck
if (this.compact && !this.updating) {
return this.label;
}
return '';
},
},
methods: {
async onToggle() {
this.updating = true;
try {
const input = {
id: this.runner.id,
active: !this.isActive,
};
const {
data: {
runnerUpdate: { errors },
},
} = await this.$apollo.mutate({
mutation: runnerToggleActiveMutation,
variables: {
input,
},
});
if (errors && errors.length) {
throw new Error(errors.join(' '));
}
} catch (e) {
this.onError(e);
} finally {
this.updating = false;
}
},
onError(error) {
const { message } = error;
createAlert({ message });
this.reportToSentry(error);
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
};
</script>
<template>
<gl-button
v-gl-tooltip.hover.viewport="tooltip"
v-bind="$attrs"
:aria-label="ariaLabel"
:icon="icon"
:loading="updating"
@click="onToggle"
v-on="$listeners"
>
<!--
Use <template v-if> to ensure a square button is shown when compact: true.
Sending empty content will still show a distorted/rectangular button.
-->
<template v-if="buttonContent">{{ buttonContent }}</template>
</gl-button>
</template>
import { s__ } from '~/locale';
import { __, s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000;
......@@ -28,6 +28,10 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__(
'Runners|No contact from this runner in over 3 months',
);
// Active flag
export const I18N_PAUSE = __('Pause');
export const I18N_RESUME = __('Resume');
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
......
#import "~/runner/graphql/runner_node.fragment.graphql"
# Mutation for updates within the runners list via action
# buttons (play, pause, ...), loads attributes shown in the
# runner list.
mutation runnerActionsUpdate($input: RunnerUpdateInput!) {
runnerUpdate(input: $input) {
runner {
...RunnerNode
}
errors
}
}
# Mutation executed for the pause/resume button in the
# runner list and details views.
mutation runnerToggleActive($input: RunnerUpdateInput!) {
runnerUpdate(input: $input) {
runner {
id
active
}
errors
}
}
......@@ -8,6 +8,8 @@ import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/runner/components/runner_header.vue';
import RunnerDetails from '~/runner/components/runner_details.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql';
import AdminRunnerShowApp from '~/runner/admin_runner_show/admin_runner_show_app.vue';
import { captureException } from '~/runner/sentry_utils';
......@@ -17,7 +19,8 @@ import { runnerData } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
const mockRunnerGraphqlId = runnerData.data.runner.id;
const mockRunner = runnerData.data.runner;
const mockRunnerGraphqlId = mockRunner.id;
const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
Vue.use(VueApollo);
......@@ -28,6 +31,16 @@ describe('AdminRunnerShowApp', () => {
const findRunnerHeader = () => wrapper.findComponent(RunnerHeader);
const findRunnerDetails = () => wrapper.findComponent(RunnerDetails);
const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton);
const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton);
const mockRunnerQueryResult = (runner = {}) => {
mockRunnerQuery = jest.fn().mockResolvedValue({
data: {
runner: { ...mockRunner, ...runner },
},
});
};
const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
wrapper = mountFn(AdminRunnerShowApp, {
......@@ -41,10 +54,6 @@ describe('AdminRunnerShowApp', () => {
return waitForPromises();
};
beforeEach(() => {
mockRunnerQuery = jest.fn().mockResolvedValue(runnerData);
});
afterEach(() => {
mockRunnerQuery.mockReset();
wrapper.destroy();
......@@ -52,6 +61,8 @@ describe('AdminRunnerShowApp', () => {
describe('When showing runner details', () => {
beforeEach(async () => {
mockRunnerQueryResult();
await createComponent({ mountFn: mount });
});
......@@ -63,6 +74,11 @@ describe('AdminRunnerShowApp', () => {
expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`);
});
it('displays the runner edit and pause buttons', async () => {
expect(findRunnerEditButton().exists()).toBe(true);
expect(findRunnerPauseButton().exists()).toBe(true);
});
it('shows basic runner details', async () => {
const expected = `Details
Description Instance runner
......@@ -75,6 +91,42 @@ describe('AdminRunnerShowApp', () => {
expect(findRunnerDetails().text()).toMatchInterpolatedText(expected);
});
describe('when runner cannot be updated', () => {
beforeEach(async () => {
mockRunnerQueryResult({
userPermissions: {
updateRunner: false,
},
});
await createComponent({
mountFn: mount,
});
});
it('does not display the runner edit and pause buttons', () => {
expect(findRunnerEditButton().exists()).toBe(false);
expect(findRunnerPauseButton().exists()).toBe(false);
});
});
describe('when runner does not have an edit url ', () => {
beforeEach(async () => {
mockRunnerQueryResult({
editAdminUrl: null,
});
await createComponent({
mountFn: mount,
});
});
it('does not display the runner edit button', () => {
expect(findRunnerEditButton().exists()).toBe(false);
expect(findRunnerPauseButton().exists()).toBe(true);
});
});
});
describe('When there is an error', () => {
......
......@@ -9,12 +9,12 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { captureException } from '~/runner/sentry_utils';
import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql';
import { runnersData } from '../../mock_data';
const mockRunner = runnersData.data.runners.nodes[0];
......@@ -32,10 +32,9 @@ describe('RunnerTypeCell', () => {
const mockToastShow = jest.fn();
const runnerDeleteMutationHandler = jest.fn();
const runnerActionsUpdateMutationHandler = jest.fn();
const findEditBtn = () => wrapper.findComponent(RunnerEditButton);
const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner');
const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton);
const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal);
const findDeleteBtn = () => wrapper.findByTestId('delete-runner');
const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value;
......@@ -52,10 +51,7 @@ describe('RunnerTypeCell', () => {
...runner,
},
},
apolloProvider: createMockApollo([
[runnerDeleteMutation, runnerDeleteMutationHandler],
[runnerActionsUpdateMutation, runnerActionsUpdateMutationHandler],
]),
apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteMutationHandler]]),
directives: {
GlTooltip: createMockDirective(),
GlModal: createMockDirective(),
......@@ -77,21 +73,11 @@ describe('RunnerTypeCell', () => {
},
},
});
runnerActionsUpdateMutationHandler.mockResolvedValue({
data: {
runnerUpdate: {
runner: mockRunner,
errors: [],
},
},
});
});
afterEach(() => {
mockToastShow.mockReset();
runnerDeleteMutationHandler.mockReset();
runnerActionsUpdateMutationHandler.mockReset();
wrapper.destroy();
});
......@@ -123,118 +109,14 @@ describe('RunnerTypeCell', () => {
});
});
describe('Toggle active action', () => {
describe.each`
state | label | icon | isActive | newActiveValue
${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false}
${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true}
`('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => {
beforeEach(() => {
createComponent({ active: isActive });
});
it(`Displays a ${icon} button`, () => {
expect(findToggleActiveBtn().props('loading')).toBe(false);
expect(findToggleActiveBtn().props('icon')).toBe(icon);
expect(getTooltip(findToggleActiveBtn())).toBe(label);
expect(findToggleActiveBtn().attributes('aria-label')).toBe(label);
});
it(`After clicking the ${icon} button, the button has a loading state`, async () => {
await findToggleActiveBtn().vm.$emit('click');
expect(findToggleActiveBtn().props('loading')).toBe(true);
});
it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => {
await findToggleActiveBtn().vm.$emit('click');
expect(getTooltip(findToggleActiveBtn())).toBe('');
expect(findToggleActiveBtn().attributes('aria-label')).toBe('');
});
describe(`When clicking on the ${icon} button`, () => {
it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => {
expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0);
await findToggleActiveBtn().vm.$emit('click');
expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1);
expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({
input: {
id: mockRunner.id,
active: newActiveValue,
},
});
});
it('The button does not have a loading state after the mutation occurs', async () => {
await findToggleActiveBtn().vm.$emit('click');
expect(findToggleActiveBtn().props('loading')).toBe(true);
await waitForPromises();
expect(findToggleActiveBtn().props('loading')).toBe(false);
});
});
describe('When update fails', () => {
describe('On a network error', () => {
const mockErrorMsg = 'Update error!';
beforeEach(async () => {
runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
findToggleActiveBtn().vm.$emit('click');
await waitForPromises();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`Network error: ${mockErrorMsg}`),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
});
describe('On a validation error', () => {
const mockErrorMsg = 'Runner not found!';
const mockErrorMsg2 = 'User not allowed!';
beforeEach(async () => {
runnerActionsUpdateMutationHandler.mockResolvedValue({
data: {
runnerUpdate: {
runner: mockRunner,
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
findToggleActiveBtn().vm.$emit('click');
await waitForPromises();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerActionsCell',
});
});
describe('Pause action', () => {
it('Renders a compact pause button', () => {
createComponent();
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
});
});
expect(findRunnerPauseBtn().props('compact')).toBe(true);
});
it('Does not render the runner toggle active button when user cannot update', () => {
it('Does not render the runner pause button when user cannot update', () => {
createComponent({
userPermissions: {
...mockRunner.userPermissions,
......@@ -242,7 +124,7 @@ describe('RunnerTypeCell', () => {
},
});
expect(findToggleActiveBtn().exists()).toBe(false);
expect(findRunnerPauseBtn().exists()).toBe(false);
});
});
......
......@@ -7,6 +7,7 @@ import {
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes;
......@@ -92,7 +93,8 @@ describe('RunnerList', () => {
const actions = findCell({ fieldKey: 'actions' });
expect(actions.findComponent(RunnerEditButton).exists()).toBe(true);
expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true);
expect(actions.findComponent(RunnerPauseButton).exists()).toBe(true);
expect(actions.findByTestId('delete-runner').exists()).toBe(true);
});
describe('Table data formatting', () => {
......
import Vue from 'vue';
import { GlButton } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import { captureException } from '~/runner/sentry_utils';
import { createAlert } from '~/flash';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import { runnersData } from '../mock_data';
const mockRunner = runnersData.data.runners.nodes[0];
Vue.use(VueApollo);
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
describe('RunnerPauseButton', () => {
let wrapper;
let runnerToggleActiveHandler;
const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
const findBtn = () => wrapper.findComponent(GlButton);
const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
const { runner, ...propsData } = props;
wrapper = mountFn(RunnerPauseButton, {
propsData: {
runner: {
id: mockRunner.id,
active: mockRunner.active,
...runner,
},
...propsData,
},
apolloProvider: createMockApollo([[runnerToggleActiveMutation, runnerToggleActiveHandler]]),
directives: {
GlTooltip: createMockDirective(),
},
});
};
const clickAndWait = async () => {
findBtn().vm.$emit('click');
await waitForPromises();
};
beforeEach(() => {
runnerToggleActiveHandler = jest.fn().mockImplementation(({ input }) => {
return Promise.resolve({
data: {
runnerUpdate: {
runner: {
id: input.id,
active: input.active,
},
errors: [],
},
},
});
});
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('Pause/Resume action', () => {
describe.each`
runnerState | icon | content | isActive | newActiveValue
${'paused'} | ${'play'} | ${'Resume'} | ${false} | ${true}
${'active'} | ${'pause'} | ${'Pause'} | ${true} | ${false}
`('When the runner is $runnerState', ({ icon, content, isActive, newActiveValue }) => {
beforeEach(() => {
createComponent({
props: {
runner: {
active: isActive,
},
},
});
});
it(`Displays a ${icon} button`, () => {
expect(findBtn().props('loading')).toBe(false);
expect(findBtn().props('icon')).toBe(icon);
expect(findBtn().text()).toBe(content);
});
it('Does not display redundant text for screen readers', () => {
expect(findBtn().attributes('aria-label')).toBe(undefined);
});
describe(`Before the ${icon} button is clicked`, () => {
it('The mutation has not been called', () => {
expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(0);
});
});
describe(`Immediately after the ${icon} button is clicked`, () => {
beforeEach(async () => {
findBtn().vm.$emit('click');
});
it('The button has a loading state', async () => {
expect(findBtn().props('loading')).toBe(true);
});
it('The stale tooltip is removed', async () => {
expect(getTooltip()).toBe('');
});
});
describe(`After clicking on the ${icon} button`, () => {
beforeEach(async () => {
await clickAndWait();
});
it(`The mutation to that sets active to ${newActiveValue} is called`, async () => {
expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(1);
expect(runnerToggleActiveHandler).toHaveBeenCalledWith({
input: {
id: mockRunner.id,
active: newActiveValue,
},
});
});
it('The button does not have a loading state', () => {
expect(findBtn().props('loading')).toBe(false);
});
});
describe('When update fails', () => {
describe('On a network error', () => {
const mockErrorMsg = 'Update error!';
beforeEach(async () => {
runnerToggleActiveHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
await clickAndWait();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`Network error: ${mockErrorMsg}`),
component: 'RunnerPauseButton',
});
});
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
});
describe('On a validation error', () => {
const mockErrorMsg = 'Runner not found!';
const mockErrorMsg2 = 'User not allowed!';
beforeEach(async () => {
runnerToggleActiveHandler.mockResolvedValueOnce({
data: {
runnerUpdate: {
runner: {
id: mockRunner.id,
active: isActive,
},
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
await clickAndWait();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerPauseButton',
});
});
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
});
});
});
});
describe('When displaying a compact button for an active runner', () => {
beforeEach(() => {
createComponent({
props: {
runner: {
active: true,
},
compact: true,
},
mountFn: mountExtended,
});
});
it('Displays no text', () => {
expect(findBtn().text()).toBe('');
// Note: Use <template v-if> to ensure rendering a
// text-less button. Ensure we don't send even empty an
// content slot to prevent a distorted/rectangular button.
expect(wrapper.find('.gl-button-text').exists()).toBe(false);
});
it('Display correctly for screen readers', () => {
expect(findBtn().attributes('aria-label')).toBe('Pause');
expect(getTooltip()).toBe('Pause');
});
describe('Immediately after the button is clicked', () => {
beforeEach(async () => {
findBtn().vm.$emit('click');
});
it('The button has a loading state', async () => {
expect(findBtn().props('loading')).toBe(true);
});
it('The stale tooltip is removed', async () => {
expect(getTooltip()).toBe('');
});
});
});
});
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