Skip to content
This repository has been archived by the owner on May 22, 2024. It is now read-only.

[terra-functional-testing] Add Accessibility Reporter #456

Merged
merged 10 commits into from
Nov 9, 2020
2 changes: 2 additions & 0 deletions packages/terra-functional-testing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@
"@wdio/local-runner": "^6.1.4",
"@wdio/logger": "^6.0.16",
"@wdio/mocha-framework": "^6.1.0",
"@wdio/reporter": "^6.6.6",
"@wdio/spec-reporter": "^6.0.16",
"@wdio/sync": "^6.1.14",
"axe-core": "4.0.2",
"chalk": "^4.1.0",
"commander": "^5.1.0",
"expect": "^26.4.2",
"express": "^4.17.1",
Expand Down
12 changes: 10 additions & 2 deletions packages/terra-functional-testing/src/commands/axe/inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
* Injects axe-core into the browser.
* @param {Object} options - The axe configuration options.
*/
const injectAxe = (options) => {
const injectAxe = (options = {}) => {
// eslint-disable-next-line global-require
const { source } = require('axe-core/axe.min.js');

browser.execute(`${source}\n ${options ? `axe.configure(${JSON.stringify(options)})` : ''}`);
const { rules } = options;

const config = {
...options,
// axe.configure has a different API than axe.run. Rules must be an array for axe.configure.
...rules && { rules: Object.keys(rules).map((rule) => ({ ...rules[rule], id: rule })) },
};

browser.execute(`${source}\n axe.configure(${JSON.stringify(config)})`);
};

module.exports = injectAxe;
36 changes: 20 additions & 16 deletions packages/terra-functional-testing/src/commands/axe/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,32 @@ const runAxe = (overrides = {}) => {
typeof service === 'function' && service.name === 'TerraService'
));

const { axe: axeOptions } = options;
const { axe: axeOptions = {} } = options;
const { rules: globalRules = {} } = axeOptions;

const globalRuleOverrides = {
/**
* This rule was introduced in axe-core v3.3 and causes failures in many Terra components.
* The solution to address this failure vary by component. It is being disabled until a solution is identified in the future.
*
* Reference: https://github.com/cerner/terra-framework/issues/991
*/
'scrollable-region-focusable': { enabled: false },
/**
* Rules configured through the Terra Service axe options are applied globally.
*/
...globalRules,
};

const isAxeUnavailable = browser.execute(() => window.axe === undefined);

// Inject axe-core onto the page if it has not already been initialized.
if (isAxeUnavailable) {
injectAxe(axeOptions);
injectAxe({ ...axeOptions, rules: globalRuleOverrides });
}

/**
* This rule was introduced in axe-core v3.3 and causes failures in many Terra components.
* The solution to address this failure vary by component. It is being disabled until a solution is identified in the future.
*
* Reference: https://github.com/cerner/terra-framework/issues/991
*/
const ruleOverrides = {
'scrollable-region-focusable': { enabled: false },
};

// Merge the global rules and overrides together.
const rules = {
...ruleOverrides,
...axeOptions && axeOptions.rules,
...globalRuleOverrides,
...overrides.rules,
};

Expand All @@ -43,7 +47,7 @@ const runAxe = (overrides = {}) => {
axe.run(document, opts, function (error, result) {
done({ error, result });
});
}, { rules, restoreScroll: true, runOnly: ['wcag2a', 'wcag2aa', 'wcag21aa', 'section508'] });
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

restoreScroll was removed: dequelabs/axe-core#2388

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know the scrolling that it used to do was only for color-contrast.

}, { rules, runOnly: ['wcag2a', 'wcag2aa', 'wcag21aa', 'section508'] });
};

module.exports = runAxe;
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ const { runAxe } = require('../axe');
*/
function toBeAccessible(_, options = {}) {
const { result } = runAxe(options);
const { violations } = result;
const { incomplete, violations } = result;

// Rules that fail but are marked for review are returned in the incomplete array.
// https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#results-object
if (incomplete && incomplete.length > 0) {
process.emit('terra:report:accessibility', { incomplete });
Copy link
Contributor

@emilyrohrbough emilyrohrbough Oct 28, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also add violations to the report's output? I could see two ways:

  • one report, two sections
  • two axe reports for incomplete and violations

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Violations are printed to the console when the expect fails; adding to the reporter also would print them again. We'd want to change how violations are printed if we wanted to pursue this, keeping in mind the expect would fail the test and print a message and then the reporter would print a message

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to add violations to the report. The failed test run should be indication enough. I see the report as valuable on non-failed test runs.

}

return {
pass: violations.length === 0,
Expand Down
3 changes: 2 additions & 1 deletion packages/terra-functional-testing/src/config/wdio.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const ip = require('ip');
const SeleniumDockerService = require('../services/wdio-selenium-docker-service');
const TerraService = require('../services/wdio-terra-service');
const AssetServerService = require('../services/wdio-asset-server-service');
const AccessibilityReporter = require('../reporters/wdio-accessibility-reporter');

const {
LOCALE,
Expand Down Expand Up @@ -157,7 +158,7 @@ exports.config = {
// Test reporter for stdout.
// The only one supported by default is 'dot'
// see also: https://webdriver.io/docs/dot-reporter.html
reporters: ['spec'],
reporters: ['spec', AccessibilityReporter],
//
// Options to be passed to Mocha.
// See the full list at http://mochajs.org/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const chalk = require('chalk');
const WDIOReporter = require('@wdio/reporter').default;

class AccessibilityReporter extends WDIOReporter {
/**
* Indents a string.
* @param {string} string - The string to indent.
* @param {number} indent - The number of spaces to indent the string.
*/
static indent(string, indent) {
if (indent > 0) {
return string.replace(/^(?!\s*$)/gm, Array(indent + 1).join(' '));
}

return string;
}

constructor(options) {
super(options);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we defined the outputDir and logFile option to pass to super ? It seems there is only one option for output files? maybe, which option 2 from this comment: https://github.com/cerner/terra-toolkit/pull/456/files#r513483163

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the discussion in our WDIO 6 sync I'd say this PR is not going to write out to a file, but it is something we can discuss and circle back on down the road if we think it would be beneficial


this.currentTest = null;
this.accessibilityResults = {};

process.on('terra:report:accessibility', this.onReportAccessibility.bind(this));
}

/**
* Hook invoked at the start of the test.
* @param {TestStats} test - The current test object.
*/
onTestStart(test) {
this.currentTest = test.uid;
}

/**
* Hook invoked when accessibility results are reported.
* @param {Object} results - Accessibility results.
* @param {array} results.incomplete - Incomplete accessibility results.
*/
onReportAccessibility(results) {
this.accessibilityResults[this.currentTest] = results;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to know the suite info here instead of recursively building it out at the end?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably, but I personally think traveling the tree is actually easier than tracking additional test data manually. A suite can be nested infinitely inside of other suites

}

/**
* Hook invoked when the runner ends.
* @param RunnerStats runner - The test runner stats object.
*/
onRunnerEnd(runner) {
this.printReport(runner);
}

/**
* Print the accessibility report.
* @param {RunnerStats} runner - The test runner.
*/
printReport(runner) {
const warnings = Object.keys(this.accessibilityResults);
Copy link
Contributor

@emilyrohrbough emilyrohrbough Oct 28, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way to write to the console that an aggregated report can be found in the results dir

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can write to the console, but we don't currently write any files to the results dir


if (warnings.length === 0) {
return;
}

const rootSuite = this.currentSuites.find((suite) => suite.uid === '(root)');
const output = this.travelSuite(rootSuite);
const spec = runner.specs[0];

this.write(`Spec: ${spec}\n${output}`);
Copy link
Contributor

@emilyrohrbough emilyrohrbough Oct 28, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we output this as a md/json report as well that can be pulled in as an build artifact and as a release site artifact?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll probably want something like this, but I think that work will likely happen inside of the WDIO 6 reporter that generates the reports (which may or may not be the same reporter)

}

/**
* Travels the suite tree to generate the accessibility report.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by travel?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Travels though each node in the tree

* @param {SuiteStats} currentSuite - The current suite.
* @param {number} depth - The current nesting depth of the suite tree.
* @returns {string} - An accessibility report.
*/
travelSuite(currentSuite, depth = 0) {
const { suites, tests, title } = currentSuite;

const warnings = [];

suites.forEach((suite) => {
const output = this.travelSuite(suite, depth + 1);

if (output.length > 0) {
warnings.push(output);
}
});

tests.forEach((test) => {
const { uid } = test;

if (this.accessibilityResults[uid]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to ensure that this result specifically has the incomplete type or is it guaranteed that the type is incomplete by the time it gets here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's guaranteed, with the current implementation the results are only stored if they contain an incomplete array

const testTitle = this.formatTestWarning(uid, (depth * 2) + 2);

warnings.push(testTitle);
}
});

if (warnings.length > 0) {
const suiteTitle = title === '(root)' ? '' : title;

return `${AccessibilityReporter.indent(suiteTitle, (depth * 2))}\n${warnings.join('\n')}\n`;
}

return '';
}

/**
* Formats an accessibility warning summary for a test.
* @param {string} uid - The unique identifier of the test.
* @param {number} indent - The indentation the report should have,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you intend to have more comment at the end or should it be a period?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a period, updated:

51b3981

*/
formatTestWarning(uid, indent) {
const { title } = this.tests[uid];
const { incomplete } = this.accessibilityResults[uid];

const string = `${chalk.yellow('warning')} ${title}\n\n ${chalk.yellow(`${JSON.stringify(incomplete, null, 2)}`)}`;

return AccessibilityReporter.indent(string, indent);
}
}

module.exports = AccessibilityReporter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"title": "(root)",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be the spec file that was ran?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this was generated directly by the test runner and copy pasted into this file. The root suite is always assigned '(root)':

https://github.com/webdriverio/webdriverio/blob/master/packages/wdio-reporter/src/index.ts#L58

"suites": [
{
"type": "suite:start",
"start": "2020-10-27T01:14:33.430Z",
"_duration": 214,
"uid": "suite-0-0",
"cid": "0-0",
"title": "Example Describe 1",
"fullTitle": "Example Describe 1",
"tests": [],
"hooks": [],
"suites": [
{
"type": "suite:start",
"start": "2020-10-27T01:14:33.430Z",
"_duration": 213,
"uid": "suite-1-0",
"cid": "0-0",
"title": "Example Describe 2",
"fullTitle": "Example Describe 1 Example Describe 2",
"tests": [
{
"type": "test",
"start": "2020-10-27T01:14:33.431Z",
"_duration": 101,
"uid": "test-10-0",
"cid": "0-0",
"title": "example it 1",
"fullTitle": "Example Describe 1 Example Describe 2 example it 1",
"output": [],
"retries": 0,
"state": "passed",
"end": "2020-10-27T01:14:33.532Z"
},
{
"type": "test",
"start": "2020-10-27T01:14:33.532Z",
"_duration": 53,
"uid": "test-10-1",
"cid": "0-0",
"title": "example it 2",
"fullTitle": "Example Describe 1 Example Describe 2 example it 2",
"output": [],
"retries": 0,
"state": "passed",
"end": "2020-10-27T01:14:33.585Z"
},
{
"type": "test",
"start": "2020-10-27T01:14:33.586Z",
"_duration": 57,
"uid": "test-10-2",
"cid": "0-0",
"title": "example it 3",
"fullTitle": "Example Describe 1 Example Describe 2 example it 3",
"output": [],
"retries": 0,
"state": "passed",
"end": "2020-10-27T01:14:33.643Z"
}
],
"hooks": [],
"suites": [],
"hooksAndTests": [],
"end": "2020-10-27T01:14:33.643Z"
}
],
"hooksAndTests": [],
"end": "2020-10-27T01:14:33.644Z"
}
],
"tests": []
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ describe('Inject Axe', () => {

it('should inject axe into the document with the provided options', () => {
const mockExecute = jest.fn();
const mockRules = { rules: [{ id: 'mock-rule', enabled: true }] };
const mockRules = { rules: { 'mock-rule': { enabled: true } } };

global.browser = {
execute: mockExecute,
};

injectAxe(mockRules);

expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('axe.configure({"rules":[{"id":"mock-rule","enabled":true}]})'));
expect(mockExecute).toHaveBeenCalledWith(expect.stringContaining('axe.configure({"rules":[{"enabled":true,"id":"mock-rule"}]})'));
});
});
Loading