Commit aab2067b authored by Scott Hampton's avatar Scott Hampton

Create store for a11y MR widget

Create mutations and actions for MR widget.
Create frontend comparison utility functions
to get the comparison report to be rendered.
Wrote tests to cover actions, mutations, and
utils.
parent 9f8901c3
...@@ -26,18 +26,11 @@ export default { ...@@ -26,18 +26,11 @@ export default {
* The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation. * The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation.
* Here we simply split the string on `.` and get the code in the 5th position * Here we simply split the string on `.` and get the code in the 5th position
*/ */
if (this.issue.code === undefined) { return this.issue.code?.split('.')[4];
return null;
}
return this.issue.code.split('.')[4] || null;
}, },
learnMoreUrl() { learnMoreUrl() {
if (this.parsedTECHSCode === null) { // eslint-disable-next-line @gitlab/require-i18n-strings
return 'https://www.w3.org/TR/WCAG20-TECHS/Overview.html'; return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode || 'Overview'}.html`;
}
return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode}.html`;
}, },
}, },
}; };
......
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
import { parseAccessibilityReport, compareAccessibilityReports } from './utils';
export const setBaseEndpoint = ({ commit }, endpoint) => commit(types.SET_BASE_ENDPOINT, endpoint);
export const setHeadEndpoint = ({ commit }, endpoint) => commit(types.SET_HEAD_ENDPOINT, endpoint);
export const fetchReport = ({ state, dispatch, commit }) => {
commit(types.REQUEST_REPORT);
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));
};
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 () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
mutations,
state: state(),
});
export const SET_BASE_ENDPOINT = 'SET_BASE_ENDPOINT';
export const SET_HEAD_ENDPOINT = 'SET_HEAD_ENDPOINT';
export const REQUEST_REPORT = 'REQUEST_REPORT';
export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS';
export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_BASE_ENDPOINT](state, endpoint) {
state.baseEndpoint = endpoint;
},
[types.SET_HEAD_ENDPOINT](state, endpoint) {
state.headEndpoint = endpoint;
},
[types.REQUEST_REPORT](state) {
state.isLoading = true;
},
[types.RECEIVE_REPORT_SUCCESS](state, report) {
state.hasError = false;
state.isLoading = false;
state.report = report;
},
[types.RECEIVE_REPORT_ERROR](state) {
state.isLoading = false;
state.hasError = true;
state.report = {};
},
};
export default () => ({
endpoint: null,
isLoading: false,
hasError: false,
/**
* Report will have the following format:
* {
* status: {String},
* summary: {
* total: {Number},
* notes: {Number},
* warnings: {Number},
* errors: {Number},
* },
* existing_errors: {Array.<Object>},
* existing_notes: {Array.<Object>},
* existing_warnings: {Array.<Object>},
* new_errors: {Array.<Object>},
* new_notes: {Array.<Object>},
* new_warnings: {Array.<Object>},
* resolved_errors: {Array.<Object>},
* resolved_notes: {Array.<Object>},
* resolved_warnings: {Array.<Object>},
* }
*/
report: {},
});
import { difference, intersection } from 'lodash';
import { STATUS_FAILED, STATUS_SUCCESS } 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);
existingIssues.forEach(issue => {
const parsedIssue = JSON.parse(issue);
switch (parsedIssue.type) {
case 'error':
result.existing_errors.push(parsedIssue);
result.summary.errors += 1;
break;
case 'warning':
result.existing_warnings.push(parsedIssue);
result.summary.warnings += 1;
break;
default:
result.existing_notes.push(parsedIssue);
result.summary.notes += 1;
break;
}
});
newIssues.forEach(issue => {
const parsedIssue = JSON.parse(issue);
switch (parsedIssue.type) {
case 'error':
result.new_errors.push(parsedIssue);
result.summary.errors += 1;
break;
case 'warning':
result.new_warnings.push(parsedIssue);
result.summary.warnings += 1;
break;
default:
result.new_notes.push(parsedIssue);
result.summary.notes += 1;
break;
}
});
resolvedIssues.forEach(issue => {
const parsedIssue = JSON.parse(issue);
switch (parsedIssue.type) {
case 'error':
result.resolved_errors.push(parsedIssue);
break;
case 'warning':
result.resolved_warnings.push(parsedIssue);
break;
default:
result.resolved_notes.push(parsedIssue);
break;
}
});
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 () => {};
export const baseReport = {
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 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: {},
},
],
'https://about.gitlab.com': [
{
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 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': [
{
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 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: [],
};
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import * as actions from '~/reports/accessibility_report/store/actions';
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';
describe('Accessibility Reports actions', () => {
let localState;
let localStore;
beforeEach(() => {
localStore = createStore();
localState = localStore.state;
});
describe('setBaseEndpoint', () => {
it('should commit SET_BASE_ENDPOINT mutation', done => {
testAction(
actions.setBaseEndpoint,
'endpoint.json',
localState,
[{ type: types.SET_BASE_ENDPOINT, payload: 'endpoint.json' }],
[],
done,
);
});
});
describe('setHeadEndpoint', () => {
it('should commit SET_HEAD_ENDPOINT mutation', done => {
testAction(
actions.setHeadEndpoint,
'endpoint.json',
localState,
[{ type: types.SET_HEAD_ENDPOINT, payload: 'endpoint.json' }],
[],
done,
);
});
});
describe('fetchReport', () => {
let mock;
beforeEach(() => {
localState.baseEndpoint = `${TEST_HOST}/endpoint.json`;
localState.headEndpoint = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('success', () => {
it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', done => {
const data = { report: { summary: {} } };
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, data);
testAction(
actions.fetchReport,
null,
localState,
[{ type: types.REQUEST_REPORT }],
[
{
payload: [{ ...data, isHead: false }, { ...data, isHead: true }],
type: 'receiveReportSuccess',
},
],
done,
);
});
});
describe('error', () => {
it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', done => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
testAction(
actions.fetchReport,
null,
localState,
[{ type: types.REQUEST_REPORT }, { type: types.RECEIVE_REPORT_ERROR }],
[],
done,
);
});
});
});
describe('receiveReportSuccess', () => {
it('should commit RECEIVE_REPORT_SUCCESS mutation', done => {
testAction(
actions.receiveReportSuccess,
[{ ...baseReport, isHead: false }, { ...headReport, isHead: true }],
localState,
[{ type: types.RECEIVE_REPORT_SUCCESS, payload: comparedReportResult }],
[],
done,
);
});
});
});
import mutations from '~/reports/accessibility_report/store/mutations';
import createStore from '~/reports/accessibility_report/store';
describe('Accessibility Reports mutations', () => {
let localState;
let localStore;
beforeEach(() => {
localStore = createStore();
localState = localStore.state;
});
describe('SET_BASE_ENDPOINT', () => {
it('sets the given endpoint', () => {
const endpoint = '/test-endpoint';
mutations.SET_BASE_ENDPOINT(localState, endpoint);
expect(localState.baseEndpoint).toEqual(endpoint);
});
});
describe('SET_HEAD_ENDPOINT', () => {
it('sets the given endpoint', () => {
const endpoint = '/test-endpoint';
mutations.SET_HEAD_ENDPOINT(localState, endpoint);
expect(localState.headEndpoint).toEqual(endpoint);
});
});
describe('REQUEST_REPORT', () => {
it('sets isLoading to true', () => {
mutations.REQUEST_REPORT(localState);
expect(localState.isLoading).toEqual(true);
});
});
describe('RECEIVE_REPORT_SUCCESS', () => {
it('sets isLoading to false', () => {
mutations.RECEIVE_REPORT_SUCCESS(localState, {});
expect(localState.isLoading).toEqual(false);
});
it('sets hasError to false', () => {
mutations.RECEIVE_REPORT_SUCCESS(localState, {});
expect(localState.hasError).toEqual(false);
});
it('sets report to response report', () => {
const report = { data: 'testing' };
mutations.RECEIVE_REPORT_SUCCESS(localState, report);
expect(localState.report).toEqual(report);
});
});
describe('RECEIVE_REPORT_ERROR', () => {
it('sets isLoading to false', () => {
mutations.RECEIVE_REPORT_ERROR(localState);
expect(localState.isLoading).toEqual(false);
});
it('sets hasError to true', () => {
mutations.RECEIVE_REPORT_ERROR(localState);
expect(localState.hasError).toEqual(true);
});
});
});
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