Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rule): no-autoplay-audio #1946

Merged
merged 24 commits into from
Jan 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading