Commit 51cf09e6 authored by Scott Hampton's avatar Scott Hampton

Switch to backend a11y comp

The frontend previously handled the
comparison of the accessiblity reports. We
are switching this to happen on the backend.

To handle this, we need to change our endpoint
to be a single endpoint, poll the endpoint while
the comparison happens, and update the
schema to match the backend response.
parent a1b737af
......@@ -13,11 +13,7 @@ export default {
IssuesList,
},
props: {
baseEndpoint: {
type: String,
required: true,
},
headEndpoint: {
endpoint: {
type: String,
required: true,
},
......@@ -34,15 +30,12 @@ export default {
]),
},
created() {
this.setEndpoints({
baseEndpoint: this.baseEndpoint,
headEndpoint: this.headEndpoint,
});
this.setEndpoint(this.endpoint);
this.fetchReport();
},
methods: {
...mapActions(['fetchReport', 'setEndpoints']),
...mapActions(['fetchReport', 'setEndpoint']),
},
};
</script>
......
import Visibility from 'visibilityjs';
import Poll from '~/lib/utils/poll';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
import { parseAccessibilityReport, compareAccessibilityReports } from './utils';
import { s__ } from '~/locale';
export const setEndpoints = ({ commit }, { baseEndpoint, headEndpoint }) =>
commit(types.SET_ENDPOINTS, { baseEndpoint, headEndpoint });
let eTagPoll;
export const clearEtagPoll = () => {
eTagPoll = null;
};
export const stopPolling = () => {
if (eTagPoll) eTagPoll.stop();
};
export const restartPolling = () => {
if (eTagPoll) eTagPoll.restart();
};
export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
/**
* We need to poll the report endpoint while they are being parsed in the Backend.
* This can take up to one minute.
*
* Poll.js will handle etag response.
* While http status code is 204, it means it's parsing, and we'll keep polling
* When http status code is 200, it means parsing is done, we can show the results & stop polling
* When http status code is 500, it means parsing went wrong and we stop polling
*/
export const fetchReport = ({ state, dispatch, commit }) => {
commit(types.REQUEST_REPORT);
// If we don't have both endpoints, throw an error.
if (!state.baseEndpoint || !state.headEndpoint) {
commit(
types.RECEIVE_REPORT_ERROR,
s__('AccessibilityReport|Accessibility report artifact not found'),
);
return;
eTagPoll = new Poll({
resource: {
getReport(endpoint) {
return axios.get(endpoint);
},
},
data: state.endpoint,
method: 'getReport',
successCallback: ({ status, data }) => dispatch('receiveReportSuccess', { status, data }),
errorCallback: () => dispatch('receiveReportError'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
} else {
axios
.get(state.endpoint)
.then(({ status, data }) => dispatch('receiveReportSuccess', { status, data }))
.catch(() => dispatch('receiveReportError'));
}
Promise.all([
axios.get(state.baseEndpoint).then(response => ({
...response.data,
isHead: false,
})),
axios.get(state.headEndpoint).then(response => ({
...response.data,
isHead: true,
})),
])
.then(responses => dispatch('receiveReportSuccess', responses))
.catch(() =>
Visibility.change(() => {
if (!Visibility.hidden()) {
dispatch('restartPolling');
} else {
dispatch('stopPolling');
}
});
};
export const receiveReportSuccess = ({ commit }, response) => {
if (response.status === httpStatusCodes.OK) {
const report = response.data;
commit(types.RECEIVE_REPORT_SUCCESS, report);
}
};
export const receiveReportError = ({ commit }) => {
commit(
types.RECEIVE_REPORT_ERROR,
s__('AccessibilityReport|Failed to retrieve accessibility report'),
),
);
};
export const receiveReportSuccess = ({ commit }, responses) => {
const parsedReports = responses.map(response => ({
isHead: response.isHead,
issues: parseAccessibilityReport(response),
}));
const report = compareAccessibilityReports(parsedReports);
commit(types.RECEIVE_REPORT_SUCCESS, report);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -10,8 +10,7 @@ export const groupedSummaryText = state => {
return s__('Reports|Accessibility scanning failed loading results');
}
const numberOfResults =
(state.report?.summary?.errors || 0) + (state.report?.summary?.warnings || 0);
const numberOfResults = state.report?.summary?.errored || 0;
if (numberOfResults === 0) {
return s__('Reports|Accessibility scanning detected no issues for the source branch only');
}
......
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_ENDPOINT = 'SET_ENDPOINT';
export const REQUEST_REPORT = 'REQUEST_REPORT';
export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS';
......
import * as types from './mutation_types';
export default {
[types.SET_ENDPOINTS](state, { baseEndpoint, headEndpoint }) {
state.baseEndpoint = baseEndpoint;
state.headEndpoint = headEndpoint;
[types.SET_ENDPOINT](state, endpoint) {
state.endpoint = endpoint;
},
[types.REQUEST_REPORT](state) {
state.isLoading = true;
......
export default (initialState = {}) => ({
baseEndpoint: initialState.baseEndpoint || '',
headEndpoint: initialState.headEndpoint || '',
endpoint: initialState.endpoint || '',
isLoading: initialState.isLoading || false,
hasError: initialState.hasError || false,
......@@ -11,9 +10,8 @@ export default (initialState = {}) => ({
* status: {String},
* summary: {
* total: {Number},
* notes: {Number},
* warnings: {Number},
* errors: {Number},
* resolved: {Number},
* errored: {Number},
* },
* existing_errors: {Array.<Object>},
* existing_notes: {Array.<Object>},
......
import { difference, intersection } from 'lodash';
import {
STATUS_FAILED,
STATUS_SUCCESS,
ACCESSIBILITY_ISSUE_ERROR,
ACCESSIBILITY_ISSUE_WARNING,
} from '../../constants';
export const parseAccessibilityReport = data => {
// Combine all issues into one array
return Object.keys(data.results)
.map(key => [...data.results[key]])
.flat()
.map(issue => JSON.stringify(issue)); // stringify to help with comparisons
};
export const compareAccessibilityReports = reports => {
const result = {
status: '',
summary: {
total: 0,
notes: 0,
errors: 0,
warnings: 0,
},
new_errors: [],
new_notes: [],
new_warnings: [],
resolved_errors: [],
resolved_notes: [],
resolved_warnings: [],
existing_errors: [],
existing_notes: [],
existing_warnings: [],
};
const headReport = reports.filter(report => report.isHead)[0];
const baseReport = reports.filter(report => !report.isHead)[0];
// existing issues are those that exist in both the head report and the base report
const existingIssues = intersection(headReport.issues, baseReport.issues);
// new issues are those that exist in only the head report
const newIssues = difference(headReport.issues, baseReport.issues);
// resolved issues are those that exist in only the base report
const resolvedIssues = difference(baseReport.issues, headReport.issues);
const parseIssues = (issue, issueType, shouldCount) => {
const parsedIssue = JSON.parse(issue);
switch (parsedIssue.type) {
case ACCESSIBILITY_ISSUE_ERROR:
result[`${issueType}_errors`].push(parsedIssue);
if (shouldCount) {
result.summary.errors += 1;
}
break;
case ACCESSIBILITY_ISSUE_WARNING:
result[`${issueType}_warnings`].push(parsedIssue);
if (shouldCount) {
result.summary.warnings += 1;
}
break;
default:
result[`${issueType}_notes`].push(parsedIssue);
if (shouldCount) {
result.summary.notes += 1;
}
break;
}
};
existingIssues.forEach(issue => parseIssues(issue, 'existing', true));
newIssues.forEach(issue => parseIssues(issue, 'new', true));
resolvedIssues.forEach(issue => parseIssues(issue, 'resolved', false));
result.summary.total = result.summary.errors + result.summary.warnings + result.summary.notes;
const hasErrorsOrWarnings = result.summary.errors > 0 || result.summary.warnings > 0;
result.status = hasErrorsOrWarnings ? STATUS_FAILED : STATUS_SUCCESS;
return result;
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -143,11 +143,7 @@ export default {
});
},
shouldShowAccessibilityReport() {
return (
this.accessibilility?.base_path &&
this.accessibilility?.head_path &&
this.glFeatures.accessibilityMergeRequestWidget
);
return this.mr.accessibilityReportPath && this.glFeatures.accessibilityMergeRequestWidget;
},
},
watch: {
......@@ -393,8 +389,7 @@ export default {
<grouped-accessibility-reports-app
v-if="shouldShowAccessibilityReport"
:base-endpoint="mr.accessibility.base_path"
:head-endpoint="mr.accessibility.head_path"
:endpoint="mr.accessibilityReportPath"
/>
<div class="mr-widget-section">
......
......@@ -103,7 +103,7 @@ export default class MergeRequestStore {
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
this.terraformReportsPath = data.terraform_reports_path;
this.testResultsPath = data.test_reports_path;
this.accessibility = data.accessibility || {};
this.accessibilityReportPath = data.accessibility_report_path;
this.exposedArtifactsPath = data.exposed_artifacts_path;
this.cancelAutoMergePath = data.cancel_auto_merge_path;
this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);
......
......@@ -352,8 +352,7 @@ export default {
<grouped-accessibility-reports-app
v-if="shouldShowAccessibilityReport"
:base-endpoint="mr.accessibility.base_endpoint"
:head-endpoint="mr.accessibility.head_endpoint"
:endpoint="mr.accessibilityReportPath"
/>
<div class="mr-widget-section">
......
......@@ -1063,9 +1063,6 @@ msgstr ""
msgid "AccessTokens|reset it"
msgstr ""
msgid "AccessibilityReport|Accessibility report artifact not found"
msgstr ""
msgid "AccessibilityReport|Failed to retrieve accessibility report"
msgstr ""
......
......@@ -3,7 +3,7 @@ import Vuex from 'vuex';
import GroupedAccessibilityReportsApp from '~/reports/accessibility_report/grouped_accessibility_reports_app.vue';
import AccessibilityIssueBody from '~/reports/accessibility_report/components/accessibility_issue_body.vue';
import store from '~/reports/accessibility_report/store';
import { comparedReportResult } from './mock_data';
import { mockReport } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -18,8 +18,7 @@ describe('Grouped accessibility reports app', () => {
store: mockStore,
localVue,
propsData: {
baseEndpoint: 'base_endpoint.json',
headEndpoint: 'head_endpoint.json',
endpoint: 'endpoint.json',
},
methods: {
fetchReport: () => {},
......@@ -66,8 +65,7 @@ describe('Grouped accessibility reports app', () => {
beforeEach(() => {
mockStore.state.report = {
summary: {
errors: 0,
warnings: 0,
errored: 0,
},
};
});
......@@ -83,8 +81,7 @@ describe('Grouped accessibility reports app', () => {
beforeEach(() => {
mockStore.state.report = {
summary: {
errors: 0,
warnings: 1,
errored: 1,
},
};
});
......@@ -100,8 +97,7 @@ describe('Grouped accessibility reports app', () => {
beforeEach(() => {
mockStore.state.report = {
summary: {
errors: 1,
warnings: 1,
errored: 2,
},
};
});
......@@ -115,17 +111,15 @@ describe('Grouped accessibility reports app', () => {
describe('with issues to show', () => {
beforeEach(() => {
mockStore.state.report = comparedReportResult;
mockStore.state.report = mockReport;
});
it('renders custom accessibility issue body', () => {
const issueBody = wrapper.find(AccessibilityIssueBody);
expect(issueBody.props('issue').name).toEqual(comparedReportResult.new_errors[0].name);
expect(issueBody.props('issue').code).toEqual(comparedReportResult.new_errors[0].code);
expect(issueBody.props('issue').message).toEqual(
comparedReportResult.new_errors[0].message,
);
expect(issueBody.props('issue').name).toEqual(mockReport.new_errors[0].name);
expect(issueBody.props('issue').code).toEqual(mockReport.new_errors[0].code);
expect(issueBody.props('issue').message).toEqual(mockReport.new_errors[0].message);
expect(issueBody.props('isNew')).toEqual(true);
});
});
......
export const baseReport = {
results: {
'http://about.gitlab.com/users/sign_in': [
export const mockReport = {
status: 'failed',
summary: {
total: 2,
resolved: 0,
errored: 2,
},
new_errors: [
{
code: 'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail',
type: 'error',
typeCode: 1,
message:
'This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 2.82:1. Recommendation: change background to #d1470c.',
context:
'<a class="btn btn-nav-cta btn-nav-link-cta" href="/free-trial">\nGet free trial\n</a>',
selector: '#main-nav > div:nth-child(2) > ul > div:nth-child(8) > a',
'This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 3.84:1. Recommendation: change text colour to #767676.',
context: '<a href="/stages-devops-lifecycle/" class="main-nav-link">Product</a>',
selector: '#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a',
runner: 'htmlcs',
runnerExtras: {},
},
],
'https://about.gitlab.com': [
new_notes: [],
new_warnings: [],
resolved_errors: [
{
code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent',
type: 'error',
......@@ -27,30 +33,9 @@ export const baseReport = {
runnerExtras: {},
},
],
},
};
export const parsedBaseReport = [
'{"code":"WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail","type":"error","typeCode":1,"message":"This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 2.82:1. Recommendation: change background to #d1470c.","context":"<a class=\\"btn btn-nav-cta btn-nav-link-cta\\" href=\\"/free-trial\\">\\nGet free trial\\n</a>","selector":"#main-nav > div:nth-child(2) > ul > div:nth-child(8) > a","runner":"htmlcs","runnerExtras":{}}',
'{"code":"WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent","type":"error","typeCode":1,"message":"Anchor element found with a valid href attribute, but no link content has been supplied.","context":"<a href=\\"/\\" class=\\"navbar-brand animated\\"><svg height=\\"36\\" viewBox=\\"0 0 1...</a>","selector":"#main-nav > div:nth-child(1) > a","runner":"htmlcs","runnerExtras":{}}',
];
export const headReport = {
results: {
'http://about.gitlab.com/users/sign_in': [
{
code: 'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail',
type: 'error',
typeCode: 1,
message:
'This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 3.84:1. Recommendation: change text colour to #767676.',
context: '<a href="/stages-devops-lifecycle/" class="main-nav-link">Product</a>',
selector: '#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a',
runner: 'htmlcs',
runnerExtras: {},
},
],
'https://about.gitlab.com': [
resolved_notes: [],
resolved_warnings: [],
existing_errors: [
{
code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent',
type: 'error',
......@@ -63,24 +48,8 @@ export const headReport = {
runnerExtras: {},
},
],
},
};
export const comparedReportResult = {
status: 'failed',
summary: {
total: 2,
notes: 0,
errors: 2,
warnings: 0,
},
new_errors: [headReport.results['http://about.gitlab.com/users/sign_in'][0]],
new_notes: [],
new_warnings: [],
resolved_errors: [baseReport.results['http://about.gitlab.com/users/sign_in'][0]],
resolved_notes: [],
resolved_warnings: [],
existing_errors: [headReport.results['https://about.gitlab.com'][0]],
existing_notes: [],
existing_warnings: [],
};
export default () => {};
......@@ -5,7 +5,7 @@ import * as types from '~/reports/accessibility_report/store/mutation_types';
import createStore from '~/reports/accessibility_report/store';
import { TEST_HOST } from 'spec/test_constants';
import testAction from 'helpers/vuex_action_helper';
import { baseReport, headReport, comparedReportResult } from '../mock_data';
import { mockReport } from '../mock_data';
describe('Accessibility Reports actions', () => {
let localState;
......@@ -18,14 +18,13 @@ describe('Accessibility Reports actions', () => {
describe('setEndpoints', () => {
it('should commit SET_ENDPOINTS mutation', done => {
const baseEndpoint = 'base_endpoint.json';
const headEndpoint = 'head_endpoint.json';
const endpoint = 'endpoint.json';
testAction(
actions.setEndpoints,
{ baseEndpoint, headEndpoint },
actions.setEndpoint,
endpoint,
localState,
[{ type: types.SET_ENDPOINTS, payload: { baseEndpoint, headEndpoint } }],
[{ type: types.SET_ENDPOINT, payload: endpoint }],
[],
done,
);
......@@ -36,8 +35,7 @@ describe('Accessibility Reports actions', () => {
let mock;
beforeEach(() => {
localState.baseEndpoint = `${TEST_HOST}/endpoint.json`;
localState.headEndpoint = `${TEST_HOST}/endpoint.json`;
localState.endpoint = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
......@@ -45,30 +43,6 @@ describe('Accessibility Reports actions', () => {
mock.restore();
});
describe('when no endpoints are given', () => {
beforeEach(() => {
localState.baseEndpoint = null;
localState.headEndpoint = null;
});
it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', done => {
testAction(
actions.fetchReport,
null,
localState,
[
{ type: types.REQUEST_REPORT },
{
type: types.RECEIVE_REPORT_ERROR,
payload: 'Accessibility report artifact not found',
},
],
[],
done,
);
});
});
describe('success', () => {
it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', done => {
const data = { report: { summary: {} } };
......@@ -81,7 +55,7 @@ describe('Accessibility Reports actions', () => {
[{ type: types.REQUEST_REPORT }],
[
{
payload: [{ ...data, isHead: false }, { ...data, isHead: true }],
payload: { status: 200, data },
type: 'receiveReportSuccess',
},
],
......@@ -98,14 +72,8 @@ describe('Accessibility Reports actions', () => {
actions.fetchReport,
null,
localState,
[
{ type: types.REQUEST_REPORT },
{
type: types.RECEIVE_REPORT_ERROR,
payload: 'Failed to retrieve accessibility report',
},
],
[],
[{ type: types.REQUEST_REPORT }],
[{ type: 'receiveReportError' }],
done,
);
});
......@@ -116,9 +84,22 @@ describe('Accessibility Reports actions', () => {
it('should commit RECEIVE_REPORT_SUCCESS mutation', done => {
testAction(
actions.receiveReportSuccess,
[{ ...baseReport, isHead: false }, { ...headReport, isHead: true }],
{ status: 200, data: mockReport },
localState,
[{ type: types.RECEIVE_REPORT_SUCCESS, payload: mockReport }],
[],
done,
);
});
});
describe('receiveReportError', () => {
it('should commit RECEIVE_REPORT_ERROR mutation', done => {
testAction(
actions.receiveReportError,
null,
localState,
[{ type: types.RECEIVE_REPORT_SUCCESS, payload: comparedReportResult }],
[{ type: types.RECEIVE_REPORT_ERROR, payload: 'Failed to retrieve accessibility report' }],
[],
done,
);
......
......@@ -67,8 +67,7 @@ describe('Accessibility reports store getters', () => {
it('returns summary message containing number of errors', () => {
localState.report = {
summary: {
errors: 1,
warnings: 1,
errored: 2,
},
};
const result = 'Accessibility scanning detected 2 issues for the source branch only';
......@@ -81,8 +80,7 @@ describe('Accessibility reports store getters', () => {
it('returns summary message containing no errors', () => {
localState.report = {
summary: {
errors: 0,
warnings: 0,
errored: 0,
},
};
const result = 'Accessibility scanning detected no issues for the source branch only';
......@@ -108,7 +106,7 @@ describe('Accessibility reports store getters', () => {
it('returns false', () => {
localState.report = {
status: 'success',
summary: { errors: 0, warnings: 0 },
summary: { errored: 0 },
};
expect(getters.shouldRenderIssuesList(localState)).toEqual(false);
......
......@@ -10,17 +10,12 @@ describe('Accessibility Reports mutations', () => {
localState = localStore.state;
});
describe('SET_ENDPOINTS', () => {
it('sets base and head endpoints to give values', () => {
const baseEndpoint = 'base_endpoint.json';
const headEndpoint = 'head_endpoint.json';
mutations.SET_ENDPOINTS(localState, {
baseEndpoint,
headEndpoint,
});
describe('SET_ENDPOINT', () => {
it('sets endpoint to given value', () => {
const endpoint = 'endpoint.json';
mutations.SET_ENDPOINT(localState, endpoint);
expect(localState.baseEndpoint).toEqual(baseEndpoint);
expect(localState.headEndpoint).toEqual(headEndpoint);
expect(localState.endpoint).toEqual(endpoint);
});
});
......
import * as utils from '~/reports/accessibility_report/store/utils';
import { baseReport, headReport, parsedBaseReport, comparedReportResult } from '../mock_data';
describe('Accessibility Report store utils', () => {
describe('parseAccessibilityReport', () => {
it('returns array of stringified issues', () => {
const result = utils.parseAccessibilityReport(baseReport);
expect(result).toEqual(parsedBaseReport);
});
});
describe('compareAccessibilityReports', () => {
let reports;
beforeEach(() => {
reports = [
{
isHead: false,
issues: utils.parseAccessibilityReport(baseReport),
},
{
isHead: true,
issues: utils.parseAccessibilityReport(headReport),
},
];
});
it('returns the comparison report with a new, resolved, and existing error', () => {
const result = utils.compareAccessibilityReports(reports);
expect(result).toEqual(comparedReportResult);
});
});
});
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