Commit d974dc05 authored by Miranda Fluharty's avatar Miranda Fluharty Committed by Phil Hughes

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 9f9b9070
......@@ -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 />');
};
......@@ -46,6 +46,7 @@ import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variab
import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform';
import accessibilityExtension from './extensions/accessibility';
import testReportExtension from './extensions/test_report';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
......@@ -190,6 +191,9 @@ export default {
shouldRenderTerraformPlans() {
return Boolean(this.mr?.terraformReportsPath);
},
shouldRenderTestReport() {
return Boolean(this.mr?.testResultsPath);
},
mergeError() {
let { mergeError } = this.mr;
......@@ -246,6 +250,11 @@ export default {
this.registerAccessibilityExtension();
}
},
shouldRenderTestReport(newVal) {
if (newVal) {
this.registerTestReportExtension();
}
},
},
mounted() {
MRWidgetService.fetchInitialData()
......@@ -491,6 +500,11 @@ export default {
registerExtension(accessibilityExtension);
}
},
registerTestReportExtension() {
if (this.shouldRenderTestReport && this.shouldShowExtension) {
registerExtension(testReportExtension);
}
},
},
};
</script>
......@@ -563,7 +577,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"
......
......@@ -852,6 +852,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] ""
......@@ -30937,6 +30940,9 @@ msgstr ""
msgid "Reports|Filename"
msgstr ""
msgid "Reports|Full report"
msgstr ""
msgid "Reports|Head report parsing error:"
msgstr ""
......@@ -30979,9 +30985,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 ""
......
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