diff --git a/README.md b/README.md index fc2b0472..e5199a78 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,18 @@ time it is rendered. This ensures that the component is still accessible even after state changes, and since the checks are scoped to a component's element, it means that any state change propagated downwards is also caught. +#### Inspecting Violations +When a violation is detected for a component's element, the element will have the `.axe-violation` class added to it. Visually, this will produce a striping pattern over the element (which will disappear on hover) designed to make it easily distinguishable from its expected appearance, even for users with low vision. + +Take this text input without a label, for example: + +![](docs/assets/violation-styling.png) + +At the same time, a violation error message will be logged to the console with even more detailed information as to what went wrong. The following message corresponds to the same text input element above: + +![](docs/assets/violation-console-output.png) + + #### Component Hooks Since development is not a uniform experience, Ember A11y Testing provides diff --git a/app/instance-initializers/axe-component.js b/app/instance-initializers/axe-component.js index 4d3dcbea..2e4d702e 100644 --- a/app/instance-initializers/axe-component.js +++ b/app/instance-initializers/axe-component.js @@ -39,6 +39,17 @@ export function initialize(application) { */ turnAuditOff: false, + /** + * An array of classNames to add to the component when a violation occurs. + * If unspecified, the `axe-violation` class is used to apply our default + * styling + * + * @public + * @type {Array} + */ + axeViolationClassNames: ['axe-violation'], + + /** * Runs an accessibility audit on any render of the component. * @private @@ -59,7 +70,8 @@ export function initialize(application) { audit() { if (this.get('tagName') !== '') { axe.a11yCheck(this.$(), this.axeOptions, (results) => { - let violations = results.violations; + const violations = results.violations; + const violationClassNames = this.get('axeViolationClassNames'); for (let i = 0, l = violations.length; i < l; i++) { let violation = violations[i]; @@ -72,7 +84,7 @@ export function initialize(application) { let node = nodes[j]; if (node) { - Ember.$(node.target.join(','))[0].classList.add('axe-violation'); + Ember.$(node.target.join(','))[0].classList.add(...violationClassNames); } } } diff --git a/content-for/head-footer.html b/content-for/head-footer.html index 8ef69e1e..80cfb853 100644 --- a/content-for/head-footer.html +++ b/content-for/head-footer.html @@ -1,5 +1,19 @@ diff --git a/docs/assets/violation-console-output.png b/docs/assets/violation-console-output.png new file mode 100644 index 00000000..07b1837c Binary files /dev/null and b/docs/assets/violation-console-output.png differ diff --git a/docs/assets/violation-styling.png b/docs/assets/violation-styling.png new file mode 100644 index 00000000..c67174f0 Binary files /dev/null and b/docs/assets/violation-styling.png differ diff --git a/tests/.jshintrc b/tests/.jshintrc index 6ec0b7c1..ad23df82 100644 --- a/tests/.jshintrc +++ b/tests/.jshintrc @@ -19,6 +19,7 @@ "wait", "DS", "andThen", + "axe", "currentURL", "currentPath", "currentRouteName" diff --git a/tests/acceptance/auto-run-test.js b/tests/acceptance/auto-run-test.js index 42db68cb..b10f22fe 100644 --- a/tests/acceptance/auto-run-test.js +++ b/tests/acceptance/auto-run-test.js @@ -6,6 +6,10 @@ import sinon from 'sinon'; let application; let sandbox; +const SELECTORS = { + passingInput: '[data-test-selector="passing-input"]' +}; + module('Acceptance | auto-run', { beforeEach: function() { application = startApp(); @@ -39,7 +43,7 @@ test('should run the function whenever a render occurs', function(assert) { assert.equal(currentPath(), 'index'); }); - click('label'); + click(`${SELECTORS.passingInput} label`); andThen(() => { assert.ok(callbackStub.calledTwice); diff --git a/tests/acceptance/violations-test.js b/tests/acceptance/violations-test.js new file mode 100644 index 00000000..40770814 --- /dev/null +++ b/tests/acceptance/violations-test.js @@ -0,0 +1,43 @@ +import Ember from 'ember'; +import { test } from 'qunit'; +import moduleForAcceptance from '../../tests/helpers/module-for-acceptance'; +import sinon from 'sinon'; + +const { A } = Ember; + +const IDs = { + emptyButton: '#empty-button', + sloppyInput: '#sloppy-input' +}; + + +let actual, expected, sandbox; + +moduleForAcceptance('Acceptance | violations', { + beforeEach() { + sandbox = sinon.sandbox.create(); + }, + + afterEach() { + sandbox.restore(); + } +}); + +test('marking DOM nodes with violations', function(assert) { + + sandbox.stub(axe.ember, 'a11yCheckCallback', function (results) { + actual = results.violations.length; + expected = 2; + + assert.equal(actual, expected); + + const buttonNameViolation = A(results.violations).findBy('id', 'button-name'); + actual = buttonNameViolation.nodes[0].target[0]; + expected = IDs.emptyButton; + + assert.equal(actual, expected); + }); + + visit('/violations'); + +}); diff --git a/tests/dummy/app/components/_base-demo-component.js b/tests/dummy/app/components/_base-demo-component.js new file mode 100644 index 00000000..72b0d076 --- /dev/null +++ b/tests/dummy/app/components/_base-demo-component.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +const { Component } = Ember; + +export default Component.extend({ + attributeBindings: ['data-test-selector'] +}); diff --git a/tests/dummy/app/components/empty-button.js b/tests/dummy/app/components/empty-button.js new file mode 100644 index 00000000..d2070d07 --- /dev/null +++ b/tests/dummy/app/components/empty-button.js @@ -0,0 +1,6 @@ +import BaseDemoComponent from './_base-demo-component'; + +export default BaseDemoComponent.extend({ + classNames: ['button'], + tagName: 'button' +}); diff --git a/tests/dummy/app/components/passing-component.js b/tests/dummy/app/components/passing-component.js index 10c85285..b6ae22a3 100644 --- a/tests/dummy/app/components/passing-component.js +++ b/tests/dummy/app/components/passing-component.js @@ -1,6 +1,6 @@ -import Ember from 'ember'; +import BaseDemoComponent from './_base-demo-component'; -export default Ember.Component.extend({ +export default BaseDemoComponent.extend({ actions: { toggle() { this.set('isFailing', true); diff --git a/tests/dummy/app/components/sloppy-input.js b/tests/dummy/app/components/sloppy-input.js new file mode 100644 index 00000000..eb9a3cb4 --- /dev/null +++ b/tests/dummy/app/components/sloppy-input.js @@ -0,0 +1,5 @@ +import BaseDemoComponent from './_base-demo-component'; + +export default BaseDemoComponent.extend({ + tagName: 'input', +}); diff --git a/tests/dummy/app/router.js b/tests/dummy/app/router.js index 3bba78eb..9f99b137 100644 --- a/tests/dummy/app/router.js +++ b/tests/dummy/app/router.js @@ -6,6 +6,7 @@ const Router = Ember.Router.extend({ }); Router.map(function() { + this.route('violations'); }); export default Router; diff --git a/tests/dummy/app/routes/index.js b/tests/dummy/app/routes/index.js new file mode 100644 index 00000000..26d9f312 --- /dev/null +++ b/tests/dummy/app/routes/index.js @@ -0,0 +1,4 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ +}); diff --git a/tests/dummy/app/routes/violations.js b/tests/dummy/app/routes/violations.js new file mode 100644 index 00000000..26d9f312 --- /dev/null +++ b/tests/dummy/app/routes/violations.js @@ -0,0 +1,4 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ +}); diff --git a/tests/dummy/app/styles/app.css b/tests/dummy/app/styles/app.css index e69de29b..77189728 100644 --- a/tests/dummy/app/styles/app.css +++ b/tests/dummy/app/styles/app.css @@ -0,0 +1,10 @@ +.button { + background-color: aqua; + border-radius: 0.25em; + border: none; + min-height: 1.5rem; + height: 1.5rem; + + width: 2.2rem; + min-width: 2.2rem; +} diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs index d6a7f4ef..f8bc38e7 100644 --- a/tests/dummy/app/templates/application.hbs +++ b/tests/dummy/app/templates/application.hbs @@ -1,3 +1,3 @@ -

Welcome to Ember.js

+

Welcome to Ember

-{{passing-component}} +{{outlet}} diff --git a/tests/dummy/app/templates/components/empty-button.hbs b/tests/dummy/app/templates/components/empty-button.hbs new file mode 100644 index 00000000..889d9eea --- /dev/null +++ b/tests/dummy/app/templates/components/empty-button.hbs @@ -0,0 +1 @@ +{{yield}} diff --git a/tests/dummy/app/templates/components/sloppy-input.hbs b/tests/dummy/app/templates/components/sloppy-input.hbs new file mode 100644 index 00000000..889d9eea --- /dev/null +++ b/tests/dummy/app/templates/components/sloppy-input.hbs @@ -0,0 +1 @@ +{{yield}} diff --git a/tests/dummy/app/templates/index.hbs b/tests/dummy/app/templates/index.hbs new file mode 100644 index 00000000..f7f52238 --- /dev/null +++ b/tests/dummy/app/templates/index.hbs @@ -0,0 +1,2 @@ +{{passing-component data-test-selector="passing-input"}} +{{outlet}} diff --git a/tests/dummy/app/templates/violations.hbs b/tests/dummy/app/templates/violations.hbs new file mode 100644 index 00000000..a46a7a12 --- /dev/null +++ b/tests/dummy/app/templates/violations.hbs @@ -0,0 +1,3 @@ +{{empty-button id="empty-button" data-test-selector="empty-button"}} + +{{sloppy-input id="sloppy-input" data-test-selector="sloppy-input"}} diff --git a/tests/unit/instance-initializers/axe-component-test.js b/tests/unit/instance-initializers/axe-component-test.js index bf163eef..67988584 100644 --- a/tests/unit/instance-initializers/axe-component-test.js +++ b/tests/unit/instance-initializers/axe-component-test.js @@ -1,15 +1,17 @@ /* global sinon, axe */ import Ember from 'ember'; import { initialize } from 'dummy/instance-initializers/axe-component'; -import { module, test, skip } from 'qunit'; +import { module, test } from 'qunit'; + +const { Application, Component, Logger, run } = Ember; let application; let sandbox; module('Unit | Instance Initializer | axe-component', { beforeEach() { - Ember.run(() => { - application = Ember.Application.create({ + run(() => { + application = Application.create({ rootElement: '#ember-testing' }); application.deferReadiness(); @@ -25,11 +27,11 @@ module('Unit | Instance Initializer | axe-component', { /* Basic Behavior */ -test('initializer should not re-open Ember.Component more than once', function(assert) { +test('initializer should not re-open Component more than once', function(assert) { // Depending on if the initializer has already ran, we will either expect the // reopen method to be called once or not at all. - let assertMethod = Ember.Component.prototype.audit ? 'notCalled' : 'calledOnce'; - let reopenSpy = sandbox.spy(Ember.Component, 'reopen'); + let assertMethod = Component.prototype.audit ? 'notCalled' : 'calledOnce'; + let reopenSpy = sandbox.spy(Component, 'reopen'); initialize(application); initialize(application); @@ -40,19 +42,19 @@ test('initializer should not re-open Ember.Component more than once', function(a test('audit is run on didRender when not in testing mode', function(assert) { initialize(application); - let component = Ember.Component.create({}); + let component = Component.create({}); let auditSpy = sandbox.spy(component, 'audit'); // In order for the audit to run, we have to act like we're not in testing Ember.testing = false; - Ember.run(() => component.appendTo('#ember-testing')); + run(() => component.appendTo('#ember-testing')); assert.ok(auditSpy.calledOnce); - Ember.run(() => component.trigger('didRender')); + run(() => component.trigger('didRender')); assert.ok(auditSpy.calledTwice); - Ember.run(() => component.destroy()); + run(() => component.destroy()); // Turn testing mode back on to ensure validity of other tests Ember.testing = true; @@ -61,36 +63,36 @@ test('audit is run on didRender when not in testing mode', function(assert) { test('audit is not run on didRender when in testing mode', function(assert) { initialize(application); - let component = Ember.Component.create({}); + let component = Component.create({}); let auditSpy = sandbox.spy(component, 'audit'); - Ember.run(() => component.appendTo('#ember-testing')); + run(() => component.appendTo('#ember-testing')); assert.ok(auditSpy.notCalled); - Ember.run(() => component.destroy()); + run(() => component.destroy()); }); -/* Ember.Component.turnAuditOff */ +/* Component.turnAuditOff */ test('turnAuditOff prevents audit from running on didRender', function(assert) { initialize(application); - let component = Ember.Component.create({ turnAuditOff: true }); + let component = Component.create({ turnAuditOff: true }); let auditSpy = sandbox.spy(component, 'audit'); // In order for the audit to run, we have to act like we're not in testing Ember.testing = false; - Ember.run(() => component.appendTo('#ember-testing')); + run(() => component.appendTo('#ember-testing')); assert.ok(auditSpy.notCalled); - Ember.run(() => component.destroy()); + run(() => component.destroy()); // Turn testing mode back on to ensure validity of other tests Ember.testing = true; }); -/* Ember.Component.audit */ +/* Component.audit */ test('audit should log any violations found', function(assert) { sandbox.stub(axe, 'a11yCheck', function(el, options, callback) { @@ -102,17 +104,14 @@ test('audit should log any violations found', function(assert) { }); }); - let logSpy = sandbox.spy(Ember.Logger, 'error'); + let logSpy = sandbox.spy(Logger, 'error'); - let component = Ember.Component.create({}); + let component = Component.create({}); component.audit(); assert.ok(logSpy.calledOnce); }); -skip('audit should mark the DOM nodes of any violations', function(/* assert */) { - -}); test('audit should do nothing if no violations found', function(assert) { sandbox.stub(axe, 'a11yCheck', function(el, options, callback) { @@ -121,15 +120,15 @@ test('audit should do nothing if no violations found', function(assert) { }); }); - let logSpy = sandbox.spy(Ember.Logger, 'error'); + let logSpy = sandbox.spy(Logger, 'error'); - let component = Ember.Component.create({}); + let component = Component.create({}); component.audit(); assert.ok(logSpy.notCalled); }); -/* Ember.Component.axeCallback */ +/* Component.axeCallback */ test('axeCallback receives the results of the audit', function(assert) { let results = { violations: [] }; @@ -139,7 +138,7 @@ test('axeCallback receives the results of the audit', function(assert) { }); let axeCallbackSpy = sandbox.spy(); - let component = Ember.Component.create({ + let component = Component.create({ axeCallback: axeCallbackSpy }); @@ -156,22 +155,83 @@ test('axeCallback throws an error if it is not a function', function(assert) { callback(results); }); - let component = Ember.Component.create({ + let component = Component.create({ axeCallback: 'axeCallbackSpy' }); assert.throws(() => component.audit(), 'axeCallback should be a function.'); }); -/* Ember.Component.axeOptions */ +/* Component.axeOptions */ test('axeOptions are passed in as the second param to a11yCheck', function(assert) { let a11yCheckStub = sandbox.stub(axe, 'a11yCheck'); let axeOptions = { test: 'test' }; - let component = Ember.Component.create({ axeOptions }); + let component = Component.create({ axeOptions }); component.audit(); assert.ok(a11yCheckStub.calledOnce); assert.ok(a11yCheckStub.calledWith(component.$(), axeOptions)); }); + +test('custom classNames are set on the violating elmenet if they are defined', function (assert) { + const dummyDOMNodeID = 'sign-up-button'; + const dummyDOMNodeClass = 'icon-left-shark'; + const dummyDOMNode = document.createElement('div'); + + const component = Component.create({ + axeViolationClassNames: [dummyDOMNodeClass] + }); + + dummyDOMNode.setAttribute('id', dummyDOMNodeID); + document.body.appendChild(dummyDOMNode); + + // run(() => component.appendTo('#ember-testing')); + sandbox.stub(axe, 'a11yCheck', function(el, options, callback) { + callback({ + violations: [{ + name: 'test', + nodes: [ + { target: [`#${dummyDOMNodeID}`] } + ] + }] + }); + }); + + component.audit(); + + assert.ok(dummyDOMNode.classList.contains(dummyDOMNodeClass)); + assert.notOk(dummyDOMNode.classList.contains('axe-violation')); + + run(() => dummyDOMNode.remove()); +}); + +test(`the component defaults to setting the \`axe-violation\` class on + the element if no custom class names are set`, function (assert) { + + const dummyDOMNodeID = 'sign-up-button'; + const dummyDOMNode = document.createElement('div'); + const component = Component.create(); + + dummyDOMNode.setAttribute('id', dummyDOMNodeID); + document.body.appendChild(dummyDOMNode); + + // run(() => component.appendTo('#ember-testing')); + sandbox.stub(axe, 'a11yCheck', function(el, options, callback) { + callback({ + violations: [{ + name: 'test', + nodes: [ + { target: [`#${dummyDOMNodeID}`] } + ] + }] + }); + }); + + component.audit(); + + assert.ok(dummyDOMNode.classList.contains('axe-violation')); + + run(() => dummyDOMNode.remove()); +});