Commit f7d288d3 authored by Miguel Rincon's avatar Miguel Rincon

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
<script> <script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale'; 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 runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
const i18n = { const i18n = {
I18N_EDIT: __('Edit'), I18N_EDIT: __('Edit'),
...@@ -14,6 +16,7 @@ const i18n = { ...@@ -14,6 +16,7 @@ const i18n = {
}; };
export default { export default {
name: 'RunnerActionsCell',
components: { components: {
GlButton, GlButton,
GlButtonGroup, GlButtonGroup,
...@@ -86,7 +89,7 @@ export default { ...@@ -86,7 +89,7 @@ export default {
}); });
if (errors && errors.length) { if (errors && errors.length) {
this.onError(new Error(errors[0])); throw new Error(errors.join(' '));
} }
} catch (e) { } catch (e) {
this.onError(e); this.onError(e);
...@@ -109,7 +112,7 @@ export default { ...@@ -109,7 +112,7 @@ export default {
runnerDelete: { errors }, runnerDelete: { errors },
}, },
} = await this.$apollo.mutate({ } = await this.$apollo.mutate({
mutation: deleteRunnerMutation, mutation: runnerDeleteMutation,
variables: { variables: {
input: { input: {
id: this.runner.id, id: this.runner.id,
...@@ -119,7 +122,7 @@ export default { ...@@ -119,7 +122,7 @@ export default {
refetchQueries: ['getRunners'], refetchQueries: ['getRunners'],
}); });
if (errors && errors.length) { if (errors && errors.length) {
this.onError(new Error(errors[0])); throw new Error(errors.join(' '));
} }
} catch (e) { } catch (e) {
this.onError(e); this.onError(e);
...@@ -129,9 +132,13 @@ export default { ...@@ -129,9 +132,13 @@ export default {
}, },
onError(error) { onError(error) {
// TODO Render errors when "delete" action is done const { message } = error;
// `active` toggle would not fail due to user input. createFlash({ message });
throw error;
this.reportToSentry(error);
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
}, },
}, },
i18n, i18n,
......
...@@ -3,9 +3,11 @@ import { GlButton } from '@gitlab/ui'; ...@@ -3,9 +3,11 @@ import { GlButton } from '@gitlab/ui';
import createFlash, { FLASH_TYPES } from '~/flash'; import createFlash, { FLASH_TYPES } from '~/flash';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; 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 { export default {
name: 'RunnerRegistrationTokenReset',
components: { components: {
GlButton, GlButton,
}, },
...@@ -52,8 +54,7 @@ export default { ...@@ -52,8 +54,7 @@ export default {
}, },
}); });
if (errors && errors.length) { if (errors && errors.length) {
this.onError(new Error(errors[0])); throw new Error(errors.join(' '));
return;
} }
this.onSuccess(token); this.onSuccess(token);
} catch (e) { } catch (e) {
...@@ -65,6 +66,8 @@ export default { ...@@ -65,6 +66,8 @@ export default {
onError(error) { onError(error) {
const { message } = error; const { message } = error;
createFlash({ message }); createFlash({ message });
this.reportToSentry(error);
}, },
onSuccess(token) { onSuccess(token) {
createFlash({ createFlash({
...@@ -73,6 +76,9 @@ export default { ...@@ -73,6 +76,9 @@ export default {
}); });
this.$emit('tokenReset', token); this.$emit('tokenReset', token);
}, },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
}, },
}; };
</script> </script>
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import createFlash, { FLASH_TYPES } from '~/flash'; import createFlash, { FLASH_TYPES } from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { captureException } from '~/runner/sentry_utils';
import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql'; import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql';
...@@ -37,6 +38,7 @@ const runnerToModel = (runner) => { ...@@ -37,6 +38,7 @@ const runnerToModel = (runner) => {
}; };
export default { export default {
name: 'RunnerUpdateForm',
components: { components: {
GlButton, GlButton,
GlForm, GlForm,
...@@ -104,25 +106,28 @@ export default { ...@@ -104,25 +106,28 @@ export default {
}); });
if (errors?.length) { if (errors?.length) {
this.onError(new Error(errors[0])); // Validation errors need not be thrown
createFlash({ message: errors[0] });
return; return;
} }
this.onSuccess(); this.onSuccess();
} catch (e) { } catch (error) {
this.onError(e); const { message } = error;
createFlash({ message });
this.reportToSentry(error);
} finally { } finally {
this.saving = false; this.saving = false;
} }
}, },
onError(error) {
const { message } = error;
createFlash({ message });
},
onSuccess() { onSuccess() {
createFlash({ message: __('Changes saved.'), type: FLASH_TYPES.SUCCESS }); createFlash({ message: __('Changes saved.'), type: FLASH_TYPES.SUCCESS });
this.model = runnerToModel(this.runner); this.model = runnerToModel(this.runner);
}, },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
}, },
ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED,
ACCESS_LEVEL_REF_PROTECTED, ACCESS_LEVEL_REF_PROTECTED,
......
...@@ -2,6 +2,7 @@ import { s__ } from '~/locale'; ...@@ -2,6 +2,7 @@ import { s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20; 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 I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
export const RUNNER_TAG_BADGE_VARIANT = 'info'; export const RUNNER_TAG_BADGE_VARIANT = 'info';
......
<script> <script>
import createFlash from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId } from '~/graphql_shared/utils';
import { sprintf } from '~/locale';
import RunnerTypeAlert from '../components/runner_type_alert.vue'; import RunnerTypeAlert from '../components/runner_type_alert.vue';
import RunnerTypeBadge from '../components/runner_type_badge.vue'; import RunnerTypeBadge from '../components/runner_type_badge.vue';
import RunnerUpdateForm from '../components/runner_update_form.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 getRunnerQuery from '../graphql/get_runner.query.graphql';
import { captureException } from '../sentry_utils';
export default { export default {
name: 'RunnerDetailsApp',
components: { components: {
RunnerTypeAlert, RunnerTypeAlert,
RunnerTypeBadge, RunnerTypeBadge,
RunnerUpdateForm, RunnerUpdateForm,
}, },
i18n: {
I18N_DETAILS_TITLE,
},
props: { props: {
runnerId: { runnerId: {
type: String, type: String,
...@@ -35,6 +36,24 @@ export default { ...@@ -35,6 +36,24 @@ export default {
id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), 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 });
}, },
}, },
}; };
...@@ -42,9 +61,7 @@ export default { ...@@ -42,9 +61,7 @@ export default {
<template> <template>
<div> <div>
<h2 class="page-title"> <h2 class="page-title">
{{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }} {{ pageTitle }} <runner-type-badge v-if="runner" :type="runner.runnerType" />
<runner-type-badge v-if="runner" :type="runner.runnerType" />
</h2> </h2>
<runner-type-alert v-if="runner" :type="runner.runnerType" /> <runner-type-alert v-if="runner" :type="runner.runnerType" />
......
<script> <script>
import * as Sentry from '@sentry/browser'; import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility'; import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
...@@ -7,8 +7,9 @@ import RunnerList from '../components/runner_list.vue'; ...@@ -7,8 +7,9 @@ import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerPagination from '../components/runner_pagination.vue'; import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeHelp from '../components/runner_type_help.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 getRunnersQuery from '../graphql/get_runners.query.graphql';
import { captureException } from '../sentry_utils';
import { import {
fromUrlQueryToSearch, fromUrlQueryToSearch,
fromSearchToUrl, fromSearchToUrl,
...@@ -16,6 +17,7 @@ import { ...@@ -16,6 +17,7 @@ import {
} from './runner_search_utils'; } from './runner_search_utils';
export default { export default {
name: 'RunnerListApp',
components: { components: {
RunnerFilteredSearchBar, RunnerFilteredSearchBar,
RunnerList, RunnerList,
...@@ -59,8 +61,10 @@ export default { ...@@ -59,8 +61,10 @@ export default {
pageInfo: runners?.pageInfo || {}, pageInfo: runners?.pageInfo || {},
}; };
}, },
error(err) { error(error) {
this.captureException(err); createFlash({ message: I18N_FETCH_ERROR });
this.reportToSentry(error);
}, },
}, },
}, },
...@@ -87,15 +91,12 @@ export default { ...@@ -87,15 +91,12 @@ export default {
}, },
}, },
}, },
errorCaptured(err) { errorCaptured(error) {
this.captureException(err); this.reportToSentry(error);
}, },
methods: { methods: {
captureException(err) { reportToSentry(error) {
Sentry.withScope((scope) => { captureException({ error, component: this.$options.name });
scope.setTag('component', 'runner_list_app');
Sentry.captureException(err);
});
}, },
}, },
INSTANCE_TYPE, INSTANCE_TYPE,
......
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);
});
};
...@@ -28144,6 +28144,9 @@ msgstr "" ...@@ -28144,6 +28144,9 @@ msgstr ""
msgid "Runners|Show Runner installation instructions" msgid "Runners|Show Runner installation instructions"
msgstr "" msgstr ""
msgid "Runners|Something went wrong while fetching runner data."
msgstr ""
msgid "Runners|Something went wrong while fetching the tags suggestions" msgid "Runners|Something went wrong while fetching the tags suggestions"
msgstr "" msgstr ""
......
...@@ -7,8 +7,10 @@ import createFlash, { FLASH_TYPES } from '~/flash'; ...@@ -7,8 +7,10 @@ import createFlash, { FLASH_TYPES } from '~/flash';
import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue'; import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
import { INSTANCE_TYPE } from '~/runner/constants'; import { INSTANCE_TYPE } from '~/runner/constants';
import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
jest.mock('~/flash'); jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
...@@ -111,25 +113,32 @@ describe('RunnerRegistrationTokenReset', () => { ...@@ -111,25 +113,32 @@ describe('RunnerRegistrationTokenReset', () => {
describe('On error', () => { describe('On error', () => {
it('On network error, error message is shown', async () => { it('On network error, error message is shown', async () => {
runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce( const mockErrorMsg = 'Token reset failed!';
new Error('Something went wrong'),
); runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
window.confirm.mockReturnValueOnce(true); window.confirm.mockReturnValueOnce(true);
await findButton().vm.$emit('click'); await findButton().vm.$emit('click');
await waitForPromises(); await waitForPromises();
expect(createFlash).toHaveBeenLastCalledWith({ 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 () => { it('On validation error, error message is shown', async () => {
const mockErrorMsg = 'User not allowed!';
const mockErrorMsg2 = 'Type is not valid!';
runnersRegistrationTokenResetMutationHandler.mockResolvedValue({ runnersRegistrationTokenResetMutationHandler.mockResolvedValue({
data: { data: {
runnersRegistrationTokenReset: { runnersRegistrationTokenReset: {
token: null, token: null,
errors: ['Token reset failed'], errors: [mockErrorMsg, mockErrorMsg2],
}, },
}, },
}); });
...@@ -139,7 +148,11 @@ describe('RunnerRegistrationTokenReset', () => { ...@@ -139,7 +148,11 @@ describe('RunnerRegistrationTokenReset', () => {
await waitForPromises(); await waitForPromises();
expect(createFlash).toHaveBeenLastCalledWith({ expect(createFlash).toHaveBeenLastCalledWith({
message: 'Token reset failed', message: `${mockErrorMsg} ${mockErrorMsg2}`,
});
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerRegistrationTokenReset',
}); });
}); });
}); });
......
...@@ -15,9 +15,11 @@ import { ...@@ -15,9 +15,11 @@ import {
ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED,
} from '~/runner/constants'; } from '~/runner/constants';
import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { runnerData } from '../mock_data'; import { runnerData } from '../mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
const mockRunner = runnerData.data.runner; const mockRunner = runnerData.data.runner;
...@@ -232,22 +234,30 @@ describe('RunnerUpdateForm', () => { ...@@ -232,22 +234,30 @@ describe('RunnerUpdateForm', () => {
}); });
it('On network error, error message is shown', async () => { 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(); await submitFormAndWait();
expect(createFlash).toHaveBeenLastCalledWith({ 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(); 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({ runnerUpdateHandler.mockResolvedValue({
data: { data: {
runnerUpdate: { runnerUpdate: {
runner: mockRunner, runner: mockRunner,
errors: ['A value is invalid'], errors: [mockErrorMsg],
}, },
}, },
}); });
...@@ -255,8 +265,9 @@ describe('RunnerUpdateForm', () => { ...@@ -255,8 +265,9 @@ describe('RunnerUpdateForm', () => {
await submitFormAndWait(); await submitFormAndWait();
expect(createFlash).toHaveBeenLastCalledWith({ expect(createFlash).toHaveBeenLastCalledWith({
message: 'A value is invalid', message: mockErrorMsg,
}); });
expect(captureException).not.toHaveBeenCalled();
expect(findSubmitDisabledAttr()).toBeUndefined(); expect(findSubmitDisabledAttr()).toBeUndefined();
}); });
}); });
......
...@@ -2,14 +2,19 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; ...@@ -2,14 +2,19 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue';
import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql'; import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql';
import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue'; import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue';
import { captureException } from '~/runner/sentry_utils';
import { runnerData } from '../mock_data'; import { runnerData } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
const mockRunnerGraphqlId = runnerData.data.runner.id; const mockRunnerGraphqlId = runnerData.data.runner.id;
const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
...@@ -23,11 +28,9 @@ describe('RunnerDetailsApp', () => { ...@@ -23,11 +28,9 @@ describe('RunnerDetailsApp', () => {
const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge); const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge);
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getRunnerQuery, mockRunnerQuery]];
wrapper = mountFn(RunnerDetailsApp, { wrapper = mountFn(RunnerDetailsApp, {
localVue, localVue,
apolloProvider: createMockApollo(handlers), apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]),
propsData: { propsData: {
runnerId: mockRunnerId, runnerId: mockRunnerId,
...props, ...props,
...@@ -63,4 +66,22 @@ describe('RunnerDetailsApp', () => { ...@@ -63,4 +66,22 @@ describe('RunnerDetailsApp', () => {
expect(findRunnerTypeBadge().text()).toBe('shared'); 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 { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility'; import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
...@@ -23,13 +23,15 @@ import { ...@@ -23,13 +23,15 @@ import {
} from '~/runner/constants'; } from '~/runner/constants';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import RunnerListApp from '~/runner/runner_list/runner_list_app.vue'; import RunnerListApp from '~/runner/runner_list/runner_list_app.vue';
import { captureException } from '~/runner/sentry_utils';
import { runnersData, runnersDataPaginated } from '../mock_data'; import { runnersData, runnersDataPaginated } from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockActiveRunnersCount = 2; const mockActiveRunnersCount = 2;
jest.mock('@sentry/browser'); jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'), ...jest.requireActual('~/lib/utils/url_utility'),
updateHistory: jest.fn(), updateHistory: jest.fn(),
...@@ -80,11 +82,6 @@ describe('RunnerListApp', () => { ...@@ -80,11 +82,6 @@ describe('RunnerListApp', () => {
beforeEach(async () => { beforeEach(async () => {
setQuery(''); setQuery('');
Sentry.withScope.mockImplementation((fn) => {
const scope = { setTag: jest.fn() };
fn(scope);
});
mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
createComponentWithApollo(); createComponentWithApollo();
await waitForPromises(); await waitForPromises();
...@@ -191,15 +188,21 @@ describe('RunnerListApp', () => { ...@@ -191,15 +188,21 @@ describe('RunnerListApp', () => {
describe('when runners query fails', () => { describe('when runners query fails', () => {
beforeEach(async () => { beforeEach(async () => {
mockRunnersQuery = jest.fn().mockRejectedValue(new Error()); mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
createComponentWithApollo(); createComponentWithApollo();
await waitForPromises(); await waitForPromises();
}); });
it('error is reported to sentry', async () => { it('error is reported to sentry', async () => {
expect(Sentry.withScope).toHaveBeenCalled(); expect(captureException).toHaveBeenCalledWith({
expect(Sentry.captureException).toHaveBeenCalled(); error: new Error('Network error: Error!'),
component: 'RunnerListApp',
});
});
it('error is shown to the user', async () => {
expect(createFlash).toHaveBeenCalledTimes(1);
}); });
}); });
......
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);
});
});
});
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