Skip to content

Commit

Permalink
feat(rule): no-autoplay-audio (#1946)
Browse files Browse the repository at this point in the history
* add meta data (json) for rules and checks

* update is-hidden to exclude media tags

* add test for matches

* add axe.utils.preloadMedia helper fn

* rule no-autoplay-audio

* revert changes to extracting getAllRootNodesInTree Fn

* update interpretation of audio src

* update resolving src in audio check

* add tests for no-autoplay-audio check

* update test of preloadMedia

* Apply suggestions from code review

Co-Authored-By: Steven Lambert <steven.lambert@deque.com>

* update matches

* use querySelectorAllFilter

* updates based on review

* test: update tests

* only handle audio media node with no controls in ishidden

* update tests

* use excludeHidden over isHidden fn

* test: move integration test to full
  • Loading branch information
jeeyyy committed Jan 20, 2020
1 parent 185e1f5 commit b2373cb
Show file tree
Hide file tree
Showing 11 changed files with 658 additions and 132 deletions.
181 changes: 91 additions & 90 deletions doc/rule-descriptions.md

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions lib/checks/media/no-autoplay-audio.js
@@ -0,0 +1,93 @@
/**
* if duration cannot be read, this means `preloadMedia` has failed
*/
if (!node.duration || Number.isNaN(node.duration)) {
console.warn(`axe.utils.preloadMedia did not load metadata`);
return undefined;
}

/**
* Compute playable duration and verify if it within allowed duration
*/
const { allowedDuration = 3 } = options;
const playableDuration = getPlayableDuration(node);
if (playableDuration <= allowedDuration && !node.hasAttribute('loop')) {
return true;
}

/**
* if media element does not provide controls mechanism
* -> fail
*/
if (!node.hasAttribute('controls')) {
return false;
}

return true;

/**
* Compute playback duration
* @param {HTMLMediaElement} elm media element
*/
function getPlayableDuration(elm) {
if (!elm.currentSrc) {
return 0;
}

const playbackRange = getPlaybackRange(elm.currentSrc);
if (!playbackRange) {
return Math.abs(elm.duration - (elm.currentTime || 0));
}

if (playbackRange.length === 1) {
return Math.abs(elm.duration - playbackRange[0]);
}

return Math.abs(playbackRange[1] - playbackRange[0]);
}

/**
* Get playback range from a media elements source, if specified
* See - https://developer.mozilla.org/de/docs/Web/HTML/Using_HTML5_audio_and_video#Specifying_playback_range
*
* Eg:
* src='....someMedia.mp3#t=8'
* -> should yeild [8]
* src='....someMedia.mp3#t=10,12'
* -> should yeild [10,12]
* @param {String} src media src
* @returns {Array|undefined}
*/
function getPlaybackRange(src) {
const match = src.match(/#t=(.*)/);
if (!match) {
return;
}
const [, value] = match;
const ranges = value.split(',');

return ranges.map(range => {
// range is denoted in HH:MM:SS -> convert to seconds
if (/:/.test(range)) {
return convertHourMinSecToSeconds(range);
}
return parseFloat(range);
});
}

/**
* Add HH, MM, SS to seconds
* @param {String} hhMmSs time expressed in HH:MM:SS
*/
function convertHourMinSecToSeconds(hhMmSs) {
let parts = hhMmSs.split(':');
let secs = 0;
let mins = 1;

while (parts.length > 0) {
secs += mins * parseInt(parts.pop(), 10);
mins *= 60;
}

return parseFloat(secs);
}
15 changes: 15 additions & 0 deletions lib/checks/media/no-autoplay-audio.json
@@ -0,0 +1,15 @@
{
"id": "no-autoplay-audio",
"evaluate": "no-autoplay-audio.js",
"options": {
"allowedDuration": 3
},
"metadata": {
"impact": "moderate",
"messages": {
"pass": "<video> or <audio> does not output audio for more than allowed duration or has controls mechanism",
"fail": "<video> or <audio> outputs audio for more than allowed duration and does not have a controls mechanism",
"incomplete": "Check that the <video> or <audio> does not output audio for more than allowed duration or provides a controls mechanism"
}
}
}
24 changes: 22 additions & 2 deletions lib/core/utils/preload-media.js
Expand Up @@ -7,9 +7,29 @@
* @property {Object} options.treeRoot (optional) the DOM tree to be inspected
*/
axe.utils.preloadMedia = function preloadMedia({ treeRoot = axe._tree[0] }) {
const mediaVirtualNodes = axe.utils.querySelectorAll(
const mediaVirtualNodes = axe.utils.querySelectorAllFilter(
treeRoot,
'video, audio'
'video, audio',
({ actualNode }) => {
/**
* this is to safe-gaurd against empty `src` values which can get resolved `window.location`, thus never preloading as the URL is not a media asset
*/
if (actualNode.hasAttribute('src')) {
return !!actualNode.getAttribute('src');
}

/**
* The `src` on <source> element is essential for `audio` and `video` elements
*/
const sourceWithSrc = Array.from(
actualNode.getElementsByTagName('source')
).filter(source => !!source.getAttribute('src'));
if (sourceWithSrc.length <= 0) {
return false;
}

return true;
}
);

return Promise.all(
Expand Down
18 changes: 18 additions & 0 deletions lib/rules/no-autoplay-audio-matches.js
@@ -0,0 +1,18 @@
/**
* Ignore media nodes without `currenSrc`
* Notes:
* - https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/currentSrc
* - https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/src
*/
if (!node.currentSrc) {
return false;
}

/**
* Ignore media nodes which are `paused` or `muted`
*/
if (node.hasAttribute('paused') || node.hasAttribute('muted')) {
return false;
}

return true;
15 changes: 15 additions & 0 deletions lib/rules/no-autoplay-audio.json
@@ -0,0 +1,15 @@
{
"id": "no-autoplay-audio",
"excludeHidden": false,
"selector": "audio[autoplay], video[autoplay]",
"matches": "no-autoplay-audio-matches.js",
"tags": ["wcag2a", "wcag142", "experimental"],
"metadata": {
"description": "Ensures <video> or <audio> elements do not autoplay audio for more than 3 seconds without a control mechanism to stop or mute the audio",
"help": "<video> or <audio> elements do not autoplay audio"
},
"preload": true,
"all": ["no-autoplay-audio"],
"any": [],
"none": []
}
125 changes: 125 additions & 0 deletions test/checks/media/no-autoplay-audio.js
@@ -0,0 +1,125 @@
describe('no-autoplay-audio', function() {
'use strict';

var check;
var fixture = document.getElementById('fixture');
var isIE11 = axe.testUtils.isIE11;
var checkSetup = axe.testUtils.checkSetup;
var checkContext = axe.testUtils.MockCheckContext();
var preloadOptions = { preload: { assets: ['media'] } };

before(function() {
// The tests actually pass in IE10/11 in Windows machine, but fails in IE in selenium-ie-driver
// Issue has been created to debug selenium ie failing tests
if (isIE11) {
this.skip();
}
check = checks['no-autoplay-audio'];
});

afterEach(function() {
fixture.innerHTML = '';
axe._tree = undefined;
checkContext.reset();
});

it('returns undefined when <audio> has no source (duration cannot be interpreted)', function(done) {
var checkArgs = checkSetup('<audio id="target"></audio>');
axe.utils.preload(preloadOptions).then(function() {
assert.isUndefined(check.evaluate.apply(checkContext, checkArgs));
done();
});
});

it('returns undefined when <video> has no source (duration cannot be interpreted)', function(done) {
var checkArgs = checkSetup('<video id="target"><source src=""/></video>');
axe.utils.preload(preloadOptions).then(function() {
assert.isUndefined(check.evaluate.apply(checkContext, checkArgs));
done();
});
});

it('returns false when <audio> can autoplay and has no controls mechanism', function(done) {
var checkArgs = checkSetup(
'<audio id="target" src="/test/assets/moon-speech.mp3" autoplay="true"></audio>'
);
axe.utils.preload(preloadOptions).then(function() {
assert.isFalse(check.evaluate.apply(checkContext, checkArgs));
done();
});
});

it('returns false when <video> can autoplay and has no controls mechanism', function(done) {
var checkArgs = checkSetup(
'<video id="target" autoplay="true">' +
'<source src="/test/assets/video.webm" type="video/webm" />' +
'<source src="/test/assets/video.mp4" type="video/mp4" />' +
'</video>'
);
axe.utils.preload(preloadOptions).then(function() {
assert.isFalse(check.evaluate.apply(checkContext, checkArgs));
done();
});
});

it('returns false when <audio> plays less than allowed dutation but loops', function(done) {
var checkArgs = checkSetup(
'<audio id="target" src="/test/assets/moon-speech.mp3#t=2,4" autoplay="true" loop="true"></audio>'
);
axe.utils.preload(preloadOptions).then(function() {
assert.isFalse(check.evaluate.apply(checkContext, checkArgs));
done();
});
});

it('returns true when <video> can autoplay and duration is below allowed duration (by passing options)', function(done) {
var checkArgs = checkSetup(
'<video id="target" autoplay="true">' +
'<source src="/test/assets/video.webm" type="video/webm" />' +
'<source src="/test/assets/video.mp4" type="video/mp4" />' +
'</video>',
{ allowedDuration: 15 }
);
axe.utils.preload(preloadOptions).then(function() {
assert.isTrue(check.evaluate.apply(checkContext, checkArgs));
done();
});
});

it('returns true when <video> can autoplay and duration is below allowed duration (by setting playback range)', function(done) {
var checkArgs = checkSetup(
'<video id="target" autoplay="true">' +
'<source src="/test/assets/video.webm#t=7,9" type="video/webm" />' +
'<source src="/test/assets/video.mp4#t=7,9" type="video/mp4" />' +
'</video>'
// Note: default allowed duration is 3s
);
axe.utils.preload(preloadOptions).then(function() {
assert.isTrue(check.evaluate.apply(checkContext, checkArgs));
done();
});
});

it('returns true when <audio> can autoplay but has controls mechanism', function(done) {
var checkArgs = checkSetup(
'<audio id="target" src="/test/assets/moon-speech.mp3" autoplay="true" controls></audio>'
);
axe.utils.preload(preloadOptions).then(function() {
assert.isTrue(check.evaluate.apply(checkContext, checkArgs));
done();
});
});

it('returns true when <video> can autoplay and has controls mechanism', function(done) {
var checkArgs = checkSetup(
'<video id="target" autoplay="true" controls>' +
'<source src="/test/assets/video.webm" type="video/webm" />' +
'<source src="/test/assets/video.mp4" type="video/mp4" />' +
'</video>'
);
axe.utils.preload(preloadOptions).then(function() {
assert.isTrue(check.evaluate.apply(null, checkArgs));
done();
});
});
});

0 comments on commit b2373cb

Please sign in to comment.