diff --git a/config/axe/axe.config.js b/config/axe/axe.config.js new file mode 100644 index 00000000..3e167f63 --- /dev/null +++ b/config/axe/axe.config.js @@ -0,0 +1,96 @@ +/*jshint node: true*/ +'use strict'; + +// Defaults derived from: https://github.com/dequelabs/axe-core +const defaults = { + rules: { + 'area-alt': { 'enabled': true }, + 'audio-caption': { 'enabled': true }, + 'button-name': { 'enabled': true }, + 'document-title': { 'enabled': true }, + 'empty-heading': { 'enabled': true }, + 'frame-title': { 'enabled': true }, + 'frame-title-unique': { 'enabled': true }, + 'image-alt': { 'enabled': true }, + 'image-redundant-alt': { 'enabled': true }, + 'input-image-alt': { 'enabled': true }, + 'link-name': { 'enabled': true }, + 'object-alt': { 'enabled': true }, + 'server-side-image-map': { 'enabled': true }, + 'video-caption': { 'enabled': true }, + 'video-description': { 'enabled': true }, + + 'definition-list': { 'enabled': true }, + 'dlitem': { 'enabled': true }, + 'heading-order': { 'enabled': true }, + 'href-no-hash': { 'enabled': true }, + 'layout-table': { 'enabled': true }, + 'list': { 'enabled': true }, + 'listitem': { 'enabled': true }, + 'p-as-heading': { 'enabled': true }, + + 'scope-attr-valid': { 'enabled': true }, + 'table-duplicate-name': { 'enabled': true }, + 'table-fake-caption': { 'enabled': true }, + 'td-has-header': { 'enabled': true }, + 'td-headers-attr': { 'enabled': true }, + 'th-has-data-cells': { 'enabled': true }, + + 'duplicate-id': { 'enabled': true }, + 'html-has-lang': { 'enabled': true }, + 'html-lang-valid': { 'enabled': true }, + 'meta-refresh': { 'enabled': true }, + 'valid-lang': { 'enabled': true }, + + 'checkboxgroup': { 'enabled': true }, + 'label': { 'enabled': true }, + 'radiogroup': { 'enabled': true }, + + 'accesskeys': { 'enabled': true }, + 'bypass': { 'enabled': true }, + 'tabindex': { 'enabled': true }, + + 'aria-allowed-attr': { 'enabled': true }, + 'aria-required-attr': { 'enabled': true }, + 'aria-required-children': { 'enabled': true }, + 'aria-required-parent': { 'enabled': true }, + 'aria-roles': { 'enabled': true }, + 'aria-valid-attr': { 'enabled': true }, + 'aria-valid-attr-value': { 'enabled': true }, + + 'blink': { 'enabled': true }, + 'color-contrast': { 'enabled': true }, + 'link-in-text-block': { 'enabled': true }, + 'marquee': { 'enabled': true }, + 'meta-viewport': { 'enabled': true }, + 'meta-viewport-large': { 'enabled': true } + } +}; + +module.exports = { + getConfig: () => { + const skyPagesConfigUtil = require('../sky-pages/sky-pages.config'); + const skyPagesConfig = skyPagesConfigUtil.getSkyPagesConfig(); + + let config = {}; + + // Merge rules from skyux config. + if (skyPagesConfig.skyux.a11y && skyPagesConfig.skyux.a11y.rules) { + config.rules = Object.assign({}, defaults.rules, skyPagesConfig.skyux.a11y.rules); + } + + // The consuming SPA wishes to disable all rules. + if (skyPagesConfig.skyux.a11y === false) { + config.rules = Object.assign({}, defaults.rules); + Object.keys(config.rules).forEach((key) => { + config.rules[key].enabled = false; + }); + } + + if (!config.rules) { + return defaults; + } + + return config; + } +}; diff --git a/lib/a11y-analyzer.js b/lib/a11y-analyzer.js new file mode 100644 index 00000000..0a20fda1 --- /dev/null +++ b/lib/a11y-analyzer.js @@ -0,0 +1,58 @@ +/*jshint node: true */ +'use strict'; + +const axeBuilder = require('axe-webdriverjs'); +const logger = require('../utils/logger'); +const axeConfig = require('../config/axe/axe.config'); +const { browser } = require('protractor'); + +function SkyA11y() {} + +SkyA11y.run = function () { + return browser + .getCurrentUrl() + .then(url => new Promise((resolve) => { + const config = axeConfig.getConfig(); + + logger.info(`Starting accessibility checks for ${url}...`); + + axeBuilder(browser.driver) + .options(config) + .analyze((results) => { + const numViolations = results.violations.length; + const subject = (numViolations === 1) ? 'violation' : 'violations'; + + logger.info(`Accessibility checks finished with ${numViolations} ${subject}.\n`); + + if (numViolations > 0) { + logViolations(results); + } + + resolve(numViolations); + }); + })); +}; + +function logViolations(results) { + results.violations.forEach((violation) => { + const wcagTags = violation.tags + .filter(tag => tag.match(/wcag\d{3}|^best*/gi)) + .join(', '); + + const html = violation.nodes + .reduce( + (accumulator, node) => `${accumulator}\n${node.html}\n`, + ' Elements:\n' + ); + + const error = [ + `aXe - [Rule: \'${violation.id}\'] ${violation.help} - WCAG: ${wcagTags}`, + ` Get help at: ${violation.helpUrl}\n`, + `${html}\n\n` + ].join('\n'); + + logger.error(error); + }); +} + +module.exports = SkyA11y; diff --git a/package.json b/package.json index 7bd6876a..ccd0cdf1 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@types/node": "7.0.18", "angular2-template-loader": "0.6.2", "awesome-typescript-loader": "3.1.3", + "axe-webdriverjs": "1.1.3", "codelyzer": "3.0.1", "core-js": "2.4.1", "enhanced-resolve": "3.3.0", @@ -82,6 +83,7 @@ "rxjs": "5.4.2", "sass-loader": "6.0.5", "selenium-standalone": "6.4.1", + "selenium-webdriver": "3.5.0", "source-map-inline-loader": "blackbaud-bobbyearl/source-map-inline-loader", "source-map-loader": "0.2.1", "style-loader": "0.17.0", @@ -89,10 +91,10 @@ "ts-node": "3.0.4", "tslint": "5.2.0", "typescript": "2.3.2", + "web-animations-js": "2.2.5", "webpack": "2.5.1", "webpack-dev-server": "2.4.5", "webpack-merge": "4.1.0", - "web-animations-js": "2.2.5", "winston": "2.3.1", "zone.js": "0.8.10" }, diff --git a/runtime/config.ts b/runtime/config.ts index a493907e..01cabb1d 100644 --- a/runtime/config.ts +++ b/runtime/config.ts @@ -25,6 +25,10 @@ export interface RuntimeConfig { useTemplateUrl: boolean; } +export interface SkyuxConfigA11y { + rules?: any; +} + export interface SkyuxConfigApp { externals?: Object; port?: string; @@ -36,6 +40,7 @@ export interface SkyuxConfigHost { } export interface SkyuxConfig { + a11y?: SkyuxConfigA11y|boolean; app?: SkyuxConfigApp; appSettings?: any; auth?: boolean; diff --git a/runtime/testing/e2e/a11y.d.ts b/runtime/testing/e2e/a11y.d.ts new file mode 100644 index 00000000..b1bec7ab --- /dev/null +++ b/runtime/testing/e2e/a11y.d.ts @@ -0,0 +1,3 @@ +export declare class SkyA11y { + static run(): Promise; +} diff --git a/runtime/testing/e2e/a11y.js b/runtime/testing/e2e/a11y.js new file mode 100644 index 00000000..bfbcce5c --- /dev/null +++ b/runtime/testing/e2e/a11y.js @@ -0,0 +1,2 @@ +const analyzer = require('../../../lib/a11y-analyzer'); +module.exports = { SkyA11y: analyzer }; diff --git a/runtime/testing/e2e/index.ts b/runtime/testing/e2e/index.ts index 8d2d6c88..97d36f6d 100644 --- a/runtime/testing/e2e/index.ts +++ b/runtime/testing/e2e/index.ts @@ -1 +1,2 @@ export * from './host-browser'; +export * from './a11y'; diff --git a/test/config-axe.spec.js b/test/config-axe.spec.js new file mode 100644 index 00000000..1539d186 --- /dev/null +++ b/test/config-axe.spec.js @@ -0,0 +1,73 @@ +/*jshint jasmine: true, node: true */ +'use strict'; + +const mock = require('mock-require'); + +describe('config axe', () => { + afterEach(() => { + mock.stopAll(); + }); + + it('should return a config object', () => { + mock('../config/sky-pages/sky-pages.config', { + getSkyPagesConfig: () => { + return { + skyux: {} + }; + } + }); + const lib = mock.reRequire('../config/axe/axe.config'); + const config = lib.getConfig(); + expect(config).toBeDefined(); + expect(config.rules.label.enabled).toEqual(true); + }); + + it('should merge config from a consuming SPA', () => { + mock('../config/sky-pages/sky-pages.config', { + getSkyPagesConfig: () => { + return { + skyux: { + a11y: { + rules: { + label: { enabled: false } + } + } + } + }; + } + }); + const lib = mock.reRequire('../config/axe/axe.config'); + const config = lib.getConfig(); + expect(config.rules.label.enabled).toEqual(false); + }); + + it('should return defaults if rules are not defined', () => { + mock('../config/sky-pages/sky-pages.config', { + getSkyPagesConfig: () => { + return { + skyux: { + a11y: {} + } + }; + } + }); + const lib = mock.reRequire('../config/axe/axe.config'); + const config = lib.getConfig(); + expect(config.rules.label.enabled).toEqual(true); + }); + + it('should disabled all rules if accessibility is set to false', () => { + mock('../config/sky-pages/sky-pages.config', { + getSkyPagesConfig: () => { + return { + skyux: { + a11y: false + } + }; + } + }); + const lib = mock.reRequire('../config/axe/axe.config'); + const config = lib.getConfig(); + expect(config.rules.label.enabled).toEqual(false); + }); +}); diff --git a/test/config-protractor.spec.js b/test/config-protractor.spec.js index 2412d584..8e7107ed 100644 --- a/test/config-protractor.spec.js +++ b/test/config-protractor.spec.js @@ -9,10 +9,14 @@ describe('config protractor test', () => { let config; beforeEach(() => { - lib = require('../config/protractor/protractor.conf.js'); + lib = mock.reRequire('../config/protractor/protractor.conf.js'); config = lib.config; }); + afterEach(() => { + mock.stopAll(); + }); + it('should return a config object', () => { expect(lib.config).toBeDefined(); }); @@ -28,9 +32,6 @@ describe('config protractor test', () => { expect(config.beforeLaunch).toBeDefined(); config.beforeLaunch(); expect(called).toBe(true); - - mock.stop('ts-node'); - }); it('should provide a method for onPrepare', () => { diff --git a/test/lib-a11y-analyzer.spec.js b/test/lib-a11y-analyzer.spec.js new file mode 100644 index 00000000..6a8c7808 --- /dev/null +++ b/test/lib-a11y-analyzer.spec.js @@ -0,0 +1,138 @@ +/*jshint jasmine: true, node: true */ +'use strict'; + +const mock = require('mock-require'); +const logger = require('../utils/logger'); + +describe('SkyA11y', () => { + let browser; + let context; + + beforeEach(() => { + mock('protractor', { + browser: { + getCurrentUrl: () => Promise.resolve('') + } + }); + + mock('../config/axe/axe.config', { + getConfig: () => { + return {}; + } + }); + + context = { + config: { + axe: {} + } + }; + + browser = { + getCurrentUrl: () => { + return Promise.resolve('http://foo.bar'); + } + }; + }); + + afterEach(() => { + mock.stopAll(); + }); + + it('should return a promise', () => { + mock('axe-webdriverjs', function MockAxeBuilder() { + return { + options: () => { + return { + analyze: (callback) => Promise.resolve() + }; + } + }; + }); + + spyOn(logger, 'info').and.returnValue(); + spyOn(logger, 'error').and.returnValue(); + + const plugin = mock.reRequire('../lib/a11y-analyzer'); + const result = plugin.run(); + expect(typeof result.then).toEqual('function'); + }); + + it('should return a class', () => { + mock('axe-webdriverjs', function MockAxeBuilder() { + return { + options: () => { + return { + analyze: (callback) => Promise.resolve() + }; + } + }; + }); + + spyOn(logger, 'info').and.returnValue(); + spyOn(logger, 'error').and.returnValue(); + + const plugin = mock.reRequire('../lib/a11y-analyzer'); + const result = new plugin(); + expect(typeof result).toBeDefined(); + }); + + it('should print a message to the console', (done) => { + mock('axe-webdriverjs', function MockAxeBuilder() { + return { + options: () => { + return { + analyze: (callback) => { + callback({ + violations: [] + }); + expect(logger.info.calls.argsFor(1)[0]) + .toContain(`Accessibility checks finished with 0 violations.`); + done(); + return Promise.resolve(); + } + }; + } + }; + }); + + spyOn(logger, 'info').and.returnValue(); + spyOn(logger, 'error').and.returnValue(); + + const plugin = mock.reRequire('../lib/a11y-analyzer'); + const result = plugin.run(); + }); + + it('should log violations', (done) => { + mock('axe-webdriverjs', function MockAxeBuilder() { + return { + options: () => { + return { + analyze: (callback) => { + callback({ + violations: [{ + id: 'label', + help: 'Description here.', + helpUrl: 'https://foo.bar', + nodes: [{ html: '

' }], + tags: ['cat.forms', 'wcag2a', 'wcag332', 'wcag131'] + }] + }); + expect(logger.info.calls.argsFor(1)[0]) + .toContain(`Accessibility checks finished with 1 violation.`); + expect(logger.error.calls.argsFor(0)[0]) + .toContain('aXe - [Rule: \'label\'] Description here. - WCAG: wcag332, wcag131'); + done(); + return Promise.resolve(); + } + }; + } + }; + }); + + spyOn(logger, 'info').and.returnValue(); + spyOn(logger, 'error').and.returnValue(); + + const plugin = mock.reRequire('../lib/a11y-analyzer'); + const result = plugin.run(); + }); +});