Commit ddc74809 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch 'a11y-mr-widget-update' into 'master'

Switch to backend a11y comparison

See merge request gitlab-org/gitlab!31487
parents f55c7c11 5412b8aa
......@@ -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'));
}
Visibility.change(() => {
if (!Visibility.hidden() && state.isLoading) {
dispatch('restartPolling');
} else {
dispatch('stopPolling');
}
});
};
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(() =>
commit(
types.RECEIVE_REPORT_ERROR,
s__('AccessibilityReport|Failed to retrieve accessibility report'),
),
);
export const receiveReportSuccess = ({ commit, dispatch }, { status, data }) => {
if (status === httpStatusCodes.OK) {
commit(types.RECEIVE_REPORT_SUCCESS, data);
// Stop polling since we have the information already parsed and it won't be changing
dispatch('stopPolling');
}
};
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);
export const receiveReportError = ({ commit, dispatch }) => {
commit(types.RECEIVE_REPORT_ERROR);
dispatch('stopPolling');
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
......
......@@ -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;
......@@ -13,10 +12,9 @@ export default {
state.isLoading = false;
state.report = report;
},
[types.RECEIVE_REPORT_ERROR](state, message) {
[types.RECEIVE_REPORT_ERROR](state) {
state.isLoading = false;
state.hasError = true;
state.errorMessage = message;
state.report = {};
},
};
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 () => {};
......@@ -146,11 +146,7 @@ export default {
});
},
shouldShowAccessibilityReport() {
return (
this.accessibilility?.base_path &&
this.accessibilility?.head_path &&
this.glFeatures.accessibilityMergeRequestWidget
);
return this.mr.accessibilityReportPath && this.glFeatures.accessibilityMergeRequestWidget;
},
},
watch: {
......@@ -396,8 +392,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">
......
......@@ -1069,12 +1069,6 @@ msgstr ""
msgid "AccessTokens|reset it"
msgstr ""
msgid "AccessibilityReport|Accessibility report artifact not found"
msgstr ""
msgid "AccessibilityReport|Failed to retrieve accessibility report"
msgstr ""
msgid "AccessibilityReport|Learn More"
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,18 +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('isNew')).toEqual(true);
expect(issueBody.props('issue').code).toBe(mockReport.new_errors[0].code);
expect(issueBody.props('issue').message).toBe(mockReport.new_errors[0].message);
expect(issueBody.props('isNew')).toBe(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,37 +35,14 @@ 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);
});
afterEach(() => {
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,
);
});
actions.stopPolling();
actions.clearEtagPoll();
});
describe('success', () => {
......@@ -81,7 +57,7 @@ describe('Accessibility Reports actions', () => {
[{ type: types.REQUEST_REPORT }],
[
{
payload: [{ ...data, isHead: false }, { ...data, isHead: true }],
payload: { status: 200, data },
type: 'receiveReportSuccess',
},
],
......@@ -98,14 +74,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,
);
});
......@@ -113,13 +83,37 @@ describe('Accessibility Reports actions', () => {
});
describe('receiveReportSuccess', () => {
it('should commit RECEIVE_REPORT_SUCCESS mutation', done => {
it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', done => {
testAction(
actions.receiveReportSuccess,
{ status: 200, data: mockReport },
localState,
[{ type: types.RECEIVE_REPORT_SUCCESS, payload: mockReport }],
[{ type: 'stopPolling' }],
done,
);
});
it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => {
testAction(
actions.receiveReportSuccess,
[{ ...baseReport, isHead: false }, { ...headReport, isHead: true }],
{ status: 204, data: mockReport },
localState,
[{ type: types.RECEIVE_REPORT_SUCCESS, payload: comparedReportResult }],
[],
[],
done,
);
});
});
describe('receiveReportError', () => {
it('should commit RECEIVE_REPORT_ERROR mutation', done => {
testAction(
actions.receiveReportError,
null,
localState,
[{ type: types.RECEIVE_REPORT_ERROR }],
[{ type: 'stopPolling' }],
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);
});
});
......@@ -65,11 +60,5 @@ describe('Accessibility Reports mutations', () => {
expect(localState.hasError).toEqual(true);
});
it('sets errorMessage to given message', () => {
mutations.RECEIVE_REPORT_ERROR(localState, 'message');
expect(localState.errorMessage).toEqual('message');
});
});
});
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