Commit b2501d91 authored by Martin Wortschack's avatar Martin Wortschack

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

Create store for a11y MR widget

See merge request gitlab-org/gitlab!29199
parents 4a54cd18 9370080d
...@@ -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';
import { s__ } from '~/locale';
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;
}
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 }, 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 initialState =>
new Vuex.Store({
actions,
mutations,
state: state(initialState),
});
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.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, message) {
state.isLoading = false;
state.hasError = true;
state.errorMessage = message;
state.report = {};
},
};
export default (initialState = {}) => ({
baseEndpoint: initialState.baseEndpoint || '',
headEndpoint: initialState.headEndpoint || '',
isLoading: initialState.isLoading || false,
hasError: initialState.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: initialState.report || {},
});
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 () => {};
...@@ -22,3 +22,6 @@ export const status = { ...@@ -22,3 +22,6 @@ export const status = {
ERROR: 'ERROR', ERROR: 'ERROR',
SUCCESS: 'SUCCESS', SUCCESS: 'SUCCESS',
}; };
export const ACCESSIBILITY_ISSUE_ERROR = 'error';
export const ACCESSIBILITY_ISSUE_WARNING = 'warning';
...@@ -1049,6 +1049,12 @@ msgstr "" ...@@ -1049,6 +1049,12 @@ msgstr ""
msgid "AccessTokens|reset it" msgid "AccessTokens|reset it"
msgstr "" msgstr ""
msgid "AccessibilityReport|Accessibility report artifact not found"
msgstr ""
msgid "AccessibilityReport|Failed to retrieve accessibility report"
msgstr ""
msgid "AccessibilityReport|Learn More" msgid "AccessibilityReport|Learn More"
msgstr "" msgstr ""
......
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('fetchReport', () => {
let mock;
beforeEach(() => {
localState.baseEndpoint = `${TEST_HOST}/endpoint.json`;
localState.headEndpoint = `${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,
);
});
});
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,
payload: 'Failed to retrieve accessibility report',
},
],
[],
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('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);
});
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