From 4bd0acf379fba2ef17679cacb70776e77e5f5e79 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Mon, 1 Mar 2021 16:22:48 +0100 Subject: [PATCH] fix(respondable): Avoid message duplication with messageId (#2816) * refactor: rewrite respondable * fix: use nodejs crypto for uuid.v4 * test(cmd): allow require('crypto') --- lib/core/public/load.js | 18 +- lib/core/utils/index.js | 1 + lib/core/utils/respondable.js | 323 +++---- lib/core/utils/respondable/assert-window.js | 21 + lib/core/utils/respondable/callback-store.js | 36 + lib/core/utils/respondable/message-id.js | 22 + lib/core/utils/respondable/message-parser.js | 109 +++ lib/core/utils/respondable/post.js | 32 + lib/core/utils/uuid.js | 9 + package-lock.json | 85 ++ package.json | 1 + test/checks/media/frame-tested.js | 2 +- test/core/public/cleanup.js | 4 +- test/core/public/load.js | 44 +- test/core/public/run.js | 126 ++- .../core/utils/collect-results-from-frames.js | 8 +- test/core/utils/respondable.js | 871 +++++++++--------- test/core/utils/send-command-to-frame.js | 104 +-- .../configure-options/configure-options.js | 49 +- .../full/umd/umd-module-exports.js | 16 +- test/mock/frames/responder.html | 42 - test/mock/frames/results-timeout.html | 16 - test/mock/frames/throwing.html | 20 +- test/node/uuid.js | 19 + test/testutils.js | 10 + 25 files changed, 1084 insertions(+), 904 deletions(-) create mode 100644 lib/core/utils/respondable/assert-window.js create mode 100644 lib/core/utils/respondable/callback-store.js create mode 100644 lib/core/utils/respondable/message-id.js create mode 100644 lib/core/utils/respondable/message-parser.js create mode 100644 lib/core/utils/respondable/post.js delete mode 100644 test/mock/frames/responder.html create mode 100644 test/node/uuid.js diff --git a/lib/core/public/load.js b/lib/core/public/load.js index 6b46a8983e..02fce5e0cf 100644 --- a/lib/core/public/load.js +++ b/lib/core/public/load.js @@ -1,6 +1,7 @@ import Audit from '../base/audit'; import cleanup from './cleanup'; import runRules from './run-rules'; +import respondable from '../utils/respondable'; function runCommand(data, keepalive, callback) { var resolve = callback; @@ -43,20 +44,21 @@ function runCommand(data, keepalive, callback) { } } +if (window.top !== window) { + respondable.subscribe('axe.start', runCommand); + respondable.subscribe('axe.ping', (data, keepalive, respond) => { + respond({ + axe: true + }); + }); +} + /** * Sets up Rules, Messages and default options for Checks, must be invoked before attempting analysis * @param {Object} audit The "audit specification" object * @private */ function load(audit) { - axe.utils.respondable.subscribe('axe.ping', (data, keepalive, respond) => { - respond({ - axe: true - }); - }); - - axe.utils.respondable.subscribe('axe.start', runCommand); - axe._audit = new Audit(audit); } diff --git a/lib/core/utils/index.js b/lib/core/utils/index.js index 9a392a6c48..6fc7c8fb4a 100644 --- a/lib/core/utils/index.js +++ b/lib/core/utils/index.js @@ -74,5 +74,6 @@ export { default as setScrollState } from './set-scroll-state'; export { default as toArray } from './to-array'; export { default as tokenList } from './token-list'; export { default as uniqueArray } from './unique-array'; +export { default as uuid } from './uuid'; export { default as validInputTypes } from './valid-input-type'; export { default as isValidLang, validLangs } from './valid-langs'; diff --git a/lib/core/utils/respondable.js b/lib/core/utils/respondable.js index 2b98c31b42..b20edd57d8 100644 --- a/lib/core/utils/respondable.js +++ b/lib/core/utils/respondable.js @@ -1,116 +1,62 @@ -import { v1 as uuid } from './uuid'; -import cache from '../base/cache'; - -const messages = {}; -const subscribers = {}; -const errorTypes = Object.freeze([ - 'EvalError', - 'RangeError', - 'ReferenceError', - 'SyntaxError', - 'TypeError', - 'URIError' -]); +import { v4 as createUuid } from './uuid'; +import assert from './assert'; +import { + storeCallback, + getCallback, + deleteCallback +} from './respondable/callback-store'; +import { parseMessage } from './respondable/message-parser'; +import { + assertIsFrameWindow, + assertIsParentWindow +} from './respondable/assert-window'; +import { post } from './respondable/post'; +import { createMessageId, isNewMessage } from './respondable/message-id'; /** - * get the unique string to be used to identify our instance of axe - * @private - */ -function _getSource() { - var application = 'axeAPI', - version = '', - src; - // TODO: es-modules_audit - if (typeof axe !== 'undefined' && axe._audit && axe._audit.application) { - application = axe._audit.application; - } - if (typeof axe !== 'undefined') { - // TODO: es-modules-version - version = axe.version; - } - src = application + '.' + version; - return src; -} -/** - * Verify the received message is from the "respondable" module - * @private - * @param {Object} postedMessage The message received via postMessage - * @return {Boolean} `true` if the message is verified from respondable - */ -function verify(postedMessage) { - if ( - // Check incoming message is valid - typeof postedMessage === 'object' && - typeof postedMessage.uuid === 'string' && - postedMessage._respondable === true - ) { - var messageSource = _getSource(); - return ( - // Check the version matches - postedMessage._source === messageSource - ); - } - return false; -} - -/** - * Posts the message to correct frame. - * This abstraction necessary because IE9 & 10 do not support posting Objects; only strings - * @private - * @param {Window} win The `window` to post the message to + * Post a message to a window who may or may not respond to it. + * @param {Window} win The window to post the message to * @param {String} topic The topic of the message * @param {Object} message The message content - * @param {String} uuid The UUID, or pseudo-unique ID of the message * @param {Boolean} keepalive Whether to allow multiple responses - default is false * @param {Function} callback The function to invoke when/if the message is responded to */ -function post(win, topic, message, uuid, keepalive, callback) { - var error; - if (message instanceof Error) { - error = { - name: message.name, - message: message.message, - stack: message.stack - }; - message = undefined; - } - - var data = { - uuid: uuid, - topic: topic, - message: message, - error: error, - _respondable: true, - _source: _getSource(), - // TODO: es-modules_uuid - _axeuuid: axe._uuid, - _keepalive: keepalive - }; - - var axeRespondables = cache.get('axeRespondables'); - if (!axeRespondables) { - axeRespondables = {}; - cache.set('axeRespondables', axeRespondables); - } - axeRespondables[uuid] = true; +export default function respondable(win, topic, message, keepalive, callback) { + const channelId = `${createUuid()}:${createUuid()}`; if (typeof callback === 'function') { - messages[uuid] = callback; + storeCallback({ channelId }, callback, false); } + post(win, { + topic, + channelId, + message, + messageId: createMessageId(), + keepalive: !!keepalive, + sendToParent: false + }); +} - win.postMessage(JSON.stringify(data), '*'); +// Set up the global listener +if (typeof window.addEventListener === 'function') { + window.addEventListener('message', messageListener, false); } /** - * Post a message to a window who may or may not respond to it. - * @param {Window} win The window to post the message to - * @param {String} topic The topic of the message - * @param {Object} message The message content - * @param {Boolean} keepalive Whether to allow multiple responses - default is false - * @param {Function} callback The function to invoke when/if the message is responded to + * Handle incoming window messages + * @param {MessageEvent} */ -function respondable(win, topic, message, keepalive, callback) { - var id = uuid(); - post(win, topic, message, id, keepalive, callback); +function messageListener({ data: dataString, source: win }) { + const { channelId, topic, message, messageId, keepalive } = + parseMessage(dataString) || {}; + const { callback, sendToParent } = getCallback({ channelId, topic }) || {}; + if (!shouldRunCallback({ message, messageId, callback, sendToParent })) { + return; + } + + if (!keepalive && channelId) { + deleteCallback({ channelId }); + } + runCallback(win, { channelId, message, keepalive, sendToParent, callback }); } /** @@ -122,143 +68,98 @@ function respondable(win, topic, message, keepalive, callback) { * @param {Function} callback The function to invoke when a message is received */ respondable.subscribe = function subscribe(topic, callback) { - subscribers[topic] = callback; + assert( + typeof callback === 'function', + 'Subscriber callback must be a function' + ); + storeCallback({ topic }, callback); }; /** * checks if the current context is inside a frame * @return {Boolean} */ -respondable.isInFrame = function isInFrame(win) { - win = win || window; +respondable.isInFrame = function isInFrame(win = window) { return !!win.frameElement; }; /** - * Helper closure to create a function that may be used to respond to a message - * @private - * @param {Window} source The window from which the message originated - * @param {String} topic The topic of the message - * @param {String} uuid The "unique" ID of the original message - * @return {Function} A function that may be invoked to respond to the message - */ -function createResponder(source, topic, uuid) { - return (message, keepalive, callback) => { - post(source, topic, message, uuid, keepalive, callback); - }; -} - -/** - * Publishes the "respondable" message to the appropriate subscriber - * @private - * @param {Window} source The window from which the message originated - * @param {Object} data The data sent with the message - * @param {Boolean} keepalive Whether to allow multiple responses - default is false + * Test if the callback should be invoked with this message + * @param {object} messageData */ -function publish(source, data, keepalive) { - var topic = data.topic; - var subscriber = subscribers[topic]; - - if (subscriber) { - var responder = createResponder(source, null, data.uuid); - subscriber(data.message, keepalive, responder); +function shouldRunCallback({ message, messageId, callback, sendToParent }) { + // An error should never come from a parent. Log it and exit. + if (message instanceof Error && sendToParent) { + axe.log(message); + return false; } -} -// added for testing so we can fire subscriber events without having -// to mock the universe going through `respondable()` -respondable._publish = publish; + return !!callback && isNewMessage(messageId); +} /** - * Convert a javascript Error into something that can be stringified - * @param {Error} error Any type of error - * @return {Object} Processable object + * Run the callback, including handling any errors that might come doing so + * @param {window} win + * @param {object} messageData */ -function buildErrorObject(error) { - var msg = error.message || 'Unknown error occurred'; - var errorName = errorTypes.includes(error.name) ? error.name : 'Error'; - var ErrConstructor = window[errorName] || Error; - - if (error.stack) { - msg += '\n' + error.stack.replace(error.message, ''); +function runCallback( + win, + { channelId, message, keepalive, sendToParent, callback } +) { + try { + // Only accept messages from parent or child frames + sendToParent ? assertIsParentWindow(win) : assertIsFrameWindow(win); + const responder = createResponder(win, channelId, sendToParent); + callback(message, keepalive, responder); + } catch (error) { + processError(win, error, channelId, sendToParent); } - return new ErrConstructor(msg); } /** - * Parse the received message for processing - * @param {string} dataString Message received - * @return {object} Object to be used for pub/sub + * Log, or post an error to the parent window + * @param {window} win + * @param {object} messageData */ -function parseMessage(dataString) { - /*eslint no-empty: 0*/ - var data; - if (typeof dataString !== 'string') { - return; +function processError(win, error, channelId, sendToParent) { + if (!sendToParent) { + return axe.log(error); } try { - data = JSON.parse(dataString); - } catch (ex) {} - - if (!verify(data)) { - return; + post(win, { + topic: null, + channelId, + message: error, + messageId: createMessageId(), + keepalive: true, + sendToParent + }); + } catch (err) { + // Last resort, logging + return axe.log(err); } - - if (typeof data.error === 'object') { - data.error = buildErrorObject(data.error); - } else { - data.error = undefined; - } - return data; } -if (typeof window.addEventListener === 'function') { - window.addEventListener( - 'message', - e => { - var data = parseMessage(e.data); - if (!data || !data._axeuuid) { - return; - } - - var uuid = data.uuid; - - /** - * NOTE: messages from other contexts (frames) in response - * to a message should not contain the same axe._uuid. We - * ignore these messages to prevent rogue postMessage - * handlers reflecting our messages. - * @see https://github.com/dequelabs/axe-core/issues/1754 - */ - var axeRespondables = cache.get('axeRespondables') || {}; - if (axeRespondables[uuid] && data._axeuuid === axe._uuid) { - return; - } - - var keepalive = data._keepalive; - var callback = messages[uuid]; - - if (callback) { - var result = data.error || data.message; - var responder = createResponder(e.source, data.topic, uuid); - callback(result, keepalive, responder); - - if (!keepalive) { - delete messages[uuid]; - } - } - - if (!data.error) { - try { - publish(e.source, data, keepalive); - } catch (err) { - post(e.source, null, err, uuid, false); - } - } - }, - false - ); +/** + * Helper closure to create a function that may be used to respond to a message + * @private + * @param {Window} win The window from which the message originated + * @param {String} channelId The "unique" ID of the original message + * @return {Function} A function that may be invoked to respond to the message + */ +function createResponder(win, channelId, sendToParent) { + return function respond(message, keepalive, callback) { + if (typeof callback === 'function') { + storeCallback({ channelId }, callback, sendToParent); + } + post(win, { + topic: null, + channelId, + message, + messageId: createMessageId(), + keepalive: !!keepalive, + sendToParent + }); + }; } - -export default respondable; diff --git a/lib/core/utils/respondable/assert-window.js b/lib/core/utils/respondable/assert-window.js new file mode 100644 index 0000000000..8e767d0d51 --- /dev/null +++ b/lib/core/utils/respondable/assert-window.js @@ -0,0 +1,21 @@ +import assert from '../assert'; + +export function assertIsParentWindow(win) { + assetNotGlobalWindow(win); + assert( + window.parent === win, + 'Source of the response must be the parent window.' + ); +} + +export function assertIsFrameWindow(win) { + assetNotGlobalWindow(win); + const frames = Array.from(window.frames); + if (!frames.some(frame => frame === win)) { + throw new Error('Respondable target must be a frame in the current window'); + } +} + +export function assetNotGlobalWindow(win) { + assert(window !== win, 'Messages can not be sent to the same window.'); +} diff --git a/lib/core/utils/respondable/callback-store.js b/lib/core/utils/respondable/callback-store.js new file mode 100644 index 0000000000..68a26ee714 --- /dev/null +++ b/lib/core/utils/respondable/callback-store.js @@ -0,0 +1,36 @@ +import assert from '../assert'; +const subscribers = {}; +const channels = {}; + +export function storeCallback( + { topic, channelId }, + callback, + sendToParent = true +) { + if (topic) { + assert(!subscribers[topic], `Topic ${topic} is already registered to.`); + subscribers[topic] = { callback, sendToParent }; + } else if (channelId) { + assert( + !channels[channelId], + `A callback already exists for this message channel.` + ); + channels[channelId] = { callback, sendToParent }; + } +} + +export function getCallback({ topic, channelId }) { + if (topic) { + return subscribers[topic]; + } else if (channelId) { + return channels[channelId]; + } +} + +export function deleteCallback({ topic, channelId }) { + if (topic) { + delete subscribers[topic]; + } else if (channelId) { + delete channels[channelId]; + } +} diff --git a/lib/core/utils/respondable/message-id.js b/lib/core/utils/respondable/message-id.js new file mode 100644 index 0000000000..0766e1b36d --- /dev/null +++ b/lib/core/utils/respondable/message-id.js @@ -0,0 +1,22 @@ +import { v4 as createUuid } from '../uuid'; + +const messageIds = []; + +export function createMessageId() { + const uuid = `${createUuid()}:${createUuid()}`; + // Prevent repeats + if (messageIds.includes(uuid)) { + return createMessageId(); + } + + messageIds.push(uuid); + return uuid; +} + +export function isNewMessage(uuid) { + if (messageIds.includes(uuid)) { + return false; + } + messageIds.push(uuid); + return true; +} diff --git a/lib/core/utils/respondable/message-parser.js b/lib/core/utils/respondable/message-parser.js new file mode 100644 index 0000000000..306a95aa46 --- /dev/null +++ b/lib/core/utils/respondable/message-parser.js @@ -0,0 +1,109 @@ +const errorTypes = Object.freeze([ + 'EvalError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'TypeError', + 'URIError' +]); + +export function stringifyMessage({ + topic, + channelId, + message, + messageId, + keepalive +}) { + const data = { + channelId, + topic, + messageId, + keepalive, + source: getSource() + }; + + if (message instanceof Error) { + data.error = { + name: message.name, + message: message.message, + stack: message.stack + }; + } else { + data.payload = message; + } + return JSON.stringify(data); +} + +/** + * Parse the received message for processing + * @param {string} dataString Message received + * @return {object} Object to be used for pub/sub + */ +export function parseMessage(dataString) { + let data; + try { + data = JSON.parse(dataString); + } catch (e) { + /* ignored */ + } + if (!isRespondableMessage(data)) { + return; + } + + const { topic, channelId, messageId, keepalive } = data; + const message = + typeof data.error === 'object' + ? buildErrorObject(data.error) + : data.payload; + + return { topic, message, messageId, channelId, keepalive }; +} + +/** + * Verify the received message is from the "respondable" module + * @private + * @param {Object} postedMessage The message received via postMessage + * @return {Boolean} `true` if the message is verified from respondable + */ +function isRespondableMessage(postedMessage) { + return ( + typeof postedMessage === 'object' && + typeof postedMessage.channelId === 'string' && + postedMessage.source === getSource() + ); +} + +/** + * Convert a javascript Error into something that can be stringified + * @param {Error} error Any type of error + * @return {Object} Processable object + */ +function buildErrorObject(error) { + let msg = error.message || 'Unknown error occurred'; + const errorName = errorTypes.includes(error.name) ? error.name : 'Error'; + const ErrConstructor = window[errorName] || Error; + + if (error.stack) { + msg += '\n' + error.stack.replace(error.message, ''); + } + return new ErrConstructor(msg); +} + +/** + * get the unique string to be used to identify our instance of axe + * @private + */ +function getSource() { + let application = 'axeAPI'; + let version = ''; + + // TODO: es-modules_audit + if (typeof axe !== 'undefined' && axe._audit && axe._audit.application) { + application = axe._audit.application; + } + if (typeof axe !== 'undefined') { + // TODO: es-modules-version + version = axe.version; + } + return application + '.' + version; +} diff --git a/lib/core/utils/respondable/post.js b/lib/core/utils/respondable/post.js new file mode 100644 index 0000000000..fd4c9f1573 --- /dev/null +++ b/lib/core/utils/respondable/post.js @@ -0,0 +1,32 @@ +import { stringifyMessage } from './message-parser'; +import { assertIsParentWindow, assertIsFrameWindow } from './assert-window'; + +/** + * Posts the message to correct frame. + * This abstraction necessary because IE9 & 10 do not support posting Objects; only strings + * @private + * @param {Window} win The `window` to post the message to + * @param {String} topic The topic of the message + * @param {Object} message The message content + * @param {String} uuid The UUID, or pseudo-unique ID of the message + * @param {Boolean} keepalive Whether to allow multiple responses - default is false + */ +export function post( + win, + { topic, message, messageId, channelId, keepalive, sendToParent } +) { + // Prevent messaging to an inappropriate window + sendToParent ? assertIsParentWindow(win) : assertIsFrameWindow(win); + if (message instanceof Error && !sendToParent) { + return axe.log(message); + } + // console.log({ topic, message, messageId, channelId, keepalive }) + const dataString = stringifyMessage({ + topic, + message, + messageId, + channelId, + keepalive + }); + win.postMessage(dataString, '*'); +} diff --git a/lib/core/utils/uuid.js b/lib/core/utils/uuid.js index de8a9eb66e..99484d4bd5 100644 --- a/lib/core/utils/uuid.js +++ b/lib/core/utils/uuid.js @@ -23,6 +23,15 @@ if (!_rng && _crypto && _crypto.getRandomValues) { }; } +try { + if (!_rng && require) { + const nodeCrypto = require('crypto'); + _rng = () => nodeCrypto.randomBytes(16); + } +} catch (e) { + /* do nothing */ +} + if (!_rng) { // Math.random()-based (RNG) // diff --git a/package-lock.json b/package-lock.json index b85779e206..7f1746189b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "node-notifier": "^8.0.1", "npm-run-all": "^4.1.5", "prettier": "^1.17.1", + "proxyquire": "^2.1.3", "revalidator": "~0.3.1", "selenium-webdriver": "~3.6.0", "sinon": "^7.5.0", @@ -4955,6 +4956,19 @@ "node": ">=4" } }, + "node_modules/fill-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", + "integrity": "sha1-mo+jb06K1jTjv2tPPIiCVRRS6yA=", + "dev": true, + "dependencies": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -6998,6 +7012,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-observable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-1.1.0.tgz", @@ -8773,6 +8796,12 @@ "node": ">=10" } }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", + "dev": true + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9195,6 +9224,12 @@ "node": ">=0.10.0" } }, + "node_modules/module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=", + "dev": true + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -10219,6 +10254,17 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true }, + "node_modules/proxyquire": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", + "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", + "dev": true, + "dependencies": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" + } + }, "node_modules/ps-tree": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", @@ -17402,6 +17448,16 @@ "flat-cache": "^2.0.1" } }, + "fill-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", + "integrity": "sha1-mo+jb06K1jTjv2tPPIiCVRRS6yA=", + "dev": true, + "requires": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -19025,6 +19081,12 @@ "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", "dev": true }, + "is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "dev": true + }, "is-observable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-1.1.0.tgz", @@ -20477,6 +20539,12 @@ "yargs-parser": "^20.2.3" } }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", + "dev": true + }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -20812,6 +20880,12 @@ "integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==", "dev": true }, + "module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -21631,6 +21705,17 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true }, + "proxyquire": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", + "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", + "dev": true, + "requires": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" + } + }, "ps-tree": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", diff --git a/package.json b/package.json index aebbfd0fa6..25eb43f707 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "node-notifier": "^8.0.1", "npm-run-all": "^4.1.5", "prettier": "^1.17.1", + "proxyquire": "^2.1.3", "revalidator": "~0.3.1", "selenium-webdriver": "~3.6.0", "sinon": "^7.5.0", diff --git a/test/checks/media/frame-tested.js b/test/checks/media/frame-tested.js index 5f40e6dec6..70f32c9da4 100644 --- a/test/checks/media/frame-tested.js +++ b/test/checks/media/frame-tested.js @@ -18,7 +18,7 @@ describe('frame-tested', function() { }); it('passes if the iframe contains axe-core', function(done) { - iframe.src = '/test/mock/frames/responder.html'; + iframe.src = '/test/mock/frames/test.html'; iframe.addEventListener('load', function() { checkContext._onAsync = function(result) { assert.isTrue(result); diff --git a/test/core/public/cleanup.js b/test/core/public/cleanup.js index 0ed75702f5..dc4c348b1a 100644 --- a/test/core/public/cleanup.js +++ b/test/core/public/cleanup.js @@ -5,7 +5,7 @@ describe('cleanup', function() { function createFrames(callback) { var frame; frame = document.createElement('iframe'); - frame.src = '../mock/frames/responder.html'; + frame.src = '../mock/frames/test.html'; frame.addEventListener('load', function() { setTimeout(callback, 500); }); @@ -93,7 +93,7 @@ describe('cleanup', function() { win.addEventListener('message', function(message) { var data = JSON.parse(message.data); if (data.topic === 'axe.start') { - assert.deepEqual(data.message, { command: 'cleanup-plugin' }); + assert.deepEqual(data.payload, { command: 'cleanup-plugin' }); done(); } }); diff --git a/test/core/public/load.js b/test/core/public/load.js index f3ddf55512..b68b8c3db4 100644 --- a/test/core/public/load.js +++ b/test/core/public/load.js @@ -1,7 +1,8 @@ describe('axe._load', function() { - 'use strict'; + var fixture = document.querySelector('#fixture'); + var captureError = axe.testUtils.captureError; + var isIE11 = axe.testUtils.isIE11; - // var Rule = axe._thisWillBeDeletedDoNotUse.base.Rule; afterEach(function() { axe._audit = null; }); @@ -31,23 +32,34 @@ describe('axe._load', function() { }); describe('respondable subscriber', function() { - it('should add a respondable subscriber for axe.ping', function(done) { - var mockAudit = { - rules: [{ id: 'monkeys' }, { id: 'bananas' }] - }; + (isIE11 ? it.skip : it)( + // In IE win.parent is read-only + 'should add a respondable subscriber for axe.ping', + function(done) { + var winParent = window.parent; + var mockAudit = { + rules: [{ id: 'monkeys' }, { id: 'bananas' }] + }; - axe._load(mockAudit); + axe._load(mockAudit); - var win = { - postMessage: function(message) { - var data = JSON.parse(message); - assert.deepEqual(data.message, { axe: true }); - done(); - } - }; + var frame = document.createElement('iframe'); + frame.src = '../mock/frames/test.html'; + frame.addEventListener('load', function() { + var win = frame.contentWindow; + window.parent = win; + win.postMessage = captureError(function(message) { + var data = JSON.parse(message); + assert.deepEqual(data.payload, { axe: true }); + window.parent = winParent; + done(); + }, done); + axe.utils.respondable(win, 'axe.ping', { axe: true }); + }); - axe.utils.respondable._publish(win, { topic: 'axe.ping' }); - }); + fixture.appendChild(frame); + } + ); describe('given command rules', function() { // todo: see issue - https://github.com/dequelabs/axe-core/issues/2168 diff --git a/test/core/public/run.js b/test/core/public/run.js index 8537365b0e..a6ebc06486 100644 --- a/test/core/public/run.js +++ b/test/core/public/run.js @@ -392,6 +392,9 @@ describe('axe.run iframes', function() { var fixture = document.getElementById('fixture'); var origRunRules = axe._runRules; + var captureError = axe.testUtils.captureError; + + this.timeout(1000); beforeEach(function() { fixture.innerHTML = '
Target in top frame
'; @@ -422,37 +425,33 @@ describe('axe.run iframes', function() { it('includes iframes by default', function(done) { var frame = document.createElement('iframe'); - frame.addEventListener('load', function() { - var safetyTimeout = window.setTimeout(function() { - done(); - }, 1000); - - axe.run('#fixture', {}, function(err, result) { - assert.equal(result.violations.length, 1); - var violation = result.violations[0]; - assert.equal( - violation.nodes.length, - 2, - 'one node for top frame, one for iframe' - ); - assert.isTrue( - violation.nodes.some(function(node) { - return node.target.length === 1 && node.target[0] === '#target'; - }), - 'one result from top frame' - ); - assert.isTrue( - violation.nodes.some(function(node) { - return ( - node.target.length === 2 && node.target[0] === '#fixture > iframe' - ); - }), - 'one result from iframe' - ); - window.clearTimeout(safetyTimeout); - done(); - }); + axe.run( + '#fixture', + {}, + captureError(function(err, result) { + assert.equal(result.violations.length, 1); + var violation = result.violations[0]; + assert.equal( + violation.nodes.length, + 2, + 'one node for top frame, one for iframe' + ); + assert.isTrue( + violation.nodes.some(function(node) { + return node.target.length === 1 && node.target[0] === '#target'; + }), + 'one result from top frame' + ); + assert.isTrue( + violation.nodes.some(function(node) { + return node.target.length === 2 && node.target[0] === 'iframe'; + }), + 'one result from iframe' + ); + done(); + }, done) + ); }); frame.src = '../mock/frames/test.html'; @@ -461,21 +460,19 @@ describe('axe.run iframes', function() { it('excludes iframes if iframes is false', function(done) { var frame = document.createElement('iframe'); - frame.addEventListener('load', function() { - var safetyTimeout = setTimeout(function() { - done(); - }, 1000); - - axe.run('#fixture', { iframes: false }, function(err, result) { - assert.equal(result.violations.length, 1); - var violation = result.violations[0]; - assert.equal(violation.nodes.length, 1, 'only top frame'); - assert.equal(violation.nodes[0].target.length, 1); - assert.equal(violation.nodes[0].target[0], '#target'); - window.clearTimeout(safetyTimeout); - done(); - }); + axe.run( + '#fixture', + { iframes: false }, + captureError(function(err, result) { + assert.equal(result.violations.length, 1); + var violation = result.violations[0]; + assert.equal(violation.nodes.length, 1, 'only top frame'); + assert.equal(violation.nodes[0].target.length, 1); + assert.equal(violation.nodes[0].target[0], '#target'); + done(); + }, done) + ); }); frame.src = '../mock/frames/test.html'; @@ -484,18 +481,16 @@ describe('axe.run iframes', function() { it('ignores unexpected messages from non-axe iframes', function(done) { var frame = document.createElement('iframe'); - frame.addEventListener('load', function() { - var safetyTimeout = window.setTimeout(function() { - done('timeout'); - }, 1000); - - axe.run('#fixture', {}, function(err, result) { - assert.isNull(err); - assert.equal(result.violations.length, 1); - window.clearTimeout(safetyTimeout); - done(); - }); + axe.run( + '#fixture', + {}, + captureError(function(err, result) { + assert.isNull(err); + assert.equal(result.violations.length, 1); + done(); + }, done) + ); }); frame.src = '../mock/frames/with-echo.html'; @@ -506,18 +501,15 @@ describe('axe.run iframes', function() { var frame = document.createElement('iframe'); frame.addEventListener('load', function() { - var safetyTimeout = window.setTimeout(function() { - done('timeout'); - }, 1000); - if (!axe._audit) { - throw new Error('no _audit'); - } - axe.run('#fixture', {}, function(err, result) { - assert.isNull(err); - assert.equal(result.violations.length, 1); - window.clearTimeout(safetyTimeout); - done(); - }); + axe.run( + '#fixture', + {}, + captureError(function(err, result) { + assert.isNull(err); + assert.equal(result.violations.length, 1); + done(); + }, done) + ); }); frame.src = '../mock/frames/with-echo-axe.html'; diff --git a/test/core/utils/collect-results-from-frames.js b/test/core/utils/collect-results-from-frames.js index 45a151c3e6..a71d86aacc 100644 --- a/test/core/utils/collect-results-from-frames.js +++ b/test/core/utils/collect-results-from-frames.js @@ -143,9 +143,11 @@ describe('axe.utils.collectResultsFromFrames', function() { axe.utils.collectResultsFromFrames( context, {}, - 'command', - 'params', - noop, + 'rules', + 'morestuff', + function() { + done(new Error('Should not be called')); + }, function(err) { assert.instanceOf(err, Error); assert.equal(err.message.split(/\n/)[0], 'error in axe.throw'); diff --git a/test/core/utils/respondable.js b/test/core/utils/respondable.js index 30b95067e2..12fecdf41e 100644 --- a/test/core/utils/respondable.js +++ b/test/core/utils/respondable.js @@ -1,515 +1,562 @@ +function afterMessage(win, callback) { + var handler = function() { + win.removeEventListener('message', handler); + // Wait one more tick for stuff to resolve + setTimeout(function() { + callback(); + }, 10); + }; + win.addEventListener('message', handler); +} + +function once(callback) { + var called = false; + return function() { + if (!called) { + callback.apply(this, arguments); + } + called = true; + }; +} + describe('axe.utils.respondable', function() { - 'use strict'; + var fixture, + axeVersion, + axeApplication, + frame, + frameWin, + respondable, + frameSubscribe, + axeLog; + var postMessage = window.postMessage; + var captureError = axe.testUtils.captureError; + var isIE11 = axe.testUtils.isIE11; + this.timeout(1000); + + beforeEach(function(done) { + respondable = axe.utils.respondable; + axeVersion = axe.version; + axeLog = axe.log; + axeApplication = axe._audit.application; + + frame = document.createElement('iframe'); + frame.src = '../mock/frames/test.html'; + frame.addEventListener('load', function() { + frameWin = frame.contentWindow; + frameSubscribe = frameWin.axe.utils.respondable.subscribe; + done(); + }); - it('should be a function', function() { - assert.isFunction(axe.utils.respondable); + fixture = document.querySelector('#fixture'); + fixture.appendChild(frame); }); - it('should accept 5 parameters', function() { - assert.lengthOf(axe.utils.respondable, 5); + afterEach(function() { + fixture.innerHTML = ''; + axe.version = axeVersion; + axe._audit.application = axeApplication; + axe.log = axeLog; + window.postMessage = postMessage; }); - it('should call `postMessage` on first parameter', function() { - var success = false; - var win = { - postMessage: function() { - success = true; - } - }; - - axe.utils.respondable(win, 'batman', 'nananana'); - assert.isTrue(success); + it('can be subscribed to', function(done) { + frameSubscribe('greeting', function() { + done(); + }); + respondable(frameWin, 'greeting', 'hello'); }); - it('should stringify message', function(done) { - var win = { - postMessage: function(msg) { - assert.isString(msg); + it('forwards the message', function(done) { + var expected = { hello: 'world' }; + frameSubscribe( + 'greeting', + captureError(function(actual) { + assert.deepEqual(actual, expected); done(); - } - }; - - axe.utils.respondable(win, 'batman', { derp: true }); + }, done) + ); + respondable(frameWin, 'greeting', expected); }); - it('should add the `topic` and `message` the message', function(done) { - var win = { - postMessage: function(msg) { - msg = JSON.parse(msg); - - assert.equal(msg.topic, 'batman'); - assert.isTrue(msg._respondable); + it('passes a truthy keepalive value', function(done) { + frameSubscribe( + 'greeting', + captureError(function(_, keepalive) { + assert.isTrue(keepalive); done(); - } - }; - - axe.utils.respondable(win, 'batman', 'nananana'); + }, done) + ); + respondable(frameWin, 'greeting', 'hello', 'truthy'); }); - it('should add the `keepalive`', function(done) { - var win = { - postMessage: function(msg) { - msg = JSON.parse(msg); - - assert.equal(msg._keepalive, 'batman'); + it('passes a falsy keepalive value', function(done) { + frameSubscribe( + 'greeting', + captureError(function(_, keepalive) { + assert.isFalse(keepalive); done(); - } - }; + }, done) + ); + respondable(frameWin, 'greeting', 'hello', 0); + }); - axe.utils.respondable(win, 'superman', 'spidey', 'batman'); + it('can not publish to a parent frame', function(done) { + var isCalled = false; + axe.utils.respondable.subscribe('greeting', function() { + isCalled = true; + }); + assert.throws(function() { + frameWin.axe.utils.respondable(window, 'greeting', 'hello', 0); + }); + setTimeout( + captureError(function() { + assert.isFalse(isCalled); + done(); + }, done), + 100 + ); }); - it('should add `_respondable` to the message', function(done) { - var win = { - postMessage: function(msg) { - msg = JSON.parse(msg); + it('does not expose private methods', function() { + var methods = Object.keys(respondable).sort(); + assert.deepEqual(methods, ['subscribe', 'isInFrame'].sort()); + }); - assert.equal(msg._respondable, true); + it('passes serialized information only', function(done) { + var div = document.createElement('div'); + frameSubscribe( + 'greeting', + captureError(function(message) { + assert.deepEqual(message, {}); done(); - } - }; + }, done) + ); - axe.utils.respondable(win, 'batman', 'nananana'); + respondable(frameWin, 'greeting', div); }); - describe('messageHandler', function() { - var event = document.createEvent('Event'); - // Define that the event name is 'build'. - event.initEvent('message', true, true); - event.source = window; - - var eventData; - var win; - var axeVersion; - - beforeEach(function() { - axeVersion = axe.version; - win = { - postMessage: function(message) { - var data = JSON.parse(message); - eventData.uuid = data.uuid; - event.data = JSON.stringify(eventData); - document.dispatchEvent(event); - } - }; - }); - - afterEach(function() { - axe.version = axeVersion; + describe('subscribe', function() { + it('is called with the same topic', function(done) { + var called = false; + frameSubscribe('greeting', function() { + called = true; + }); + respondable(frameWin, 'greeting'); + afterMessage( + frameWin, + captureError(function() { + assert.isTrue(called); + done(); + }, done) + ); }); - it('should pass messages that have all required properties', function(done) { - eventData = { - _respondable: true, - _source: 'axeAPI.' + axe.version, - message: 'Help us Obi-Wan', - _axeuuid: 'otherAxe' - }; - - axe.utils.respondable(win, 'Death star', null, true, function(data) { - assert.equal(data, 'Help us Obi-Wan'); - done(); + it('is not called on a different topic', function(done) { + var called = false; + frameSubscribe('otherTopic', function() { + called = true; }); + respondable(frameWin, 'greeting'); + afterMessage( + frameWin, + captureError(function() { + assert.isFalse(called); + done(); + }, done) + ); }); - it('should reject messages if the axe version is different', function(done) { + it('is not called for different axe-core versions', function(done) { + var called = false; axe.version = '1.0.0'; - eventData = { - _respondable: true, - _source: 'axeAPI.2.0.0', - message: 'Help us Obi-Wan', - _axeuuid: 'otherAxe' - }; - - axe.utils.respondable(win, 'Death star', null, true, function() { - done(new Error('should not call callback')); + frameSubscribe('greeting', function() { + called = true; }); - - setTimeout(function() { - done(); - }, 100); + respondable(frameWin, 'greeting'); + afterMessage( + frameWin, + captureError(function() { + assert.isFalse(called); + done(); + }, done) + ); }); - it('should reject messages if the axe version is x.y.z', function(done) { - eventData = { - _respondable: true, - _source: 'axeAPI.x.y.z', - message: 'Help us Obi-Wan', - _axeuuid: 'otherAxe' - }; - - axe.utils.respondable(win, 'Death star', null, true, function() { - done(new Error('should not call callback')); + it('is not called with the "x.y.z" wildcard', function(done) { + var called = false; + axe.version = 'x.y.z'; + frameSubscribe('greeting', function() { + called = true; }); - - setTimeout(function() { - done(); - }, 100); + respondable(frameWin, 'greeting'); + afterMessage( + frameWin, + captureError(function() { + assert.isFalse(called); + done(); + }, done) + ); }); - it('should reject messages that are that are not strings', function(done) { - eventData = { - _respondable: true, - _source: 'axeAPI.' + axe.version, - message: 'Help us Obi-Wan', - _axeuuid: 'otherAxe' - }; - - win = { - postMessage: function(message) { - var data = JSON.parse(message); - eventData.uuid = data.uuid; - event.data = eventData; - document.dispatchEvent(event); - } - }; - - axe.utils.respondable(win, 'Death star', null, true, function() { - done(new Error('should not call callback')); + it('is not called for different applications', function(done) { + var called = false; + axe._audit.application = 'Coconut'; + frameSubscribe('greeting', function() { + called = true; }); + respondable(frameWin, 'greeting'); + afterMessage( + frameWin, + captureError(function() { + assert.isFalse(called); + done(); + }, done) + ); + }); - setTimeout(function() { + it('logs errors passed to respondable, rather than passing them on', function(done) { + axe.log = captureError(function(e) { + assert.equal(e.message, 'expected message'); done(); - }, 100); - }); + }, done); - it('should reject messages that are invalid stringified objects', function(done) { - eventData = { - _respondable: true, - _source: 'axeAPI.' + axe.version, - message: 'Help us Obi-Wan', - _axeuuid: 'otherAxe' - }; + frameSubscribe('greeting', function() { + done(new Error('subscribe should not be called')); + }); + respondable(frameWin, 'greeting', new Error('expected message')); + }); - win = { - postMessage: function(message) { - var data = JSON.parse(message); - eventData.uuid = data.uuid; - event.data = JSON.stringify(eventData) + 'joker tricks!'; - document.dispatchEvent(event); - } - }; + (isIE11 ? it.skip : it)( + // In IE win.parent is read-only + 'is not called when the source is not a frame in the page', + function(done) { + var doneOnce = once(done); + var called = false; + frameWin.axe.log = function() { + called = true; + }; + + frameSubscribe('greeting', function() { + doneOnce(new Error('subscribe should not be called')); + }); + frameWin.parent = frameWin; + respondable(frameWin, 'greeting'); + setTimeout( + captureError(function() { + assert.isTrue(called); + doneOnce(); + }, doneOnce), + 100 + ); + } + ); - axe.utils.respondable(win, 'Death star', null, true, function() { - done(new Error('should not call callback')); + it('throws when targeting itself', function() { + assert.throws(function() { + respondable(window, 'greeting'); }); + assert.throws(function() { + frameWin.respondable(frameWin, 'greeting'); + }); + }); + it('throws when targeting a window that is not a frame in the page', function() { + var blankPage = window.open(''); + var frameCopy = window.open(frameWin.location.href); + // Cleanup setTimeout(function() { - done(); - }, 100); + blankPage.close(); + frameCopy.close(); + }); + + assert.throws(function() { + respondable(blankPage, 'greeting'); + }); + assert.throws(function() { + respondable(frameCopy, 'greeting'); + }); }); - it('should reject messages that do not have a uuid', function(done) { - eventData = { - _respondable: true, - _source: 'axeAPI.' + axe.version, - message: 'Help us Obi-Wan', - _axeuuid: 'otherAxe' - }; + it('is not triggered by "repeaters"', function(done) { + var calls = 0; + frameSubscribe('greeting', function() { + calls++; + }); + // Repeat fire the event + frameWin.addEventListener('message', function handler(evt) { + frameWin.postMessage(evt.data, '*'); + frameWin.removeEventListener('message', handler); + }); - win = { - postMessage: function() { - event.data = JSON.stringify(eventData); - document.dispatchEvent(event); - } - }; + respondable(frameWin, 'greeting', 'hello'); + setTimeout( + captureError(function() { + assert.equal(calls, 1); + done(); + }, done), + 100 + ); + }); + }); - axe.utils.respondable(win, 'Death star', null, true, function() { - done(new Error('should not call callback')); + describe('respond', function() { + it('passes the response back', function(done) { + var receivedResponse; + frameSubscribe( + 'greeting', + captureError(function(message, keepalive, respond) { + assert.isFalse(keepalive); + respond({ greet: 'bonjour' }); + }, done) + ); + + respondable(frameWin, 'greeting', 'hello', false, function(message) { + receivedResponse = message; }); - setTimeout(function() { - done(); - }, 100); + afterMessage( + window, + captureError(function() { + assert.deepEqual(receivedResponse, { greet: 'bonjour' }); + done(); + }, done) + ); }); - it('should reject messages that do not have a matching uuid', function(done) { - eventData = { - _respondable: true, - _source: 'axeAPI.' + axe.version, - message: 'Help us Obi-Wan', - _axeuuid: 'otherAxe' - }; - - win = { - postMessage: function(message) { - var data = JSON.parse(message); - eventData.uuid = data.uuid + 'joker tricks!'; - event.data = JSON.stringify(eventData); - document.dispatchEvent(event); - } - }; + it('prohibits multiple response calls when respond sets keepalive to false', function(done) { + var receivedResponse; + frameSubscribe('greeting', function(message, keepalive, respond) { + assert.isTrue(keepalive); + respond({ responseNum: 1 }, false); + setTimeout(function() { + respond({ responseNum: 2 }); + }, 10); + }); - axe.utils.respondable(win, 'Death star', null, true, function() { - done(new Error('should not call callback')); + respondable(frameWin, 'greeting', '', true, function(message) { + receivedResponse = message; }); - setTimeout(function() { - done(); - }, 100); + afterMessage(window, function() { + assert.deepEqual(receivedResponse, { responseNum: 1 }); + setTimeout(function() { + assert.deepEqual(receivedResponse, { responseNum: 1 }); + done(); + }, 100); + }); }); - it('should reject messages that do not have `_respondable: true`', function(done) { - eventData = { - _source: 'axeAPI.' + axe.version, - message: 'Help us Obi-Wan', - _axeuuid: 'otherAxe' - }; + it('allows multiple response calls with keepalive: true', function(done) { + var receivedResponse; + frameSubscribe('greeting', function(message, keepalive, respond) { + assert.isTrue(keepalive); + respond({ responseNum: 1 }, true); + setTimeout(function() { + respond({ responseNum: 2 }); + }, 30); + }); - axe.utils.respondable(win, 'Death star', null, true, function() { - done(new Error('should not call callback')); + respondable(frameWin, 'greeting', '', true, function(message) { + receivedResponse = message; }); - setTimeout(function() { - done(); - }, 100); + afterMessage(window, function() { + assert.deepEqual(receivedResponse, { responseNum: 1 }); + afterMessage(window, function() { + assert.deepEqual(receivedResponse, { responseNum: 2 }); + done(); + }); + }); }); - it('should reject messages that do not have `_axeuuid`', function(done) { - eventData = { - _respondable: true, - _source: 'axeAPI.' + axe.version, - message: 'Help us Obi-Wan' - }; - - axe.utils.respondable(win, 'Death star', null, true, function() { - done(new Error('should not call callback')); + it('responds until after keepalive: false is called', function(done) { + var concat = ''; + frameSubscribe('greeting', function(_, keepalive, respond) { + respond('1', true); + respond('2', true); + respond('3', false); + respond('4', true); + respond('5', false); }); + respondable(frameWin, 'greeting', '', true, function(result) { + concat += result; + }); setTimeout(function() { + assert.equal(concat, '123'); done(); - }, 100); + }, 200); }); - it('should reject messages from the same axe instance (`_axeuuid`)', function(done) { - eventData = { - _respondable: true, - _source: 'axeAPI.' + axe.version, - message: 'Help us Obi-Wan' - }; - - win = { - postMessage: function(message) { - var data = JSON.parse(message); - eventData.uuid = data.uuid; - eventData._axeuuid = data._axeuuid; - event.data = JSON.stringify(eventData); - document.dispatchEvent(event); - } - }; - - axe.utils.respondable(win, 'Death star', null, true, function() { - done(new Error('should not call callback')); + it('receives errors if the subscriber throws', function(done) { + var errorMessage = 'Something went wrong'; + frameSubscribe('greeting', function() { + throw new frameWin.TypeError(errorMessage); }); - setTimeout(function() { - done(); - }, 100); + respondable( + frameWin, + 'greeting', + '', + true, + captureError(function(result) { + assert.instanceOf(result, TypeError); + assert.equal(result.message.split(/\n/)[0], errorMessage); + done(); + }, done) + ); }); - it('should throw if an error message was send', function(done) { - eventData = { - _respondable: true, - _source: 'axeAPI.' + axe.version, - error: { - name: 'ReferenceError', - message: 'The exhaust port is open!', - trail: '... boom' - }, - _axeuuid: 'otherAxe' - }; + it('receives errors responded by the subscriber', function(done) { + var errorMessage = 'Something went wrong'; + frameSubscribe('greeting', function(data, keepalive, respond) { + respond(new frameWin.TypeError(errorMessage)); + }); - axe.utils.respondable(win, 'Death star', null, true, function(data) { - assert.instanceOf(data, ReferenceError); - assert.equal(data.message, 'The exhaust port is open!'); + respondable(frameWin, 'greeting', '', true, function(result) { + assert.instanceOf(result, TypeError); + assert.equal(result.message.split(/\n/)[0], errorMessage); done(); }); }); - it('should create an Error if an invalid error type is passed', function(done) { - window.evil = function() {}; - - eventData = { - _respondable: true, - _source: 'axeAPI.' + axe.version, - error: { - name: 'evil', - message: 'The exhaust port is open!', - trail: '... boom' - }, - _axeuuid: 'otherAxe' - }; - - axe.utils.respondable(win, 'Death star', null, true, function(data) { - assert.instanceOf(data, Error); - assert.equal(data.message, 'The exhaust port is open!'); - window.evil = undefined; - done(); - }); + it('can pass messages back to the subscriber (without triggering the subscriber)', function(done) { + frameSubscribe( + 'greeting', + captureError(function(message, _, respond) { + assert.equal(message, '1'); + respond('2', true, function(message) { + assert.equal(message, '3'); + done(); + }); + }, done) + ); + + respondable( + frameWin, + 'greeting', + '1', + false, + captureError(function(message, _, respond) { + assert.equal(message, '2'); + respond('3'); + }, done) + ); }); - }); - it('uses respondable.isInFrame() to check if the page is in a frame or not', function() { - assert.equal(axe.utils.respondable.isInFrame(), !!window.frameElement); + it('it errors if multiple callbacks are registered', function(done) { + var calledFirst = captureError(function(message, _, respond) { + respond('2a', true, calledThird); + assert.throws(function() { + respond('2b', true, function() { + done(new Error('Should never be called')); + }); + }); + }, done); + + var calledSecond = captureError(function(message, _, respond) { + assert.equal(message, '2a'); + respond('3a'); + }, done); + + var calledThird = captureError(function(message) { + assert.equal(message, '3a'); + setTimeout(function() { + done(); // No further messages received + }, 50); + }, done); - assert.isFalse( - axe.utils.respondable.isInFrame({ - frameElement: null - }) - ); - assert.isTrue( - axe.utils.respondable.isInFrame({ - frameElement: document.createElement('iframe') - }) - ); - }); + frameSubscribe('greeting', calledFirst); + respondable(frameWin, 'greeting', '1', false, calledSecond); + }); - describe('subscribe', function() { - var origAxeUUID = axe._uuid; - var counter = 0; - - before(function() { - // assign axe a new uuid every time it's requested to trick - // the code that each respondable was called from a different - // context - Object.defineProperty(axe, '_uuid', { - get: function() { - return ++counter; - } + it('logs errors in respondable callbacks', function(done) { + var doneOnce = once(done); + var logged = false; + axe.log = function(e) { + logged = true; + assert.equal(e.message, 'This should not go to the frame'); + }; + + frameSubscribe( + 'greeting', + captureError(function(message, _, respond) { + assert.equal(message, '1'); + + respond('2', true, function() { + doneOnce(new Error('should not call callback')); + }); + }, doneOnce) + ); + + respondable(frameWin, 'greeting', '1', false, function(message) { + assert.equal(message, '2'); + setTimeout( + captureError(function() { + assert.isTrue(logged); + doneOnce(); + }, done), + 100 + ); + + throw new Error('This should not go to the frame'); }); }); - after(function() { - Object.defineProperty(axe, '_uuid', { - value: origAxeUUID + it('is not called if the frame is not in the page', function(done) { + var receivedResponse; + frameSubscribe('greeting', function(message, keepalive, respond) { + respond({ greet: 'ola' }); }); - }); - it('should be a function', function() { - assert.isFunction(axe.utils.respondable.subscribe); - }); + respondable(frameWin, 'greeting', 'hello', false, function(message) { + receivedResponse = message; + fixture.innerHTML = ''; + }); - it('should receive messages', function(done) { - var expected = null; - axe.utils.respondable.subscribe('catman', function(data) { - assert.equal(data, expected); - if (data === 'yay') { - done(); - } - }); - axe.utils.respondable(window, 'catman', null, undefined, function( - data, - keepalive, - respond - ) { - assert.isNull(data); - setTimeout(function() { - respond('yay'); - expected = 'yay'; - }, 0); + afterMessage(window, function() { + assert.deepEqual(receivedResponse, { greet: 'ola' }); + done(); }); }); - it('should propagate the keepalive setting', function(done) { - var expected = null; - axe.utils.respondable.subscribe('catman', function(data, keepalive) { - assert.equal(keepalive, expected); - if (data === 'yayyay') { - done(); - } - }); - axe.utils.respondable(window, 'catman', null, undefined, function( - data, - keepalive, - respond - ) { - assert.isNull(data); - setTimeout(function() { - expected = 'keepy'; - respond('yayyay', expected); - }, 0); + it('is not triggered by "repeaters"', function(done) { + // Repeat fire the event + window.addEventListener('message', function handler(evt) { + frameWin.parent.postMessage(evt.data, '*'); + window.removeEventListener('message', handler); }); - }); - it('should allow multiple responses when keepalive', function(done) { - var expected = 2; - var called = 0; - axe.utils.respondable.subscribe('catman', function(data) { - if (data === 'yayyayyay') { - called += 1; - if (called === expected) { - done(); - } - } - }); - axe.utils.respondable(window, 'catman', null, undefined, function( - data, - keepalive, - respond - ) { - assert.isNull(data); - setTimeout(function() { - respond('yayyayyay', true); - }, 0); - setTimeout(function() { - respond('yayyayyay', true); - }, 100); + frameSubscribe('greeting', function(message, _, respond) { + respond('2', true); }); - }); - it('does not trigger for error messages', function(done) { - var published = false; - axe.utils.respondable.subscribe('catman', function() { - published = true; + var calls = 0; + respondable(frameWin, 'greeting', '1', false, function() { + calls++; }); - var err = new ReferenceError('whoopsy'); - axe.utils.respondable(window, 'catman', err); - setTimeout(function() { - assert.ok(!published, 'Error events should not trigger'); - done(); - }, 10); + setTimeout( + captureError(function() { + assert.equal(calls, 1); + done(); + }, done), + 100 + ); }); + }); - it('returns an error if the subscribe method responds with an error', function(done) { - var expected = 'Expected owlman to be batman'; - var wait = true; - axe.utils.respondable.subscribe('owlman', function( - data, - keepalive, - respond - ) { - wait = false; - respond(new TypeError(expected)); - }); - - axe.utils.respondable(window, 'owlman', 'help!', true, function(data) { - if (!wait) { - assert.instanceOf(data, TypeError); - assert.equal(data.message.split(/\n/)[0], expected); - done(); - } - }); + describe('isInFrame', function() { + it('is false for the page window', function() { + var frameRespondable = frameWin.axe.utils.respondable; + assert.isFalse(respondable.isInFrame()); + assert.isFalse(frameRespondable.isInFrame(window)); }); - it('returns an error if the subscribe method throws', function(done) { - var wait = true; - var expected = 'Expected owlman to be batman'; - axe.utils.respondable.subscribe('owlman', function() { - wait = false; - throw new TypeError(expected); - }); - - // use keepalive, because we're running on the same window, - // otherwise it would delete the response before subscribe - // gets to react - axe.utils.respondable(window, 'owlman', null, true, function(data) { - if (!wait) { - assert.instanceOf(data, TypeError); - assert.equal(data.message.split(/\n/)[0], expected); - done(); - } - }); + it('is true for iframes', function() { + var frameRespondable = frameWin.axe.utils.respondable; + assert.isTrue(frameRespondable.isInFrame()); + assert.isTrue(respondable.isInFrame(frameWin)); }); }); }); diff --git a/test/core/utils/send-command-to-frame.js b/test/core/utils/send-command-to-frame.js index b6f82d083c..107a843009 100644 --- a/test/core/utils/send-command-to-frame.js +++ b/test/core/utils/send-command-to-frame.js @@ -2,6 +2,8 @@ describe('axe.utils.sendCommandToFrame', function() { 'use strict'; var fixture = document.getElementById('fixture'); + var params = { command: 'rules' }; + var captureError = axe.testUtils.captureError; afterEach(function() { fixture.innerHTML = ''; @@ -13,6 +15,28 @@ describe('axe.utils.sendCommandToFrame', function() { assert.ok(false, 'should not be called'); }; + it('should return results from frames', function(done) { + var frame = document.createElement('iframe'); + frame.addEventListener('load', function() { + axe.utils.sendCommandToFrame( + frame, + params, + captureError(function(res) { + assert.lengthOf(res, 1); + assert.equal(res[0].id, 'html'); + done(); + }, done), + function() { + done(new Error('sendCommandToFrame should not error')); + } + ); + }); + + frame.id = 'level0'; + frame.src = '../mock/frames/test.html'; + fixture.appendChild(frame); + }); + it('should timeout if there is no response from frame', function(done) { var orig = window.setTimeout; window.setTimeout = function(fn, to) { @@ -31,7 +55,7 @@ describe('axe.utils.sendCommandToFrame', function() { axe._tree = axe.utils.getFlattenedTree(document.documentElement); axe.utils.sendCommandToFrame( frame, - {}, + params, function(result) { assert.equal(result, null); done(); @@ -45,82 +69,4 @@ describe('axe.utils.sendCommandToFrame', function() { frame.src = '../mock/frames/zombie-frame.html'; fixture.appendChild(frame); }); - - it('should respond once when no keepalive', function(done) { - var frame = document.createElement('iframe'); - frame.addEventListener('load', function() { - axe.utils.sendCommandToFrame( - frame, - { - number: 1 - }, - function() { - assert.isTrue(true); - done(); - }, - assertNotCalled - ); - }); - - frame.id = 'level0'; - frame.src = '../mock/frames/responder.html'; - fixture.appendChild(frame); - }); - - it('should respond multiple times when keepalive', function(done) { - var number = 3; - var called = 0; - var frame = document.createElement('iframe'); - frame.addEventListener('load', function() { - setTimeout(function() { - axe.utils.sendCommandToFrame( - frame, - { - number: number, - keepalive: true - }, - function() { - called += 1; - if (called === number) { - assert.isTrue(true); - done(); - } - }, - assertNotCalled - ); - }, 500); - }); - - frame.id = 'level0'; - frame.src = '../mock/frames/responder.html'; - fixture.appendChild(frame); - }); - - it('should respond once when no keepalive', function(done) { - var number = 1; - var called = 0; - var frame = document.createElement('iframe'); - frame.addEventListener('load', function() { - axe.utils.sendCommandToFrame( - frame, - { - number: number - }, - function() { - called += 1; - if (called === number) { - assert.isTrue(true); - done(); - } else { - throw new Error('should not have been called'); - } - }, - assertNotCalled - ); - }); - - frame.id = 'level0'; - frame.src = '../mock/frames/responder.html'; - fixture.appendChild(frame); - }); }); diff --git a/test/integration/full/configure-options/configure-options.js b/test/integration/full/configure-options/configure-options.js index 31780e32cb..2048aeb687 100644 --- a/test/integration/full/configure-options/configure-options.js +++ b/test/integration/full/configure-options/configure-options.js @@ -161,6 +161,7 @@ describe('Configure Options', function() { }); describe('noHtml', function() { + var captureError = axe.testUtils.captureError; it('prevents html property on nodes', function(done) { target.setAttribute('role', 'slider'); axe.configure({ @@ -180,19 +181,16 @@ describe('Configure Options', function() { values: ['aria-required-attr'] } }, - function(error, results) { - try { - assert.isNull(results.violations[0].nodes[0].html); - done(); - } catch (e) { - done(e); - } - } + captureError(function(error, results) { + assert.isNull(error); + assert.isNull(results.violations[0].nodes[0].html); + done(); + }, done) ); }); it('prevents html property on nodes from iframes', function(done) { - axe.configure({ + var config = { noHtml: true, rules: [ { @@ -202,11 +200,14 @@ describe('Configure Options', function() { selector: 'foo' } ] - }); + }; var iframe = document.createElement('iframe'); iframe.src = '/test/mock/frames/context.html'; iframe.onload = function() { + axe.configure(config); + iframe.contentWindow.axe.configure(config); + axe.run( '#target', { @@ -215,25 +216,22 @@ describe('Configure Options', function() { values: ['div#target'] } }, - function(error, results) { - try { - assert.deepEqual(results.passes[0].nodes[0].target, [ - 'iframe', - '#target' - ]); - assert.isNull(results.passes[0].nodes[0].html); - done(); - } catch (e) { - done(e); - } - } + captureError(function(error, results) { + assert.isNull(error); + assert.deepEqual(results.passes[0].nodes[0].target, [ + 'iframe', + '#target' + ]); + assert.isNull(results.passes[0].nodes[0].html); + done(); + }, done) ); }; target.appendChild(iframe); }); it('prevents html property in postMesage', function(done) { - axe.configure({ + var config = { noHtml: true, rules: [ { @@ -243,11 +241,14 @@ describe('Configure Options', function() { selector: 'foo' } ] - }); + }; var iframe = document.createElement('iframe'); iframe.src = '/test/mock/frames/noHtml-config.html'; iframe.onload = function() { + axe.configure(config); + iframe.contentWindow.axe.configure(config); + axe.run('#target', { runOnly: { type: 'rule', diff --git a/test/integration/full/umd/umd-module-exports.js b/test/integration/full/umd/umd-module-exports.js index 4974914db8..eb3dae3c53 100644 --- a/test/integration/full/umd/umd-module-exports.js +++ b/test/integration/full/umd/umd-module-exports.js @@ -7,13 +7,19 @@ describe('UMD module.export', function() { }); it('does not use `require` functions', function() { + var result; + var requireRegex = /[^.]require\(([^\)])\)/g; + // This is to avoid colliding with Cypress.js which overloads all // uses of variables named `require`. - assert.notMatch( - axe.source, - /[^.]require\(/, - 'Axe source should not contain `require` variables' - ); + while ((result = requireRegex.exec(axe.source)) !== null) { + // Allow 'crypto' as it is used in an unobtrusive way. + assert.includes( + result[1], + 'crypto', + 'Axe source should not contain `require` variables' + ); + } }); it('should include doT', function() { diff --git a/test/mock/frames/responder.html b/test/mock/frames/responder.html deleted file mode 100644 index 311d69459d..0000000000 --- a/test/mock/frames/responder.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - Double responding frame - - - - - - - - diff --git a/test/mock/frames/results-timeout.html b/test/mock/frames/results-timeout.html index 108b1530a6..58e3c7fae9 100644 --- a/test/mock/frames/results-timeout.html +++ b/test/mock/frames/results-timeout.html @@ -4,22 +4,6 @@ Message Iframe Fixture - - - diff --git a/test/mock/frames/throwing.html b/test/mock/frames/throwing.html index 9d4d23c5a3..ee35ec370f 100644 --- a/test/mock/frames/throwing.html +++ b/test/mock/frames/throwing.html @@ -4,27 +4,11 @@ Error returning frame frame - - diff --git a/test/node/uuid.js b/test/node/uuid.js new file mode 100644 index 0000000000..905a114722 --- /dev/null +++ b/test/node/uuid.js @@ -0,0 +1,19 @@ +var assert = require('chai').assert; +var sinon = require('sinon'); +var proxyquire = require('proxyquire'); +var crypto = require('crypto'); // Node package + +// 16 byte array, all 0's +var returnVal = new Array(16).fill(0); +var cryptoStub = sinon.stub(crypto, 'randomBytes').returns(returnVal); + +describe('uuid.v4', function() { + var axe = proxyquire('../../', { crypto: cryptoStub }); + var uuidV4 = axe.utils.uuid.v4; + + it('uses node crypto', function() { + var uuid = uuidV4(); + assert.isTrue(cryptoStub.randomBytes.called); + assert.deepEqual(uuid, '00000000-0000-4000-8000-000000000000'); + }); +}); diff --git a/test/testutils.js b/test/testutils.js index 99a5c5e2ea..4e7c45b7e6 100644 --- a/test/testutils.js +++ b/test/testutils.js @@ -454,3 +454,13 @@ afterEach(function() { // reset body styles document.body.removeAttribute('style'); }); + +testUtils.captureError = function captureError(cb, errorHandler) { + return function() { + try { + cb.apply(null, arguments); + } catch (e) { + errorHandler(e); + } + }; +};