diff --git a/.gitignore b/.gitignore index 955852e2..665cbb82 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ extension/Handlebar.js upload.py gen/ node_modules/ +.tmp/ diff --git a/Changelog.md b/Changelog.md new file mode 100644 index 00000000..e1290cb4 --- /dev/null +++ b/Changelog.md @@ -0,0 +1,66 @@ +### Enhancements: +* Rework findTextAlternatives not to return non-exposed text alternatives. +* Add Bower config (#157) + +### Bug fixes: +* Check for any text alternatives when assessing unlabeled images (#154). + +## 2.7.0 - 2015-05-15 + +### New rules +* This element does not support ARIA roles, states and properties (`src/audits/AriaOnReservedElement.js`) +* aria-owns should not be used if ownership is implicit in the DOM (`src/audits/AriaOwnsDescendant.js`) +* Elements with ARIA roles must be in the correct scope (`src/audits/AriaRoleNotScoped.js`) +* An element's ID must be unique in the DOM (`src/audits/DuplicateId.js`) +* The web page should have the content's human language indicated in the markup (`src/audits/HumanLangMissing.js`) +* An element's ID must not be present in more that one aria-owns attribute at any time (`src/audits/MultipleAriaOwners.js`) +* ARIA attributes which refer to other elements by ID should refer to elements which exist in the DOM (`src/audits/NonExistentAriaRelatedElement.js` - previously `src/audits/NonExistentAriaLabeledBy.js`) +* Elements with ARIA roles must ensure required owned elements are present (`src/audits/RequiredOwnedAriaRoleMissing.js`) +* Avoid positive integer values for tabIndex (`src/audits/TabIndexGreaterThanZero.js`) +* This element has an unsupported ARIA attribute (`src/audits/UnsupportedAriaAttribute.js`) + +### Enhancements: +* Add configurable blacklist phrases and stop words to LinkWithUnclearPurpose (#99) +* Detect and warn if we reuse the same code for more than one rule. (#133) +* Force focus before testing visibility on focusable elements. (#65) +* Use getDistributedNodes to get nodes distributed into shadowRoots (#128) +* Add section to Audit Rules page for HumanLangMissing and link to it from rule (#119) +* Reference "applied role" in axs.utils.getRoles enhancement (#130) +* Add warning that AX_FOCUS_02 is not available from axs.Audit.run() (#85) + +### Bug fixes: +* Incorrect use of nth-of-type against className in utils.getQuerySelectorText (#87) +* AX_TEXT_01 Accessibility Audit test should probably ignore role=presentation elements (#97) +* Fix path to audit rules in phantomjs runner (#108) +* Label audit should fail if form fields lack a label, even with placeholder text (#81) +* False positives for controls without labels with role=presentation (#23) +* Fix "valid" flag on return value of axs.utils.getRoles (#131) + +Note: this version number is somewhat arbitrary - just bringing it vaguely in line with [the extension](https://github.com/GoogleChrome/accessibility-developer-tools-extension) since that's where the library originated - but will use semver for version bumps going forward from here. + +## 0.0.5 - 2014-02-04 + +### Enhancements: +* overlapping elements detection code made more sophisticated +* axs.properties.getFocusProperties() returns more information about visibility +* new axs.properties.hasDirectTextDescendant() method with more sophisticated detection of text content + +### Bug fixes: +* FocusableElementNotVisibleAndNotAriaHidden audit passes on elements which are brought onscreen on focus +* UnfocusableElementsWithOnclick checks for element.disabled +* Fix infinite loop when getting descendant text content of a label containing an input +* Detect elements which are out of scroll area of any parent element, not just the document scroll area +* findTextAlternatives doesn't throw TypeError if used on a HTMLSelectElement + +## 0.0.4 - 2013-10-03 + +### Enhancements: + +* axs.AuditRule.run() has a new signature: it now takes an options object. Please see method documentation for details. +* Audit Rule severity can be overridden (per Audit Rule) in AuditConfig. + +### Bug fixes: + +* axs.utils.isLowContrast() now rounds to the nearest 0.1 before checking (so `#777` is now a passing value) +* MainRoleOnInappropriateElement was always failing due to accessible name calculation taking the main role into account and not descending into content (now just gets descendant content directly) +* UnfocusableElementsWithOnClick had a dangling if-statement causing very noisy false positives diff --git a/Gruntfile.js b/Gruntfile.js index bd90d175..e13ff852 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,63 +1,205 @@ -'use strict'; - module.exports = function(grunt) { + 'use strict'; + + require('load-grunt-tasks')(grunt); + grunt.initConfig({ - 'git-describe': { - options: { + pkg: grunt.file.readJSON('package.json'), + changelog: 'Changelog.md', + + 'gh-release': {}, - }, - 'run': {} - }, closurecompiler: { minify: { requiresConfig: 'git-revision', files: { - "gen/axs_testing.js": [ - "./lib/closure-library/closure/goog/base.js", - "./src/js/axs.js", - "./src/js/BrowserUtils.js", - "./src/js/Constants.js", - "./src/js/AccessibilityUtils.js", - "./src/js/Properties.js", - "./src/js/AuditRule.js", - "./src/js/AuditRules.js", - "./src/js/AuditResults.js", - "./src/js/Audit.js", - "./src/audits/*" + '.tmp/build/axs_testing.js': [ + './lib/closure-library/closure/goog/base.js', + './src/js/axs.js', + './src/js/BrowserUtils.js', + './src/js/Constants.js', + './src/js/AccessibilityUtils.js', + './src/js/Properties.js', + './src/js/AuditRule.js', + './src/js/AuditRules.js', + './src/js/AuditResults.js', + './src/js/Audit.js', + './src/audits/*' ] }, options: { - "language_in": "ECMASCRIPT5", - "formatting": "PRETTY_PRINT", - "summary_detail_level": 3, - "warning_level": "VERBOSE", - "compilation_level": "SIMPLE_OPTIMIZATIONS", - "output_wrapper": "<%= grunt.file.read('scripts/output_wrapper.txt') %>", - "externs": "./src/js/externs/externs.js" + 'language_in': 'ECMASCRIPT5', + 'formatting': 'PRETTY_PRINT', + 'summary_detail_level': 3, + 'warning_level': 'VERBOSE', + 'compilation_level': 'SIMPLE_OPTIMIZATIONS', + 'output_wrapper': "<%= grunt.file.read('scripts/output_wrapper.txt') %>", + 'externs': './src/js/externs/externs.js' } } }, + qunit: { all: ['test/index.html'] + }, + + copy: { + dist: { + expand: true, + cwd: '.tmp/build', + src: '**/*', + dest: 'dist/js' + } + }, + + clean: { + all: ['.tmp', 'dist'] + }, + + bump: { + options: { + prereleaseName: 'rc', + files: ['package.json', 'bower.json'], + updateConfigs: ['pkg'], + pushTo: "<%= grunt.config.get('gh-release.remote') %>", + commitFiles: ['package.json', "<%= grunt.config.get('changelog') %>", 'bower.json', 'dist'] + } + }, + + coffee: { + compile: { + files: { + '.tmp/util/gh_repo.js': 'src/util/gh_repo.coffee' + } + } + }, + + prompt: { + 'gh-release': { + options: { + questions: [ + { + config: 'gh-release.remote', + type: 'input', + message: 'Git Remote (usually upstream or origin)', + default: 'upstream', + validate: function(val) { + return (grunt.util._.size(val) > 0); + } + }, + { + config: 'gh-release.repo', + type: 'input', + message: 'Github Repository', + default: 'GoogleChrome/accessibility-developer-tools', + validate: function(val) { + return (grunt.util._.size(val) > 0); + } + }, + { + config: 'gh-release.username', + type: 'input', + message: 'Github Username', + validate: function(val) { + return (grunt.util._.size(val) > 0); + } + }, + { + config: 'gh-release.password', + type: 'password', + message: 'Github Password or Token', + validate: function(val) { + return (grunt.util._.size(val) > 0); + } + } + ] + } + } } }); - grunt.loadNpmTasks('grunt-closurecompiler'); - grunt.loadNpmTasks('grunt-contrib-qunit'); + grunt.registerTask('changelog', function(type) { + grunt.task.requires('bump-only:' + type); - grunt.registerTask('git-describe', function() { - var _spawn = require("grunt-util-spawn")(grunt); + var config = { + data: { + version: grunt.config.get('pkg.version'), + releaseDate: grunt.template.today("yyyy-mm-dd") + } + }; + + var stopRegex = /^\#\#\ [0-9]+.*$/m; + var stopIndex = 0; + var releaseNotes = ''; + var dest = grunt.config.get('changelog'); + var contents = grunt.file.read(dest); + var headerTpl = "## <%= version %> - <%= releaseDate %>\n\n"; + var header = grunt.template.process(headerTpl, config); + + if (contents.length > 0) { + if ((stopIndex = contents.search(stopRegex)) !== -1) { + releaseNotes = contents.slice(0, stopIndex); + } + } + + grunt.config.set("gh-release.release-notes", releaseNotes); + + grunt.file.write(dest, "" + header + contents); + grunt.log.ok("Changelog updated, and release notes extracted."); + }); + + grunt.registerTask('gh-release', function() { + // Compile and load GH Repo manager. + grunt.task.requires('coffee:compile'); + var GHRepo = require('./.tmp/util/gh_repo'); + + var done = this.async(); + var config = grunt.config.get('gh-release'); + var pkg = grunt.config.get('pkg'); + var currentRelease = 'v' + pkg.version; + var nextRelease = currentRelease.replace(/-rc\.[0-9]+/, ''); + var repo = new GHRepo(config); + repo.log = function() { grunt.log.writeln.apply(grunt, arguments); }; + var payload = { + tag_name: currentRelease, + name: nextRelease, + body: config['release-notes'], + draft: true + }; + + grunt.log.writeln("Searching for existing GH release:", nextRelease); + repo.getReleaseByName(nextRelease) + .then(function(release) { + if (release) { + payload.body += "\n" + release.body; + repo.updateRelease(release, payload).then(function() { + grunt.log.ok('Github release ' + nextRelease + ' updated successfully.'); + done(); + }); + } else { + repo.createRelease(payload).then(function() { + grunt.log.ok('Github release ' + nextRelease + ' created successfully'); + done(); + }); + } + }) + .catch(function(err) { + throw err; + }); + }); + + grunt.registerTask('git-describe', function() { // Start async task var done = this.async(); - _spawn({ - "cmd" : "git", - "args" : [ "rev-parse", "HEAD" ], - "opts" : { - "cwd" : "." + grunt.util.spawn({ + 'cmd' : 'git', + 'args' : [ 'rev-parse', 'HEAD' ], + 'opts' : { + 'cwd' : '.' } - }, function(err, result) { + }, function(err, result) { if (err) { grunt.log.error(err).verbose.error(result); done(); @@ -69,20 +211,35 @@ module.exports = function(grunt) { }); }); + grunt.registerTask('release', function(releaseType) { + if (typeof releaseType === 'undefined' || releaseType === null) { + grunt.fail.fatal('You must specify a release type. i.e. grunt release:prerelease'); + } + + grunt.task.run([ + 'prompt:gh-release', + 'build', + 'test:unit', + 'copy:dist', + 'bump-only:' + releaseType, + 'changelog:' + releaseType, + 'bump-commit', + 'coffee:compile', + 'gh-release' + ]); + }); + grunt.registerTask('save-revision', function() { grunt.event.once('git-describe', function (rev) { - grunt.log.writeln("Git Revision: " + rev); + grunt.log.writeln('Git Revision: ' + rev); grunt.config.set('git-revision', rev); }); grunt.task.run('git-describe'); }); - grunt.registerTask('copy-dist', function() { - grunt.file.copy('gen/axs_testing.js', 'dist/js/axs_testing.js'); - }); - - grunt.registerTask('default', ['save-revision', 'closurecompiler:minify', 'qunit']); - grunt.registerTask('build', ['default', 'copy-dist']); - grunt.registerTask('travis', ['closurecompiler:minify', 'qunit']); + grunt.registerTask('build', ['clean:all', 'save-revision', 'closurecompiler:minify']); + grunt.registerTask('test:unit', ['qunit']); + grunt.registerTask('dist', ['build', 'copy:dist']); + grunt.registerTask('travis', ['closurecompiler:minify', 'test:unit']); + grunt.registerTask('default', ['build', 'test:unit']); }; - diff --git a/bower.json b/bower.json new file mode 100644 index 00000000..2611fe5a --- /dev/null +++ b/bower.json @@ -0,0 +1,29 @@ +{ + "name": "accessibility-developer-tools", + "version": "2.7.0", + "homepage": "https://github.com/GoogleChrome/accessibility-developer-tools", + "authors": [ + "Google" + ], + "description": "This is a library of accessibility-related testing and utility code.", + "main": "dist/js/axs_testing.js", + "moduleType": [ + "globals" + ], + "keywords": [ + "accessibility", + "testing", + "WCAG" + ], + "license": "Apache License 2.0", + "ignore": [ + "**/.*", + "lib", + "scripts", + "src", + "test", + "tools", + "Gruntfile.js", + "package.json" + ] +} diff --git a/dist/js/.gitkeep b/dist/js/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/dist/js/axs_testing.js b/dist/js/axs_testing.js index 1fe815bc..d8fbf82b 100644 --- a/dist/js/axs_testing.js +++ b/dist/js/axs_testing.js @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. * - * Generated from http://github.com/GoogleChrome/accessibility-developer-tools/tree/08b3be1e095d0203a094b370c27f77446a6c69e9 + * Generated from http://github.com/GoogleChrome/accessibility-developer-tools/tree/a33b34feb4bf5c6990c9d88f98c3c8e3115168ab * * See project README for build steps. */ @@ -1536,20 +1536,19 @@ axs.AuditRule.collectMatchingElements = function(a, b, c, d) { } } if (e && "content" == e.localName) { - for (var f = e.getDistributedNodes(), g = 0;g < f.length;g++) { - axs.AuditRule.collectMatchingElements(f[g], b, c, d); + for (e = e.getDistributedNodes(), f = 0;f < e.length;f++) { + axs.AuditRule.collectMatchingElements(e[f], b, c, d); } } else { if (e && "shadow" == e.localName) { if (f = e, d) { - for (f = f.getDistributedNodes(), g = 0;g < f.length;g++) { - axs.AuditRule.collectMatchingElements(f[g], b, c, d); + for (e = f.getDistributedNodes(), f = 0;f < e.length;f++) { + axs.AuditRule.collectMatchingElements(e[f], b, c, d); } - } else { + } else { console.warn("ShadowRoot not provided for", e); } } - e && "iframe" == e.localName && e.contentDocument && axs.AuditRule.collectMatchingElements(e.contentDocument, b, c, d); for (a = a.firstChild;null != a;) { axs.AuditRule.collectMatchingElements(a, b, c, d), a = a.nextSibling; } @@ -1591,7 +1590,7 @@ axs.AuditRules = {}; } if (c.name in a) { throw Error('Can not add audit rule with same name: "' + c.name + '"'); - } + } a[c.name] = b[c.code] = c; }; axs.AuditRules.getRule = function(c) { @@ -1804,7 +1803,7 @@ axs.AuditRules.addRule({name:"audioWithoutControls", heading:"Audio elements sho if (a.test(e) && (e = e.replace(a, ""), !axs.constants.ARIA_PROPERTIES.hasOwnProperty(e))) { return !0; } - } + } return !1; }, code:"AX_ARIA_11"}); })(); @@ -1866,8 +1865,7 @@ axs.AuditRules.addRule({name:"focusableElementNotVisibleAndNotAriaHidden", headi return !1; } } - a = axs.properties.findTextAlternatives(a, {}); - return null == a || "" === a.trim() ? !1 : !0; + return "" === axs.properties.findTextAlternatives(a, {}).trim() ? !1 : !0; }, test:function(a) { if (axs.utils.isElementOrAncestorHidden(a)) { return !1; diff --git a/package.json b/package.json index 9557995e..c06f6ff5 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,23 @@ { "name": "accessibility-developer-tools", - "version": "2.6.0", + "version": "2.7.0", "repository": { "type": "git", "url": "http://github.com/GoogleChrome/accessibility-developer-tools" }, "devDependencies": { + "bluebird": "^2.9.27", "grunt": "^0.4.5", + "grunt-bump": "^0.3.1", "grunt-cli": "^0.1.13", "grunt-closurecompiler": "^0.9.9", - "grunt-contrib-qunit": "^0.2.2", - "grunt-util-spawn": "^0.0.2" + "grunt-contrib-clean": "^0.6.0", + "grunt-contrib-coffee": "^0.13.0", + "grunt-contrib-copy": "^0.8.0", + "grunt-contrib-qunit": "^0.7.0", + "grunt-prompt": "^1.3.0", + "load-grunt-tasks": "^3.2.0", + "superagent": "^1.2.0" }, "scripts": { "test": "grunt travis --verbose" diff --git a/src/audits/ImageWithoutAltText.js b/src/audits/ImageWithoutAltText.js index 9ec511f6..91c95dcb 100644 --- a/src/audits/ImageWithoutAltText.js +++ b/src/audits/ImageWithoutAltText.js @@ -15,11 +15,12 @@ goog.require('axs.AuditRules'); goog.require('axs.browserUtils'); goog.require('axs.constants.Severity'); +goog.require('axs.properties'); goog.require('axs.utils'); axs.AuditRules.addRule({ name: 'imagesWithoutAltText', - heading: 'Images should have an alt attribute', + heading: 'Images should have a text alternative or presentational role', url: 'https://github.com/GoogleChrome/accessibility-developer-tools/wiki/Audit-Rules#ax_text_02', severity: axs.constants.Severity.WARNING, relevantElementMatcher: function(element) { @@ -27,7 +28,15 @@ axs.AuditRules.addRule({ !axs.utils.isElementOrAncestorHidden(element); }, test: function(image) { - return (!image.hasAttribute('alt') && image.getAttribute('role') != 'presentation'); + var imageIsPresentational = (image.hasAttribute('alt') && image.alt == '') || image.getAttribute('role') == 'presentation'; + if (imageIsPresentational) + return false; + var textAlternatives = {}; + axs.properties.findTextAlternatives(image, textAlternatives); + var numTextAlternatives = Object.keys(textAlternatives).length; + if (numTextAlternatives == 0) + return true; + return false; }, code: 'AX_TEXT_02' }); diff --git a/src/js/Properties.js b/src/js/Properties.js index 9dac1a83..a6a40ed9 100644 --- a/src/js/Properties.js +++ b/src/js/Properties.js @@ -494,31 +494,16 @@ axs.properties.getTextFromHostLanguageAttributes = function(element, existingComputedname, recursive) { var computedName = existingComputedname; - if (axs.browserUtils.matchSelector(element, 'img')) { - if (element.hasAttribute('alt')) { - var altValue = {}; - altValue.type = 'string'; - altValue.valid = true; - altValue.text = element.getAttribute('alt'); - if (computedName) - altValue.unused = true; - else - computedName = altValue.text; - textAlternatives['alt'] = altValue; - } else { - var altValue = {}; - altValue.valid = false; - altValue.errorMessage = 'No alt value provided'; - textAlternatives['alt'] = altValue; - var src = element.src; - if (typeof(src) == 'string') { - var parts = src.split('/'); - var filename = parts.pop(); - var filenameValue = { text: filename }; - textAlternatives['filename'] = filenameValue; - computedName = filename; - } - } + if (axs.browserUtils.matchSelector(element, 'img') && element.hasAttribute('alt')) { + var altValue = {}; + altValue.type = 'string'; + altValue.valid = true; + altValue.text = element.getAttribute('alt'); + if (computedName) + altValue.unused = true; + else + computedName = altValue.text; + textAlternatives['alt'] = altValue; } var controlsSelector = ['input:not([type="hidden"]):not([disabled])', @@ -611,13 +596,28 @@ axs.properties.getTextProperties = function(node) { var computedName = axs.properties.findTextAlternatives(node, textProperties, false, true); if (Object.keys(textProperties).length == 0) { + /** @type {Element} */ var element = axs.utils.asElement(node); + if (element && axs.browserUtils.matchSelector(element, 'img')) { + var altValue = {}; + altValue.valid = false; + altValue.errorMessage = 'No alt value provided'; + textProperties['alt'] = altValue; + + var src = element.src; + if (typeof(src) == 'string') { + var parts = src.split('/'); + var filename = parts.pop(); + var filenameValue = { text: filename }; + textProperties['filename'] = filenameValue; + computedName = filename; + } + } + if (!computedName) return null; - textProperties.hasProperties = false; - } else { - textProperties.hasProperties = true; } + textProperties.hasProperties = Boolean(Object.keys(textProperties).length); textProperties.computedText = computedName; textProperties.lastWord = axs.properties.getLastWord(computedName); return textProperties; diff --git a/src/util/gh_repo.coffee b/src/util/gh_repo.coffee new file mode 100644 index 00000000..a4844cc4 --- /dev/null +++ b/src/util/gh_repo.coffee @@ -0,0 +1,69 @@ +request = require 'superagent' +Promise = require 'bluebird' + +# Small utility class to interact with the Github v3 releases API. +module.exports = class GHRepo + constructor: (@config = {}) -> + @baseUrl = "https://api.github.com/repos/#{@config.repo}" + + _buildRequest: (req) -> + req + .auth @config.username, @config.password + .set 'Accept', 'application/vnd.github.v3' + .set 'User-Agent', 'grunt' + + log: -> console.log.apply console, arguments + + getReleaseByTagName: (tag) -> + # GET /repos/:owner/:repo/releases/tags/:tag + new Promise (resolve, reject) => + @log 'GET', "#{@baseUrl}/releases/tags/#{tag}" + @_buildRequest(request.get "#{@baseUrl}/releases/tags/#{tag}") + .end (err, res) -> + return resolve() if res.statusCode is 404 + return reject(err) if err? + return reject("Request failed") if res.statusCode isnt 200 + resolve res.body + + getReleases: (tag) -> + # GET /repos/:owner/:repo/releases + new Promise (resolve, reject) => + @log 'GET', "#{@baseUrl}/releases" + @_buildRequest(request.get "#{@baseUrl}/releases") + .end (err, res) -> + return resolve() if res.statusCode is 404 + return reject(err) if err? + return reject("Request failed") if res.statusCode isnt 200 + resolve res.body + + updateRelease: (release, payload) -> + # PATCH /repos/:owner/:repo/releases/:id + new Promise (resolve, reject) => + @log 'PATCH', "#{@baseUrl}/releases/#{release.id}" + @_buildRequest(request.patch "#{@baseUrl}/releases/#{release.id}") + .send payload + .end (err, res) -> + return reject(err) if err? + return reject("Request failed") if res.statusCode isnt 200 + resolve res.body + + createRelease: (payload) -> + # POST /repos/:owner/:repo/releases + new Promise (resolve, reject) => + @log 'POST', "#{@baseUrl}/releases" + @_buildRequest(request.post "#{@baseUrl}/releases") + .send payload + .end (err, res) -> + return reject(err) if err? + return reject("Request failed") if res.statusCode isnt 201 + resolve res.body + + getReleaseByName: (name) -> + new Promise (resolve, reject) => + @getReleases().then (releases = []) -> + for release in releases + return resolve(release) if release.name is name + + return resolve() + .catch (err) -> + reject "Unable to fetch project releases." diff --git a/test/audits/image-without-alt-text-test.js b/test/audits/image-without-alt-text-test.js new file mode 100644 index 00000000..be25802c --- /dev/null +++ b/test/audits/image-without-alt-text-test.js @@ -0,0 +1,83 @@ +// Copyright 2015 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +(function() { + module('ImageWithoutAltText'); + var rule = axs.AuditRules.getRule('imagesWithoutAltText'); + + test('Image with no text alternative', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + var expected = { elements: [img], result: axs.constants.AuditResult.FAIL }; + deepEqual(rule.run({ scope: fixture }), expected, 'Image has no text alternative'); + }); + + test('Image with no text alternative and presentational role', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + img.setAttribute('role', 'presentation'); + var expected = { elements: [], result: axs.constants.AuditResult.PASS }; + deepEqual(rule.run({ scope: fixture }), expected, 'Image has presentational role'); + }); + + test('Image with alt text', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + img.alt = 'Smile!'; + var expected = { elements: [], result: axs.constants.AuditResult.PASS }; + deepEqual(rule.run({ scope: fixture }), expected, 'Image has alt text'); + }); + + test('Image with empty alt text', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + img.alt = ''; + var expected = { elements: [], result: axs.constants.AuditResult.PASS }; + deepEqual(rule.run({ scope: fixture }), expected, 'Image has empty alt text'); + }); + + test('Image with aria label', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + img.setAttribute('aria-label', 'Smile!'); + var expected = { elements: [], result: axs.constants.AuditResult.PASS }; + deepEqual(rule.run({ scope: fixture }), expected, 'Image has aria label'); + }); + + test('Image with aria labelledby', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + var label = fixture.appendChild(document.createElement('div')); + label.textContent = 'Smile!'; + label.id = 'label'; + img.setAttribute('aria-labelledby', 'label'); + var expected = { elements: [], result: axs.constants.AuditResult.PASS }; + deepEqual(rule.run({ scope: fixture }), expected, 'Image has aria labelledby'); + }); + + test('Image with title', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + img.setAttribute('title', 'Smile!'); + var expected = { elements: [], result: axs.constants.AuditResult.PASS }; + deepEqual(rule.run({ scope: fixture }), expected, 'Image has title'); + }); +})(); diff --git a/test/index.html b/test/index.html index e00f854f..d142dc11 100644 --- a/test/index.html +++ b/test/index.html @@ -56,6 +56,7 @@ + diff --git a/test/js/properties-test.js b/test/js/properties-test.js index c96b59d1..f023bd6f 100644 --- a/test/js/properties-test.js +++ b/test/js/properties-test.js @@ -32,6 +32,66 @@ test('returns the calculated text alternative for the given element', function() ok(false, 'Threw exception'); } }); +test('Image with no text alternative', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + var textAlternatives = {}; + axs.properties.findTextAlternatives(img, textAlternatives); + equal(Object.keys(textAlternatives).length, 0, 'Image has no text alternative'); +}); + +test('Image with alt text', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + img.alt = 'Smile!'; + var textAlternatives = {}; + axs.properties.findTextAlternatives(img, textAlternatives); + equal(Object.keys(textAlternatives).length, 1, 'exactly one text alternative'); + equal('alt' in textAlternatives, true, 'alt in textAlternatives'); + equal('Smile!', textAlternatives.alt.text); +}); + +test('Image with aria label', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + img.setAttribute('aria-label', 'Smile!'); + var textAlternatives = {}; + axs.properties.findTextAlternatives(img, textAlternatives); + equal(Object.keys(textAlternatives).length, 1, 'exactly one text alternative'); + equal('ariaLabel' in textAlternatives, true, 'ariaLabel in textAlternatives'); + equal('Smile!', textAlternatives.ariaLabel.text); +}); + +test('Image with aria labelledby', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + var label = fixture.appendChild(document.createElement('div')); + label.textContent = 'Smile!'; + label.id = 'label'; + img.setAttribute('aria-labelledby', 'label'); + var textAlternatives = {}; + axs.properties.findTextAlternatives(img, textAlternatives); + equal(Object.keys(textAlternatives).length, 1, 'exactly one text alternative'); + equal('ariaLabelledby' in textAlternatives, true, 'ariaLabelledby in textAlternatives'); + equal('Smile!', textAlternatives.ariaLabelledby.text); +}); + +test('Image with title', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + img.setAttribute('title', 'Smile!'); + var textAlternatives = {}; + axs.properties.findTextAlternatives(img, textAlternatives); + equal(Object.keys(textAlternatives).length, 1, 'exactly one text alternative'); + equal('title' in textAlternatives, true, 'title in textAlternatives'); + equal('Smile!', textAlternatives.title.text); +}); + module('getTextFromHostLanguageAttributes', { setup: function () { @@ -154,3 +214,17 @@ test('get implicit role for li descendant of ul', function() { var actual = axs.properties.getImplicitRole(element); strictEqual(actual, ''); }); + +module('getTextProperties', {}); +test('Image with no text alternative', function() { + var fixture = document.getElementById('qunit-fixture'); + var img = fixture.appendChild(document.createElement('img')); + img.src = 'smile.jpg'; + var textProperties = axs.properties.getTextProperties(img); + equal('alt' in textProperties, true, 'alt in textProperties'); + equal(textProperties.alt.valid, false, 'alt is not valid'); + equal('filename' in textProperties, true, 'filename in textProperties'); + equal(textProperties.filename.text, 'smile.jpg'); + equal('computedText' in textProperties, true, 'computedText in textProperties'); + equal(textProperties.computedText, 'smile.jpg'); +});