Commit c66c7573 authored by Scott Hampton's avatar Scott Hampton

Add test report widget using new framework

Implement test report widget using merge request report
widget extension framework, only levels 1 and 2 for now
Import and register extension
Add tests for widget functionality
parent 7336ccfa
......@@ -86,7 +86,7 @@ export default {
);
},
statusIconName() {
if (this.hasFetchError) return EXTENSION_ICONS.error;
if (this.hasFetchError) return EXTENSION_ICONS.failed;
if (this.isLoadingSummary) return null;
return this.statusIcon(this.collapsedData);
......
import { __, n__, s__, sprintf } from '~/locale';
const digitText = (bold = false) => (bold ? '%{strong_start}%d%{strong_end}' : '%d');
const noText = (bold = false) => (bold ? '%{strong_start}no%{strong_end}' : 'no');
export const TESTS_FAILED_STATUS = 'failed';
export const ERROR_STATUS = 'error';
export const i18n = {
label: s__('Reports|Test summary'),
loading: s__('Reports|Test summary results are loading'),
error: s__('Reports|Test summary failed to load results'),
fullReport: s__('Reports|Full report'),
noChanges: (bold) => s__(`Reports|${noText(bold)} changed test results`),
resultsString: (combinedString, resolvedString) =>
sprintf(s__('Reports|%{combinedString} and %{resolvedString}'), {
combinedString,
resolvedString,
}),
summaryText: (name, resultsString) =>
sprintf(__('%{name}: %{resultsString}'), { name, resultsString }),
failedClause: (failed, bold) =>
n__(`${digitText(bold)} failed`, `${digitText(bold)} failed`, failed),
erroredClause: (errored, bold) =>
n__(`${digitText(bold)} error`, `${digitText(bold)} errors`, errored),
resolvedClause: (resolved, bold) =>
n__(`${digitText(bold)} fixed test result`, `${digitText(bold)} fixed test results`, resolved),
totalClause: (total, bold) =>
n__(`${digitText(bold)} total test`, `${digitText(bold)} total tests`, total),
reportError: s__('Reports|An error occurred while loading report'),
reportErrorWithName: (name) =>
sprintf(s__('Reports|An error occurred while loading %{name} results'), { name }),
headReportParsingError: s__('Reports|Head report parsing error:'),
baseReportParsingError: s__('Reports|Base report parsing error:'),
};
import { uniqueId } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { EXTENSION_ICONS } from '../../constants';
import { summaryTextBuilder, reportTextBuilder, reportSubTextBuilder } from './utils';
import { i18n, TESTS_FAILED_STATUS, ERROR_STATUS } from './constants';
export default {
name: 'WidgetTestSummary',
enablePolling: true,
i18n,
expandEvent: 'i_testing_summary_widget_total',
props: ['testResultsPath', 'headBlobPath', 'pipeline'],
computed: {
summary(data) {
if (data.parsingInProgress) {
return this.$options.i18n.loading;
}
if (data.hasSuiteError) {
return this.$options.i18n.error;
}
return summaryTextBuilder(this.$options.i18n.label, data.summary);
},
statusIcon(data) {
if (data.parsingInProgress) {
return null;
}
if (data.status === TESTS_FAILED_STATUS) {
return EXTENSION_ICONS.warning;
}
if (data.hasSuiteError) {
return EXTENSION_ICONS.failed;
}
return EXTENSION_ICONS.success;
},
tertiaryButtons() {
return [
{
text: this.$options.i18n.fullReport,
href: `${this.pipeline.path}/test_report`,
target: '_blank',
},
];
},
},
methods: {
fetchCollapsedData() {
return axios.get(this.testResultsPath).then(({ data = {}, status }) => {
return {
data: {
hasSuiteError: data.suites?.some((suite) => suite.status === ERROR_STATUS),
parsingInProgress: status === 204,
...data,
},
};
});
},
fetchFullData() {
return Promise.resolve(this.prepareReports());
},
suiteIcon(suite) {
if (suite.status === ERROR_STATUS) {
return EXTENSION_ICONS.error;
}
if (suite.status === TESTS_FAILED_STATUS) {
return EXTENSION_ICONS.failed;
}
return EXTENSION_ICONS.success;
},
prepareReports() {
return this.collapsedData.suites.map((suite) => {
return {
id: uniqueId('suite-'),
text: reportTextBuilder(suite),
subtext: reportSubTextBuilder(suite),
icon: {
name: this.suiteIcon(suite),
},
};
});
},
},
};
import { i18n } from './constants';
const textBuilder = (results, boldNumbers = false) => {
const { failed, errored, resolved, total } = results;
const failedOrErrored = (failed || 0) + (errored || 0);
const failedString = failed ? i18n.failedClause(failed, boldNumbers) : null;
const erroredString = errored ? i18n.erroredClause(errored, boldNumbers) : null;
const combinedString =
failed && errored ? `${failedString}, ${erroredString}` : failedString || erroredString;
const resolvedString = resolved ? i18n.resolvedClause(resolved, boldNumbers) : null;
const totalString = total ? i18n.totalClause(total, boldNumbers) : null;
let resultsString = i18n.noChanges(boldNumbers);
if (failedOrErrored) {
if (resolved) {
resultsString = i18n.resultsString(combinedString, resolvedString);
} else {
resultsString = combinedString;
}
} else if (resolved) {
resultsString = resolvedString;
}
return `${resultsString}, ${totalString}`;
};
export const summaryTextBuilder = (name = '', results = {}) => {
const resultsString = textBuilder(results, true);
return i18n.summaryText(name, resultsString);
};
export const reportTextBuilder = ({ name = '', summary = {}, status }) => {
if (!name) {
return i18n.reportError;
}
if (status === 'error') {
return i18n.reportErrorWithName(name);
}
const resultsString = textBuilder(summary);
return i18n.summaryText(name, resultsString);
};
export const reportSubTextBuilder = ({ suite_errors }) => {
const errors = [];
if (suite_errors?.head) {
errors.push(`${i18n.headReportParsingError} ${suite_errors.head}`);
}
if (suite_errors?.base) {
errors.push(`${i18n.baseReportParsingError} ${suite_errors.base}`);
}
return errors.join('<br />');
};
......@@ -47,6 +47,7 @@ import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform';
import accessibilityExtension from './extensions/accessibility';
import codeQualityExtension from './extensions/code_quality';
import testReportExtension from './extensions/test_report';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
......@@ -191,6 +192,9 @@ export default {
shouldRenderTerraformPlans() {
return Boolean(this.mr?.terraformReportsPath);
},
shouldRenderTestReport() {
return Boolean(this.mr?.testResultsPath);
},
mergeError() {
let { mergeError } = this.mr;
......@@ -252,6 +256,11 @@ export default {
this.registerAccessibilityExtension();
}
},
shouldRenderTestReport(newVal) {
if (newVal) {
this.registerTestReportExtension();
}
},
},
mounted() {
MRWidgetService.fetchInitialData()
......@@ -502,6 +511,11 @@ export default {
registerExtension(codeQualityExtension);
}
},
registerTestReportExtension() {
if (this.shouldRenderTestReport && this.shouldShowExtension) {
registerExtension(testReportExtension);
}
},
},
};
</script>
......@@ -574,7 +588,7 @@ export default {
/>
<grouped-test-reports-app
v-if="mr.testResultsPath"
v-if="mr.testResultsPath && !shouldShowExtension"
class="js-reports-container"
:endpoint="mr.testResultsPath"
:head-blob-path="mr.headBlobPath"
......
......@@ -855,6 +855,9 @@ msgstr ""
msgid "%{name}, confirm your email address now!"
msgstr ""
msgid "%{name}: %{resultsString}"
msgstr ""
msgid "%{no_of_days} day"
msgid_plural "%{no_of_days} days"
msgstr[0] ""
......@@ -31233,6 +31236,9 @@ msgstr ""
msgid "Reports|Filename"
msgstr ""
msgid "Reports|Full report"
msgstr ""
msgid "Reports|Head report parsing error:"
msgstr ""
......@@ -31275,9 +31281,15 @@ msgstr ""
msgid "Reports|Test summary failed loading results"
msgstr ""
msgid "Reports|Test summary failed to load results"
msgstr ""
msgid "Reports|Test summary results are being parsed"
msgstr ""
msgid "Reports|Test summary results are loading"
msgstr ""
msgid "Reports|Tool"
msgstr ""
......
......@@ -17,6 +17,9 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
project.add_maintainer(user)
project_only_mwps.add_maintainer(user)
sign_in(user)
stub_feature_flags(refactor_mr_widgets_extensions: false)
stub_feature_flags(refactor_mr_widgets_extensions_user: false)
end
context 'new merge request', :sidekiq_might_not_need_inline do
......
import { GlButton } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import testReportExtension from '~/vue_merge_request_widget/extensions/test_report';
import { i18n } from '~/vue_merge_request_widget/extensions/test_report/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { trimText } from 'helpers/text_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import httpStatusCodes from '~/lib/utils/http_status';
import { failedReport } from '../../../reports/mock_data/mock_data';
import mixedResultsTestReports from '../../../reports/mock_data/new_and_fixed_failures_report.json';
import newErrorsTestReports from '../../../reports/mock_data/new_errors_report.json';
import newFailedTestReports from '../../../reports/mock_data/new_failures_report.json';
import successTestReports from '../../../reports/mock_data/no_failures_report.json';
import resolvedFailures from '../../../reports/mock_data/resolved_failures.json';
const reportWithParsingErrors = failedReport;
reportWithParsingErrors.suites[0].suite_errors = {
head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error',
base: 'JUnit data parsing failed: string not matched',
};
describe('Test report extension', () => {
let wrapper;
let mock;
registerExtension(testReportExtension);
const endpoint = '/root/repo/-/merge_requests/4/test_reports.json';
const mockApi = (statusCode, data = mixedResultsTestReports) => {
mock.onGet(endpoint).reply(statusCode, data);
};
const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button');
const findTertiaryButton = () => wrapper.find(GlButton);
const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item');
const createComponent = () => {
wrapper = mountExtended(extensionsContainer, {
propsData: {
mr: {
testResultsPath: endpoint,
headBlobPath: 'head/blob/path',
pipeline: { path: 'pipeline/path' },
},
},
});
};
const createExpandedWidgetWithData = async (data = mixedResultsTestReports) => {
mockApi(httpStatusCodes.OK, data);
createComponent();
await waitForPromises();
findToggleCollapsedButton().trigger('click');
await waitForPromises();
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('summary', () => {
it('displays loading text', () => {
mockApi(httpStatusCodes.OK);
createComponent();
expect(wrapper.text()).toContain(i18n.loading);
});
it('displays failed loading text', async () => {
mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR);
createComponent();
await waitForPromises();
expect(wrapper.text()).toContain(i18n.error);
});
it.each`
description | mockData | expectedResult
${'mixed test results'} | ${mixedResultsTestReports} | ${'Test summary: 2 failed and 2 fixed test results, 11 total tests'}
${'unchanged test results'} | ${successTestReports} | ${'Test summary: no changed test results, 11 total tests'}
${'tests with errors'} | ${newErrorsTestReports} | ${'Test summary: 2 errors, 11 total tests'}
${'failed test results'} | ${newFailedTestReports} | ${'Test summary: 2 failed, 11 total tests'}
${'resolved failures'} | ${resolvedFailures} | ${'Test summary: 4 fixed test results, 11 total tests'}
`('displays summary text for $description', async ({ mockData, expectedResult }) => {
mockApi(httpStatusCodes.OK, mockData);
createComponent();
await waitForPromises();
expect(wrapper.text()).toContain(expectedResult);
});
it('displays a link to the full report', async () => {
mockApi(httpStatusCodes.OK);
createComponent();
await waitForPromises();
expect(findTertiaryButton().text()).toBe('Full report');
expect(findTertiaryButton().attributes('href')).toBe('pipeline/path/test_report');
});
it('shows an error when a suite has a parsing error', async () => {
mockApi(httpStatusCodes.OK, reportWithParsingErrors);
createComponent();
await waitForPromises();
expect(wrapper.text()).toContain(i18n.error);
});
});
describe('expanded data', () => {
it('displays summary for each suite', async () => {
await createExpandedWidgetWithData();
expect(trimText(findAllExtensionListItems().at(0).text())).toBe(
'rspec:pg: 1 failed and 2 fixed test results, 8 total tests',
);
expect(trimText(findAllExtensionListItems().at(1).text())).toBe(
'java ant: 1 failed, 3 total tests',
);
});
it('displays suite parsing errors', async () => {
await createExpandedWidgetWithData(reportWithParsingErrors);
const suiteText = trimText(findAllExtensionListItems().at(0).text());
expect(suiteText).toContain(
'Head report parsing error: JUnit XML parsing failed: 2:24: FATAL: attributes construct error',
);
expect(suiteText).toContain(
'Base report parsing error: JUnit data parsing failed: string not matched',
);
});
});
});
......@@ -1025,7 +1025,7 @@ describe('MrWidgetOptions', () => {
it('captures sentry error and displays error when poll has failed', () => {
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureException).toHaveBeenCalledWith(new Error('Fetch error'));
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error');
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
});
});
});
......@@ -1036,7 +1036,7 @@ describe('MrWidgetOptions', () => {
const itHandlesTheException = () => {
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureException).toHaveBeenCalledWith(new Error('Fetch error'));
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error');
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
};
beforeEach(() => {
......
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