From 8d758a1deaab0afd609247289e17ef9d70377e73 Mon Sep 17 00:00:00 2001 From: jerasmus Date: Wed, 22 Oct 2025 11:49:39 +0200 Subject: [PATCH] Add error handling for inline blame GraphQL query - Add try-catch blocks in simple_viewer and source_viewer components - Display flash message when blame query fails - Add test coverage for error scenarios in both components The inline blame feature now gracefully handles GraphQL errors instead of silently failing, improving user experience. --- .../components/blob_viewers/simple_viewer.vue | 51 ++++++++++++------- .../source_viewer/source_viewer.vue | 43 ++++++++++------ locale/gitlab.pot | 3 ++ .../blob_viewers/simple_viewer_spec.js | 22 +++++++- .../source_viewer/source_viewer_spec.js | 25 ++++++++- 5 files changed, 107 insertions(+), 37 deletions(-) diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index 65f90a82f8cd1c..9813d80e0b06bc 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -2,6 +2,8 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { getParameterByName } from '~/lib/utils/url_utility'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import { createAlert } from '~/alert'; import Blame from '../source_viewer/components/blame_info.vue'; import { calculateBlameOffset, shouldRender, toggleBlameClasses } from '../source_viewer/utils'; import blameDataQuery from '../source_viewer/queries/blame_data.query.graphql'; @@ -18,6 +20,9 @@ export default { }, mixins: [ViewerMixin], inject: ['blobHash'], + i18n: { + blameErrorMessage: __('Unable to load blame information. Please try again.'), + }, props: { blobPath: { type: String, @@ -131,26 +136,34 @@ export default { async requestBlameInfo(fromLine, toLine) { if (!this.showBlame && !this.shouldPreloadBlame) return; - const { data } = await this.$apollo.query({ - query: blameDataQuery, - variables: { - ref: this.currentRef, - fullPath: this.projectPath, - filePath: this.blobPath, - fromLine, - toLine, - ignoreRevs: parseBoolean(getParameterByName('ignore_revs')), - }, - }); + try { + const { data } = await this.$apollo.query({ + query: blameDataQuery, + variables: { + ref: this.currentRef, + fullPath: this.projectPath, + filePath: this.blobPath, + fromLine, + toLine, + ignoreRevs: parseBoolean(getParameterByName('ignore_revs')), + }, + }); - const blob = data?.project?.repository?.blobs?.nodes[0]; - const blameGroups = blob?.blame?.groups; - const isDuplicate = this.blameData.includes(blameGroups[0]); - if (blameGroups && !isDuplicate) this.blameData.push(...blameGroups); - if (this.toLine < this.lineNumbers) { - this.fromLine += MAX_BLAME_LINES; - this.toLine += MAX_BLAME_LINES; - this.requestBlameInfo(this.fromLine, this.toLine); + const blob = data?.project?.repository?.blobs?.nodes[0]; + const blameGroups = blob?.blame?.groups; + const isDuplicate = this.blameData.includes(blameGroups[0]); + if (blameGroups && !isDuplicate) this.blameData.push(...blameGroups); + if (this.toLine < this.lineNumbers) { + this.fromLine += MAX_BLAME_LINES; + this.toLine += MAX_BLAME_LINES; + this.requestBlameInfo(this.fromLine, this.toLine); + } + } catch (error) { + createAlert({ + message: this.$options.i18n.blameErrorMessage, + captureError: true, + error, + }); } }, }, diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index 6e10a9b5f87348..84d9aef7bbbe00 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -3,8 +3,10 @@ import { debounce } from 'lodash'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; +import { __ } from '~/locale'; import { getParameterByName } from '~/lib/utils/url_utility'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { createAlert } from '~/alert'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; import LineHighlighter from '~/blob/line_highlighter'; import { EVENT_ACTION, EVENT_LABEL_VIEWER, CODEOWNERS_FILE_NAME } from './constants'; @@ -24,6 +26,9 @@ export default { SafeHtml, }, mixins: [Tracking.mixin()], + i18n: { + blameErrorMessage: __('Unable to load blame information. Please try again.'), + }, props: { blob: { type: Object, @@ -141,22 +146,30 @@ export default { const chunk = this.chunks[chunkIndex]; if ((!this.showBlame && !this.shouldPreloadBlame) || !chunk) return; - const { data } = await this.$apollo.query({ - query: blameDataQuery, - variables: { - ref: this.currentRef, - fullPath: this.projectPath, - filePath: this.blob.path, - fromLine: chunk.startingFrom + 1, - toLine: chunk.startingFrom + chunk.totalLines, - ignoreRevs: parseBoolean(getParameterByName('ignore_revs')), - }, - }); + try { + const { data } = await this.$apollo.query({ + query: blameDataQuery, + variables: { + ref: this.currentRef, + fullPath: this.projectPath, + filePath: this.blob.path, + fromLine: chunk.startingFrom + 1, + toLine: chunk.startingFrom + chunk.totalLines, + ignoreRevs: parseBoolean(getParameterByName('ignore_revs')), + }, + }); - const blob = data?.project?.repository?.blobs?.nodes[0]; - const blameGroups = blob?.blame?.groups; - const isDuplicate = this.blameData.includes(blameGroups[0]); - if (blameGroups && !isDuplicate) this.blameData.push(...blameGroups); + const blob = data?.project?.repository?.blobs?.nodes[0]; + const blameGroups = blob?.blame?.groups; + const isDuplicate = this.blameData.includes(blameGroups[0]); + if (blameGroups && !isDuplicate) this.blameData.push(...blameGroups); + } catch (error) { + createAlert({ + message: this.$options.i18n.blameErrorMessage, + captureError: true, + error, + }); + } }, async selectLine() { await this.$nextTick(); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d2c2fe7a027148..fe6ceed92351fe 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -69705,6 +69705,9 @@ msgstr "" msgid "Unable to generate new instance ID" msgstr "" +msgid "Unable to load blame information. Please try again." +msgstr "" + msgid "Unable to load commits. Try again later." msgstr "" diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js index f0b751b50afcd9..a7834b6702626a 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js @@ -9,11 +9,14 @@ import { import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue'; import waitForPromises from 'helpers/wait_for_promises'; import * as urlUtility from '~/lib/utils/url_utility'; +import { createAlert } from '~/alert'; import blameDataQuery from '~/vue_shared/components/source_viewer/queries/blame_data.query.graphql'; import Blame from '~/vue_shared/components/source_viewer/components/blame_info.vue'; import { BLAME_DATA_QUERY_RESPONSE_MOCK } from './mock_data'; +jest.mock('~/alert'); + Vue.use(VueApollo); describe('Blob Simple Viewer component', () => { @@ -23,6 +26,7 @@ describe('Blob Simple Viewer component', () => { const blobHash = 'foo-bar'; const blameDataQueryHandlerSuccess = jest.fn().mockResolvedValue(BLAME_DATA_QUERY_RESPONSE_MOCK); + const blameDataQueryHandlerError = jest.fn().mockRejectedValue(new Error('GraphQL error')); function createComponent({ content = contentMock, @@ -31,8 +35,9 @@ describe('Blob Simple Viewer component', () => { isBlameLinkHidden = false, isRawContent = false, propsData = {}, + blameQueryHandler = blameDataQueryHandlerSuccess, } = {}) { - fakeApollo = createMockApollo([[blameDataQuery, blameDataQueryHandlerSuccess]]); + fakeApollo = createMockApollo([[blameDataQuery, blameQueryHandler]]); wrapper = shallowMount(SimpleViewer, { apolloProvider: fakeApollo, @@ -147,6 +152,21 @@ describe('Blob Simple Viewer component', () => { await waitForPromises(); expect(blameDataQueryHandlerSuccess).toHaveBeenCalledTimes(4); }); + + it('shows error alert when blame query fails', async () => { + createAlert.mockClear(); + createComponent({ + propsData: { showBlame: true }, + blameQueryHandler: blameDataQueryHandlerError, + }); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Unable to load blame information. Please try again.', + captureError: true, + error: expect.any(Error), + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index 7ae3ba49ab93a6..fa4e499c8b0a20 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -15,6 +15,7 @@ import Tracking from '~/tracking'; import LineHighlighter from '~/blob/line_highlighter'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; import blameDataQuery from '~/vue_shared/components/source_viewer/queries/blame_data.query.graphql'; import Blame from '~/vue_shared/components/source_viewer/components/blame_info.vue'; import * as utils from '~/vue_shared/components/source_viewer/utils'; @@ -30,6 +31,8 @@ import { SOURCE_CODE_CONTENT_MOCK, } from './mock_data'; +jest.mock('~/alert'); + Vue.use(VueApollo); const lineHighlighter = new LineHighlighter(); @@ -49,11 +52,17 @@ describe('Source Viewer component', () => { const hash = '#L142'; const blameDataQueryHandlerSuccess = jest.fn().mockResolvedValue(BLAME_DATA_QUERY_RESPONSE_MOCK); + const blameDataQueryHandlerError = jest.fn().mockRejectedValue(new Error('GraphQL error')); const blameInfo = BLAME_DATA_QUERY_RESPONSE_MOCK.data.project.repository.blobs.nodes[0].blame.groups; - const createComponent = ({ showBlame = true, shouldPreloadBlame = false, blob = {} } = {}) => { - fakeApollo = createMockApollo([[blameDataQuery, blameDataQueryHandlerSuccess]]); + const createComponent = ({ + showBlame = true, + shouldPreloadBlame = false, + blob = {}, + blameQueryHandler = blameDataQueryHandlerSuccess, + } = {}) => { + fakeApollo = createMockApollo([[blameDataQuery, blameQueryHandler]]); wrapper = shallowMountExtended(SourceViewer, { apolloProvider: fakeApollo, @@ -224,6 +233,18 @@ describe('Source Viewer component', () => { expect(findBlameComponents()).toHaveLength(0); }); + + it('shows error alert when blame query fails', async () => { + createAlert.mockClear(); + createComponent({ blameQueryHandler: blameDataQueryHandlerError }); + await triggerChunkAppear(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Unable to load blame information. Please try again.', + captureError: true, + error: expect.any(Error), + }); + }); }); it('renders a Chunk component for each chunk', () => { -- GitLab