Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-02-21 09:15:42 +00:00
parent 803fa8d1c7
commit 0976c2c3d9
11 changed files with 440 additions and 304 deletions

View File

@ -1,29 +1,16 @@
<script>
import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { s__, sprintf } from '~/locale';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { GlButtonGroup } from '@gitlab/ui';
import RunnerEditButton from '../runner_edit_button.vue';
import RunnerPauseButton from '../runner_pause_button.vue';
import RunnerDeleteModal from '../runner_delete_modal.vue';
const I18N_DELETE = s__('Runners|Delete runner');
const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
import RunnerDeleteButton from '../runner_delete_button.vue';
export default {
name: 'RunnerActionsCell',
components: {
GlButton,
GlButtonGroup,
RunnerEditButton,
RunnerPauseButton,
RunnerDeleteModal,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
RunnerDeleteButton,
},
props: {
runner: {
@ -31,30 +18,7 @@ export default {
required: true,
},
},
data() {
return {
updating: false,
deleting: false,
};
},
computed: {
deleteTitle() {
if (this.deleting) {
// Prevent a "sticky" tooltip: If this button is disabled,
// mouseout listeners don't run leaving the tooltip stuck
return '';
}
return I18N_DELETE;
},
runnerId() {
return getIdFromGraphQLId(this.runner.id);
},
runnerName() {
return `#${this.runnerId} (${this.runner.shortSha})`;
},
runnerDeleteModalId() {
return `delete-runner-modal-${this.runnerId}`;
},
canUpdate() {
return this.runner.userPermissions?.updateRunner;
},
@ -62,49 +26,6 @@ export default {
return this.runner.userPermissions?.deleteRunner;
},
},
methods: {
async onDelete() {
// Deleting stays "true" until this row is removed,
// should only change back if the operation fails.
this.deleting = true;
try {
const {
data: {
runnerDelete: { errors },
},
} = await this.$apollo.mutate({
mutation: runnerDeleteMutation,
variables: {
input: {
id: this.runner.id,
},
},
awaitRefetchQueries: true,
refetchQueries: ['getRunners', 'getGroupRunners'],
});
if (errors && errors.length) {
throw new Error(errors.join(' '));
} else {
// Use $root to have the toast message stay after this element is removed
this.$root.$toast?.show(sprintf(I18N_DELETED_TOAST, { name: this.runnerName }));
}
} catch (e) {
this.deleting = false;
this.onError(e);
}
},
onError(error) {
const { message } = error;
createAlert({ message });
this.reportToSentry(error);
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
I18N_DELETE,
};
</script>
@ -119,23 +40,6 @@ export default {
-->
<runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
<runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
<gl-button
v-if="canDelete"
v-gl-tooltip.hover.viewport="deleteTitle"
v-gl-modal="runnerDeleteModalId"
:aria-label="deleteTitle"
icon="close"
:loading="deleting"
variant="danger"
data-testid="delete-runner"
/>
<runner-delete-modal
v-if="canDelete"
:ref="runnerDeleteModalId"
:modal-id="runnerDeleteModalId"
:runner-name="runnerName"
@primary="onDelete"
/>
<runner-delete-button v-if="canDelete" :runner="runner" :compact="true" />
</gl-button-group>
</template>

View File

@ -0,0 +1,147 @@
<script>
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import { createAlert } from '~/flash';
import { s__, sprintf } from '~/locale';
import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { I18N_DELETE_RUNNER } from '../constants';
import RunnerDeleteModal from './runner_delete_modal.vue';
const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
export default {
name: 'RunnerDeleteButton',
components: {
GlButton,
RunnerDeleteModal,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
props: {
runner: {
type: Object,
required: true,
validator: (runner) => {
return runner?.id && runner?.shortSha;
},
},
compact: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
deleting: false,
};
},
computed: {
runnerId() {
return getIdFromGraphQLId(this.runner.id);
},
runnerName() {
return `#${this.runnerId} (${this.runner.shortSha})`;
},
runnerDeleteModalId() {
return `delete-runner-modal-${this.runnerId}`;
},
icon() {
if (this.compact) {
return 'close';
}
return '';
},
buttonContent() {
if (this.compact) {
return null;
}
return I18N_DELETE_RUNNER;
},
buttonClass() {
// Ensure a square button is shown when compact: true.
// Without this class we will have distorted/rectangular button.
if (this.compact) {
return 'btn-icon';
}
return null;
},
ariaLabel() {
if (this.compact) {
return I18N_DELETE_RUNNER;
}
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.deleting) {
return I18N_DELETE_RUNNER;
}
return '';
},
},
methods: {
async onDelete() {
// Deleting stays "true" until this row is removed,
// should only change back if the operation fails.
this.deleting = true;
try {
const {
data: {
runnerDelete: { errors },
},
} = await this.$apollo.mutate({
mutation: runnerDeleteMutation,
variables: {
input: {
id: this.runner.id,
},
},
refetchQueries: ['getRunners', 'getGroupRunners'],
});
if (errors && errors.length) {
throw new Error(errors.join(' '));
} else {
this.$root.$toast?.show(sprintf(I18N_DELETED_TOAST, { name: this.runnerName }));
}
} catch (e) {
this.deleting = false;
this.onError(e);
}
},
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-gl-modal="runnerDeleteModalId"
:aria-label="ariaLabel"
:icon="icon"
:class="buttonClass"
:loading="deleting"
variant="danger"
>
{{ buttonContent }}
<runner-delete-modal
:modal-id="runnerDeleteModalId"
:runner-name="runnerName"
@primary="onDelete"
/>
</gl-button>
</template>

View File

@ -38,6 +38,7 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__(
// Active flag
export const I18N_PAUSE = __('Pause');
export const I18N_RESUME = __('Resume');
export const I18N_DELETE_RUNNER = s__('Runners|Delete runner');
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');

View File

@ -32,15 +32,15 @@ where you can select a project to enable the GitLab Slack application for.
Alternatively, you can configure the Slack application with a project's
integration settings.
Keep in mind that you need to have the appropriate permissions for your Slack
team in order to be able to install a new application, read more in Slack's
docs on [Adding an app to your workspace](https://slack.com/help/articles/202035138-Add-apps-to-your-Slack-workspace).
Keep in mind that you must have the appropriate permissions for your Slack
team to be able to install a new application, read more in Slack's
documentation on [Adding an app to your workspace](https://slack.com/help/articles/202035138-Add-apps-to-your-Slack-workspace).
To enable the GitLab service for your Slack team:
1. Go to your project's **Settings > Integration > Slack application** (only
visible on GitLab.com).
1. Click **Add to Slack**.
1. Select **Add to Slack**.
That's all! You can now start using the Slack slash commands.
@ -49,11 +49,11 @@ That's all! You can now start using the Slack slash commands.
To create a project alias on GitLab.com for Slack integration:
1. Go to your project's home page.
1. Navigate to **Settings > Integrations** (only visible on GitLab.com)
1. On the **Integrations** page, click **Slack application**.
1. Go to **Settings > Integrations** (only visible on GitLab.com)
1. On the **Integrations** page, select **Slack application**.
1. The current **Project Alias**, if any, is displayed. To edit this value,
click **Edit**.
1. Enter your desired alias, and click **Save changes**.
select **Edit**.
1. Enter your desired alias, and select **Save changes**.
Some Slack commands require a project alias, and fail with the following error
if the project alias is incorrect or missing from the command:

View File

@ -32,7 +32,7 @@ on your configuration:
If Mattermost is installed on the same server as GitLab, the configuration process can be
done for you by GitLab.
Go to the Mattermost Slash Command service on your project and click **Add to Mattermost** button.
Go to the Mattermost Slash Command service on your project and select **Add to Mattermost**.
## Manual configuration
@ -52,13 +52,13 @@ installations from source.
To enable custom slash commands from the Mattermost administrator console:
1. Sign in to Mattermost as a user with administrator privileges.
1. Next to your username, click the **{ellipsis_v}** **Settings** icon, and
1. Next to your username, select the **{ellipsis_v}** **Settings** icon, and
select **System Console**.
1. Select **Integration Management**, and set these values to `TRUE`:
- **Enable Custom Slash Commands**
- **Enable integrations to override usernames**
- **Enable integrations to override profile picture icons**
1. Click **Save**, but do not close this browser tab, because you need it in
1. Select **Save**, but do not close this browser tab, because you need it in
a later step.
### Get configuration values from GitLab
@ -85,9 +85,9 @@ the previous step:
1. In the Mattermost tab you left open when you
[enabled custom slash commands](#enable-custom-slash-commands), go to your
team page.
1. Click the **{ellipsis_v}** **Settings** icon, and select **Integrations**.
1. Select the **{ellipsis_v}** **Settings** icon, and select **Integrations**.
1. In the left menu, select **Slash commands**.
1. Click **Add Slash Command**:
1. Select **Add Slash Command**:
![Mattermost add command](img/mattermost_add_slash_command.png)
1. Provide a **Display Name** and **Description** for your new command.
@ -101,7 +101,7 @@ the previous step:
[viewed configuration values](#get-configuration-values-from-gitlab).
1. For all other values, you may use the suggestions from GitLab or use your
preferred values.
1. Copy the **Token** value, as you need it in a later step, and click **Done**.
1. Copy the **Token** value, as you need it in a later step, and select **Done**.
### Provide the Mattermost token to GitLab
@ -116,7 +116,7 @@ provide to GitLab:
![Mattermost copy token to GitLab](img/mattermost_gitlab_token.png)
1. Click **Save changes** for the changes to take effect.
1. Select **Save changes** for the changes to take effect.
Your slash command can now communicate with your GitLab project.

View File

@ -3,11 +3,7 @@
module QA
# Disable on staging until bulk_import_projects toggle is on by default
# Otherwise tests running in parallel can disable feature in the middle of other test
RSpec.shared_context 'with gitlab project migration', :requires_admin, except: { subdomain: :staging }, quarantine: {
only: { job: 'praefect' },
type: :investigating,
issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/348999'
} do
RSpec.shared_context 'with gitlab project migration', :requires_admin, except: { subdomain: :staging } do
let(:source_project_with_readme) { false }
let(:import_wait_duration) { { max_duration: 300, sleep_interval: 2 } }
let(:admin_api_client) { Runtime::API::Client.as_admin }

View File

@ -65,7 +65,7 @@ module QA
file_path: file_path,
status: example.execution_result.status,
reliable: example.metadata.key?(:reliable).to_s,
quarantined: example.metadata.key?(:quarantine).to_s,
quarantined: quarantined(example.metadata),
retried: ((example.metadata[:retry_attempts] || 0) > 0).to_s,
job_name: job_name,
merge_request: merge_request,
@ -144,6 +144,17 @@ module QA
(!!merge_request_iid).to_s
end
# Is spec quarantined
#
# @param [Hash] metadata
# @return [String]
def quarantined(metadata)
return "false" unless metadata.key?(:quarantine)
return "true" unless metadata[:quarantine].is_a?(Hash)
(!Specs::Helpers::Quarantine.quarantined_different_context?(metadata[:quarantine])).to_s
end
# Print log message
#
# @param [Symbol] level

View File

@ -158,6 +158,23 @@ describe QA::Support::Formatters::TestStatsFormatter do
end
end
context 'with context quarantined spec' do
let(:quarantined) { 'false' }
it 'exports data to influxdb with correct qurantine tag' do
run_spec do
it(
'spec',
quarantine: { only: { job: 'praefect' } },
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/1234'
) {}
end
expect(influx_write_api).to have_received(:write).once
expect(influx_write_api).to have_received(:write).with(data: [data])
end
end
context 'with staging full run' do
let(:run_type) { 'staging-full' }

View File

@ -1,84 +1,36 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { captureException } from '~/runner/sentry_utils';
import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
import RunnerActionsCell 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 RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import { runnersData } from '../../mock_data';
const mockRunner = runnersData.data.runners.nodes[0];
const getRunnersQueryName = getRunnersQuery.definitions[0].name.value;
const getGroupRunnersQueryName = getGroupRunnersQuery.definitions[0].name.value;
Vue.use(VueApollo);
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
describe('RunnerTypeCell', () => {
describe('RunnerActionsCell', () => {
let wrapper;
const mockToastShow = jest.fn();
const runnerDeleteMutationHandler = jest.fn();
const findEditBtn = () => wrapper.findComponent(RunnerEditButton);
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;
const findDeleteBtn = () => wrapper.findComponent(RunnerDeleteButton);
const createComponent = (runner = {}, options) => {
wrapper = shallowMountExtended(RunnerActionCell, {
wrapper = shallowMountExtended(RunnerActionsCell, {
propsData: {
runner: {
id: mockRunner.id,
shortSha: mockRunner.shortSha,
editAdminUrl: mockRunner.editAdminUrl,
userPermissions: mockRunner.userPermissions,
active: mockRunner.active,
...runner,
},
},
apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteMutationHandler]]),
directives: {
GlTooltip: createMockDirective(),
GlModal: createMockDirective(),
},
mocks: {
$toast: {
show: mockToastShow,
},
},
...options,
});
};
beforeEach(() => {
runnerDeleteMutationHandler.mockResolvedValue({
data: {
runnerDelete: {
errors: [],
},
},
});
});
afterEach(() => {
mockToastShow.mockReset();
runnerDeleteMutationHandler.mockReset();
wrapper.destroy();
});
@ -129,51 +81,10 @@ describe('RunnerTypeCell', () => {
});
describe('Delete action', () => {
beforeEach(() => {
createComponent(
{},
{
stubs: { RunnerDeleteModal },
},
);
});
it('Renders a compact delete button', () => {
createComponent();
it('Renders delete button', () => {
expect(findDeleteBtn().exists()).toBe(true);
});
it('Delete button opens delete modal', () => {
const modalId = getBinding(findDeleteBtn().element, 'gl-modal').value;
expect(findRunnerDeleteModal().attributes('modal-id')).toBeDefined();
expect(findRunnerDeleteModal().attributes('modal-id')).toBe(modalId);
});
it('Delete modal shows the runner name', () => {
expect(findRunnerDeleteModal().props('runnerName')).toBe(
`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`,
);
});
it('The delete button does not have a loading icon', () => {
expect(findDeleteBtn().props('loading')).toBe(false);
expect(getTooltip(findDeleteBtn())).toBe('Delete runner');
});
it('When delete mutation is called, current runners are refetched', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate');
findRunnerDeleteModal().vm.$emit('primary');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: runnerDeleteMutation,
variables: {
input: {
id: mockRunner.id,
},
},
awaitRefetchQueries: true,
refetchQueries: [getRunnersQueryName, getGroupRunnersQueryName],
});
expect(findDeleteBtn().props('compact')).toBe(true);
});
it('Does not render the runner delete button when user cannot delete', () => {
@ -185,91 +96,6 @@ describe('RunnerTypeCell', () => {
});
expect(findDeleteBtn().exists()).toBe(false);
expect(findRunnerDeleteModal().exists()).toBe(false);
});
describe('When delete is clicked', () => {
beforeEach(async () => {
findRunnerDeleteModal().vm.$emit('primary');
await waitForPromises();
});
it('The delete mutation is called correctly', () => {
expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1);
expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({
input: { id: mockRunner.id },
});
});
it('The delete button has a loading icon', () => {
expect(findDeleteBtn().props('loading')).toBe(true);
expect(getTooltip(findDeleteBtn())).toBe('');
});
it('The toast notification is shown', async () => {
await waitForPromises();
expect(mockToastShow).toHaveBeenCalledTimes(1);
expect(mockToastShow).toHaveBeenCalledWith(
expect.stringContaining(`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`),
);
});
});
describe('When delete fails', () => {
describe('On a network error', () => {
const mockErrorMsg = 'Delete error!';
beforeEach(async () => {
runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
findRunnerDeleteModal().vm.$emit('primary');
await waitForPromises();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(mockErrorMsg),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
it('toast notification is not shown', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
});
describe('On a validation error', () => {
const mockErrorMsg = 'Runner not found!';
const mockErrorMsg2 = 'User not allowed!';
beforeEach(async () => {
runnerDeleteMutationHandler.mockResolvedValue({
data: {
runnerDelete: {
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
findRunnerDeleteModal().vm.$emit('primary');
await waitForPromises();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
});
});
});
});

View File

@ -0,0 +1,233 @@
import Vue from 'vue';
import { GlButton, GlToast } 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 runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { createAlert } from '~/flash';
import { I18N_DELETE_RUNNER } from '~/runner/constants';
import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
import { runnersData } from '../mock_data';
const mockRunner = runnersData.data.runners.nodes[0];
const mockRunnerId = getIdFromGraphQLId(mockRunner.id);
Vue.use(VueApollo);
Vue.use(GlToast);
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
describe('RunnerDeleteButton', () => {
let wrapper;
let runnerDeleteHandler;
let showToast;
const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
const getModal = () => getBinding(wrapper.element, 'gl-modal').value;
const findBtn = () => wrapper.findComponent(GlButton);
const findModal = () => wrapper.findComponent(RunnerDeleteModal);
const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
const { runner, ...propsData } = props;
wrapper = mountFn(RunnerDeleteButton, {
propsData: {
runner: {
id: mockRunner.id,
shortSha: mockRunner.shortSha,
...runner,
},
...propsData,
},
apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]),
directives: {
GlTooltip: createMockDirective(),
GlModal: createMockDirective(),
},
});
showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show').mockImplementation(() => {});
};
const clickOkAndWait = async () => {
findModal().vm.$emit('primary');
await waitForPromises();
};
beforeEach(() => {
runnerDeleteHandler = jest.fn().mockImplementation(() => {
return Promise.resolve({
data: {
runnerDelete: {
errors: [],
},
},
});
});
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays a delete button without an icon', () => {
expect(findBtn().props()).toMatchObject({
loading: false,
icon: '',
});
expect(findBtn().classes('btn-icon')).toBe(false);
expect(findBtn().text()).toBe(I18N_DELETE_RUNNER);
});
it('Displays a modal with the runner name', () => {
expect(findModal().props('runnerName')).toBe(`#${mockRunnerId} (${mockRunner.shortSha})`);
});
it('Displays a modal when clicked', () => {
const modalId = `delete-runner-modal-${mockRunnerId}`;
expect(getModal()).toBe(modalId);
expect(findModal().attributes('modal-id')).toBe(modalId);
});
it('Does not display redundant text for screen readers', () => {
expect(findBtn().attributes('aria-label')).toBe(undefined);
});
describe(`Before the delete button is clicked`, () => {
it('The mutation has not been called', () => {
expect(runnerDeleteHandler).toHaveBeenCalledTimes(0);
});
});
describe('Immediately after the delete button is clicked', () => {
beforeEach(async () => {
findModal().vm.$emit('primary');
});
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 delete button', () => {
beforeEach(async () => {
await clickOkAndWait();
});
it('The mutation to delete is called', async () => {
expect(runnerDeleteHandler).toHaveBeenCalledTimes(1);
expect(runnerDeleteHandler).toHaveBeenCalledWith({
input: {
id: mockRunner.id,
},
});
});
it('The user is notified', async () => {
expect(showToast).toHaveBeenCalledTimes(1);
});
});
describe('When update fails', () => {
describe('On a network error', () => {
const mockErrorMsg = 'Update error!';
beforeEach(async () => {
runnerDeleteHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
await clickOkAndWait();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(mockErrorMsg),
component: 'RunnerDeleteButton',
});
});
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 () => {
runnerDeleteHandler.mockResolvedValueOnce({
data: {
runnerDelete: {
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
await clickOkAndWait();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerDeleteButton',
});
});
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('');
expect(findBtn().classes('btn-icon')).toBe(true);
});
it('Display correctly for screen readers', () => {
expect(findBtn().attributes('aria-label')).toBe(I18N_DELETE_RUNNER);
expect(getTooltip()).toBe(I18N_DELETE_RUNNER);
});
describe('Immediately after the button is clicked', () => {
beforeEach(async () => {
findModal().vm.$emit('primary');
});
it('The button has a loading state', async () => {
expect(findBtn().props('loading')).toBe(true);
});
it('The stale tooltip is removed', async () => {
expect(getTooltip()).toBe('');
});
});
});
});

View File

@ -8,6 +8,7 @@ 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 RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes;
@ -94,7 +95,7 @@ describe('RunnerList', () => {
expect(actions.findComponent(RunnerEditButton).exists()).toBe(true);
expect(actions.findComponent(RunnerPauseButton).exists()).toBe(true);
expect(actions.findByTestId('delete-runner').exists()).toBe(true);
expect(actions.findComponent(RunnerDeleteButton).exists()).toBe(true);
});
describe('Table data formatting', () => {