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

Update error handling and display in runners UI

This change update error reporting to Sentry in the runner UI and
adds more specific flash messages to the user.
parent 81e12628
No related branches found
No related tags found
No related merge requests found
Showing
with 385 additions and 112 deletions
<script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
 
const i18n = {
I18N_EDIT: __('Edit'),
Loading
Loading
@@ -14,6 +16,7 @@ const i18n = {
};
 
export default {
name: 'RunnerActionsCell',
components: {
GlButton,
GlButtonGroup,
Loading
Loading
@@ -86,7 +89,7 @@ export default {
});
 
if (errors && errors.length) {
this.onError(new Error(errors[0]));
throw new Error(errors.join(' '));
}
} catch (e) {
this.onError(e);
Loading
Loading
@@ -109,7 +112,7 @@ export default {
runnerDelete: { errors },
},
} = await this.$apollo.mutate({
mutation: deleteRunnerMutation,
mutation: runnerDeleteMutation,
variables: {
input: {
id: this.runner.id,
Loading
Loading
@@ -119,7 +122,7 @@ export default {
refetchQueries: ['getRunners'],
});
if (errors && errors.length) {
this.onError(new Error(errors[0]));
throw new Error(errors.join(' '));
}
} catch (e) {
this.onError(e);
Loading
Loading
@@ -129,9 +132,13 @@ export default {
},
 
onError(error) {
// TODO Render errors when "delete" action is done
// `active` toggle would not fail due to user input.
throw error;
const { message } = error;
createFlash({ message });
this.reportToSentry(error);
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
i18n,
Loading
Loading
Loading
Loading
@@ -3,9 +3,11 @@ import { GlButton } from '@gitlab/ui';
import createFlash, { FLASH_TYPES } from '~/flash';
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';
 
export default {
name: 'RunnerRegistrationTokenReset',
components: {
GlButton,
},
Loading
Loading
@@ -52,8 +54,7 @@ export default {
},
});
if (errors && errors.length) {
this.onError(new Error(errors[0]));
return;
throw new Error(errors.join(' '));
}
this.onSuccess(token);
} catch (e) {
Loading
Loading
@@ -65,6 +66,8 @@ export default {
onError(error) {
const { message } = error;
createFlash({ message });
this.reportToSentry(error);
},
onSuccess(token) {
createFlash({
Loading
Loading
@@ -73,6 +76,9 @@ export default {
});
this.$emit('tokenReset', token);
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
};
</script>
Loading
Loading
Loading
Loading
@@ -9,6 +9,7 @@ import {
} from '@gitlab/ui';
import createFlash, { FLASH_TYPES } from '~/flash';
import { __ } from '~/locale';
import { captureException } from '~/runner/sentry_utils';
import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql';
 
Loading
Loading
@@ -37,6 +38,7 @@ const runnerToModel = (runner) => {
};
 
export default {
name: 'RunnerUpdateForm',
components: {
GlButton,
GlForm,
Loading
Loading
@@ -104,25 +106,28 @@ export default {
});
 
if (errors?.length) {
this.onError(new Error(errors[0]));
// Validation errors need not be thrown
createFlash({ message: errors[0] });
return;
}
 
this.onSuccess();
} catch (e) {
this.onError(e);
} catch (error) {
const { message } = error;
createFlash({ message });
this.reportToSentry(error);
} finally {
this.saving = false;
}
},
onError(error) {
const { message } = error;
createFlash({ message });
},
onSuccess() {
createFlash({ message: __('Changes saved.'), type: FLASH_TYPES.SUCCESS });
this.model = runnerToModel(this.runner);
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
ACCESS_LEVEL_NOT_PROTECTED,
ACCESS_LEVEL_REF_PROTECTED,
Loading
Loading
Loading
Loading
@@ -2,6 +2,7 @@ import { s__ } from '~/locale';
 
export const RUNNER_PAGE_SIZE = 20;
 
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
 
export const RUNNER_TAG_BADGE_VARIANT = 'info';
Loading
Loading
<script>
import createFlash from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { sprintf } from '~/locale';
import RunnerTypeAlert from '../components/runner_type_alert.vue';
import RunnerTypeBadge from '../components/runner_type_badge.vue';
import RunnerUpdateForm from '../components/runner_update_form.vue';
import { I18N_DETAILS_TITLE } from '../constants';
import { I18N_DETAILS_TITLE, I18N_FETCH_ERROR } from '../constants';
import getRunnerQuery from '../graphql/get_runner.query.graphql';
import { captureException } from '../sentry_utils';
 
export default {
name: 'RunnerDetailsApp',
components: {
RunnerTypeAlert,
RunnerTypeBadge,
RunnerUpdateForm,
},
i18n: {
I18N_DETAILS_TITLE,
},
props: {
runnerId: {
type: String,
Loading
Loading
@@ -35,6 +36,24 @@ export default {
id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
};
},
error(error) {
createFlash({ message: I18N_FETCH_ERROR });
this.reportToSentry(error);
},
},
},
computed: {
pageTitle() {
return sprintf(I18N_DETAILS_TITLE, { runner_id: this.runnerId });
},
},
errorCaptured(error) {
this.reportToSentry(error);
},
methods: {
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
};
Loading
Loading
@@ -42,9 +61,7 @@ export default {
<template>
<div>
<h2 class="page-title">
{{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }}
<runner-type-badge v-if="runner" :type="runner.runnerType" />
{{ pageTitle }} <runner-type-badge v-if="runner" :type="runner.runnerType" />
</h2>
 
<runner-type-alert v-if="runner" :type="runner.runnerType" />
Loading
Loading
<script>
import * as Sentry from '@sentry/browser';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
Loading
Loading
@@ -7,8 +7,9 @@ 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 } from '../constants';
import { INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
import { captureException } from '../sentry_utils';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
Loading
Loading
@@ -16,6 +17,7 @@ import {
} from './runner_search_utils';
 
export default {
name: 'RunnerListApp',
components: {
RunnerFilteredSearchBar,
RunnerList,
Loading
Loading
@@ -59,8 +61,10 @@ export default {
pageInfo: runners?.pageInfo || {},
};
},
error(err) {
this.captureException(err);
error(error) {
createFlash({ message: I18N_FETCH_ERROR });
this.reportToSentry(error);
},
},
},
Loading
Loading
@@ -87,15 +91,12 @@ export default {
},
},
},
errorCaptured(err) {
this.captureException(err);
errorCaptured(error) {
this.reportToSentry(error);
},
methods: {
captureException(err) {
Sentry.withScope((scope) => {
scope.setTag('component', 'runner_list_app');
Sentry.captureException(err);
});
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
INSTANCE_TYPE,
Loading
Loading
import * as Sentry from '@sentry/browser';
const COMPONENT_TAG = 'vue_component';
/**
* Captures an error in a Vue component and sends it
* to Sentry
*
* @param {Object} options
* @param {Error} options.error - Exception or error
* @param {String} options.component - Component name in CamelCase format
*/
export const captureException = ({ error, component }) => {
Sentry.withScope((scope) => {
if (component) {
scope.setTag(COMPONENT_TAG, component);
}
Sentry.captureException(error);
});
};
Loading
Loading
@@ -28144,6 +28144,9 @@ msgstr ""
msgid "Runners|Show Runner installation instructions"
msgstr ""
 
msgid "Runners|Something went wrong while fetching runner data."
msgstr ""
msgid "Runners|Something went wrong while fetching the tags suggestions"
msgstr ""
 
Loading
Loading
import { shallowMount } from '@vue/test-utils';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { runnerData } from '../../mock_data';
 
const mockId = '1';
const mockRunner = runnerData.data.runner;
 
const getRunnersQueryName = getRunnersQuery.definitions[0].name.value;
 
const localVue = createLocalVue();
localVue.use(VueApollo);
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
describe('RunnerTypeCell', () => {
let wrapper;
let mutate;
const runnerDeleteMutationHandler = jest.fn();
const runnerUpdateMutationHandler = jest.fn();
 
const findEditBtn = () => wrapper.findByTestId('edit-runner');
const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner');
Loading
Loading
@@ -23,26 +35,43 @@ describe('RunnerTypeCell', () => {
shallowMount(RunnerActionCell, {
propsData: {
runner: {
id: `gid://gitlab/Ci::Runner/${mockId}`,
id: mockRunner.id,
active,
},
},
mocks: {
$apollo: {
mutate,
},
},
localVue,
apolloProvider: createMockApollo([
[runnerDeleteMutation, runnerDeleteMutationHandler],
[runnerUpdateMutation, runnerUpdateMutationHandler],
]),
...options,
}),
);
};
 
beforeEach(() => {
mutate = jest.fn();
runnerDeleteMutationHandler.mockResolvedValue({
data: {
runnerDelete: {
errors: [],
},
},
});
runnerUpdateMutationHandler.mockResolvedValue({
data: {
runnerUpdate: {
runner: runnerData.data.runner,
errors: [],
},
},
});
});
 
afterEach(() => {
mutate.mockReset();
runnerDeleteMutationHandler.mockReset();
runnerUpdateMutationHandler.mockReset();
wrapper.destroy();
});
 
Loading
Loading
@@ -58,17 +87,6 @@ describe('RunnerTypeCell', () => {
${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true}
`('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => {
beforeEach(() => {
mutate.mockResolvedValue({
data: {
runnerUpdate: {
runner: {
id: `gid://gitlab/Ci::Runner/1`,
__typename: 'CiRunner',
},
},
},
});
createComponent({ active: isActive });
});
 
Loading
Loading
@@ -93,46 +111,93 @@ describe('RunnerTypeCell', () => {
});
 
describe(`When clicking on the ${icon} button`, () => {
beforeEach(async () => {
it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => {
expect(runnerUpdateMutationHandler).toHaveBeenCalledTimes(0);
await findToggleActiveBtn().vm.$emit('click');
await waitForPromises();
});
 
it(`The apollo mutation to set active to ${newActiveValue} is called`, () => {
expect(mutate).toHaveBeenCalledTimes(1);
expect(mutate).toHaveBeenCalledWith({
mutation: runnerUpdateMutation,
variables: {
input: {
id: `gid://gitlab/Ci::Runner/${mockId}`,
active: newActiveValue,
},
expect(runnerUpdateMutationHandler).toHaveBeenCalledTimes(1);
expect(runnerUpdateMutationHandler).toHaveBeenCalledWith({
input: {
id: mockRunner.id,
active: newActiveValue,
},
});
});
 
it('The button does not have a loading state', () => {
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 the user clicks a runner', () => {
beforeEach(() => {
createComponent();
describe('When update fails', () => {
describe('On a network error', () => {
const mockErrorMsg = 'Update error!';
beforeEach(async () => {
runnerUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
await findToggleActiveBtn().vm.$emit('click');
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`Network error: ${mockErrorMsg}`),
component: 'RunnerActionsCell',
});
});
 
mutate.mockResolvedValue({
data: {
runnerDelete: {
runner: {
id: `gid://gitlab/Ci::Runner/1`,
__typename: 'CiRunner',
it('error is shown to the user', () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
describe('On a validation error', () => {
const mockErrorMsg = 'Runner not found!';
const mockErrorMsg2 = 'User not allowed!';
beforeEach(async () => {
runnerUpdateMutationHandler.mockResolvedValue({
data: {
runnerUpdate: {
runner: runnerData.data.runner,
errors: [mockErrorMsg, mockErrorMsg2],
},
},
},
},
});
await findToggleActiveBtn().vm.$emit('click');
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
});
});
 
describe('When the user clicks a runner', () => {
beforeEach(() => {
jest.spyOn(window, 'confirm');
createComponent();
});
afterEach(() => {
window.confirm.mockRestore();
});
 
describe('When the user confirms deletion', () => {
Loading
Loading
@@ -141,18 +206,28 @@ describe('RunnerTypeCell', () => {
await findDeleteBtn().vm.$emit('click');
});
 
it('The user sees a confirmation alert', async () => {
it('The user sees a confirmation alert', () => {
expect(window.confirm).toHaveBeenCalledTimes(1);
expect(window.confirm).toHaveBeenCalledWith(expect.any(String));
});
 
it('The delete mutation is called correctly', () => {
expect(mutate).toHaveBeenCalledTimes(1);
expect(mutate).toHaveBeenCalledWith({
mutation: deleteRunnerMutation,
expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1);
expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({
input: { id: mockRunner.id },
});
});
it('When delete mutation is called, current runners are refetched', async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate');
await findDeleteBtn().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: runnerDeleteMutation,
variables: {
input: {
id: `gid://gitlab/Ci::Runner/${mockId}`,
id: mockRunner.id,
},
},
awaitRefetchQueries: true,
Loading
Loading
@@ -176,6 +251,57 @@ describe('RunnerTypeCell', () => {
 
expect(findDeleteBtn().attributes('title')).toBe('');
});
describe('When delete fails', () => {
describe('On a network error', () => {
const mockErrorMsg = 'Delete error!';
beforeEach(async () => {
runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
await findDeleteBtn().vm.$emit('click');
});
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(createFlash).toHaveBeenCalledTimes(1);
});
});
describe('On a validation error', () => {
const mockErrorMsg = 'Runner not found!';
const mockErrorMsg2 = 'User not allowed!';
beforeEach(async () => {
runnerDeleteMutationHandler.mockResolvedValue({
data: {
runnerDelete: {
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
await findDeleteBtn().vm.$emit('click');
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
});
});
 
describe('When the user does not confirm deletion', () => {
Loading
Loading
@@ -189,7 +315,7 @@ describe('RunnerTypeCell', () => {
});
 
it('The delete mutation is not called', () => {
expect(mutate).toHaveBeenCalledTimes(0);
expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(0);
});
 
it('The delete button does not have a loading state', () => {
Loading
Loading
Loading
Loading
@@ -7,8 +7,10 @@ import createFlash, { FLASH_TYPES } from '~/flash';
import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
import { INSTANCE_TYPE } from '~/runner/constants';
import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
 
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
 
const localVue = createLocalVue();
localVue.use(VueApollo);
Loading
Loading
@@ -111,25 +113,32 @@ describe('RunnerRegistrationTokenReset', () => {
 
describe('On error', () => {
it('On network error, error message is shown', async () => {
runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(
new Error('Something went wrong'),
);
const mockErrorMsg = 'Token reset failed!';
runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
 
window.confirm.mockReturnValueOnce(true);
await findButton().vm.$emit('click');
await waitForPromises();
 
expect(createFlash).toHaveBeenLastCalledWith({
message: 'Network error: Something went wrong',
message: `Network error: ${mockErrorMsg}`,
});
expect(captureException).toHaveBeenCalledWith({
error: new Error(`Network error: ${mockErrorMsg}`),
component: 'RunnerRegistrationTokenReset',
});
});
 
it('On validation error, error message is shown', async () => {
const mockErrorMsg = 'User not allowed!';
const mockErrorMsg2 = 'Type is not valid!';
runnersRegistrationTokenResetMutationHandler.mockResolvedValue({
data: {
runnersRegistrationTokenReset: {
token: null,
errors: ['Token reset failed'],
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
Loading
Loading
@@ -139,7 +148,11 @@ describe('RunnerRegistrationTokenReset', () => {
await waitForPromises();
 
expect(createFlash).toHaveBeenLastCalledWith({
message: 'Token reset failed',
message: `${mockErrorMsg} ${mockErrorMsg2}`,
});
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerRegistrationTokenReset',
});
});
});
Loading
Loading
Loading
Loading
@@ -15,9 +15,11 @@ import {
ACCESS_LEVEL_NOT_PROTECTED,
} from '~/runner/constants';
import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { runnerData } from '../mock_data';
 
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
 
const mockRunner = runnerData.data.runner;
 
Loading
Loading
@@ -232,22 +234,30 @@ describe('RunnerUpdateForm', () => {
});
 
it('On network error, error message is shown', async () => {
runnerUpdateHandler.mockRejectedValue(new Error('Something went wrong'));
const mockErrorMsg = 'Update error!';
runnerUpdateHandler.mockRejectedValue(new Error(mockErrorMsg));
 
await submitFormAndWait();
 
expect(createFlash).toHaveBeenLastCalledWith({
message: 'Network error: Something went wrong',
message: `Network error: ${mockErrorMsg}`,
});
expect(captureException).toHaveBeenCalledWith({
component: 'RunnerUpdateForm',
error: new Error(`Network error: ${mockErrorMsg}`),
});
expect(findSubmitDisabledAttr()).toBeUndefined();
});
 
it('On validation error, error message is shown', async () => {
it('On validation error, error message is shown and it is not sent to sentry', async () => {
const mockErrorMsg = 'Invalid value!';
runnerUpdateHandler.mockResolvedValue({
data: {
runnerUpdate: {
runner: mockRunner,
errors: ['A value is invalid'],
errors: [mockErrorMsg],
},
},
});
Loading
Loading
@@ -255,8 +265,9 @@ describe('RunnerUpdateForm', () => {
await submitFormAndWait();
 
expect(createFlash).toHaveBeenLastCalledWith({
message: 'A value is invalid',
message: mockErrorMsg,
});
expect(captureException).not.toHaveBeenCalled();
expect(findSubmitDisabledAttr()).toBeUndefined();
});
});
Loading
Loading
Loading
Loading
@@ -2,14 +2,19 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
 
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue';
import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql';
import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue';
import { captureException } from '~/runner/sentry_utils';
 
import { runnerData } from '../mock_data';
 
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
const mockRunnerGraphqlId = runnerData.data.runner.id;
const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
 
Loading
Loading
@@ -23,11 +28,9 @@ describe('RunnerDetailsApp', () => {
const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge);
 
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getRunnerQuery, mockRunnerQuery]];
wrapper = mountFn(RunnerDetailsApp, {
localVue,
apolloProvider: createMockApollo(handlers),
apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]),
propsData: {
runnerId: mockRunnerId,
...props,
Loading
Loading
@@ -63,4 +66,22 @@ describe('RunnerDetailsApp', () => {
 
expect(findRunnerTypeBadge().text()).toBe('shared');
});
describe('When there is an error', () => {
beforeEach(async () => {
mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!'));
await createComponentWithApollo();
});
it('error is reported to sentry', async () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error('Network error: Error!'),
component: 'RunnerDetailsApp',
});
});
it('error is shown to the user', async () => {
expect(createFlash).toHaveBeenCalled();
});
});
});
import * as Sentry from '@sentry/browser';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
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';
Loading
Loading
@@ -23,13 +23,15 @@ import {
} from '~/runner/constants';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import RunnerListApp from '~/runner/runner_list/runner_list_app.vue';
import { captureException } from '~/runner/sentry_utils';
 
import { runnersData, runnersDataPaginated } from '../mock_data';
 
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockActiveRunnersCount = 2;
 
jest.mock('@sentry/browser');
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
updateHistory: jest.fn(),
Loading
Loading
@@ -80,11 +82,6 @@ describe('RunnerListApp', () => {
beforeEach(async () => {
setQuery('');
 
Sentry.withScope.mockImplementation((fn) => {
const scope = { setTag: jest.fn() };
fn(scope);
});
mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
createComponentWithApollo();
await waitForPromises();
Loading
Loading
@@ -191,15 +188,21 @@ describe('RunnerListApp', () => {
 
describe('when runners query fails', () => {
beforeEach(async () => {
mockRunnersQuery = jest.fn().mockRejectedValue(new Error());
mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
createComponentWithApollo();
 
await waitForPromises();
});
 
it('error is reported to sentry', async () => {
expect(Sentry.withScope).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalled();
expect(captureException).toHaveBeenCalledWith({
error: new Error('Network error: Error!'),
component: 'RunnerListApp',
});
});
it('error is shown to the user', async () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
 
Loading
Loading
import * as Sentry from '@sentry/browser';
import { captureException } from '~/runner/sentry_utils';
jest.mock('@sentry/browser');
describe('~/runner/sentry_utils', () => {
let mockSetTag;
beforeEach(async () => {
mockSetTag = jest.fn();
Sentry.withScope.mockImplementation((fn) => {
const scope = { setTag: mockSetTag };
fn(scope);
});
});
describe('captureException', () => {
const mockError = new Error('Something went wrong!');
it('error is reported to sentry', () => {
captureException({ error: mockError });
expect(Sentry.withScope).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(mockError);
});
it('error is reported to sentry with a component name', () => {
const mockComponentName = 'MyComponent';
captureException({ error: mockError, component: mockComponentName });
expect(Sentry.withScope).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(mockError);
expect(mockSetTag).toHaveBeenCalledWith('vue_component', mockComponentName);
});
});
});
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