axe-core
Version:
Accessibility engine for automated Web UI testing
253 lines (228 loc) • 6.89 kB
JavaScript
/**
* NOTE:
* this `eslint` rule is disabled because of calling `getStyleSheetFactory` before it is defined (further below).
*/
/* eslint no-use-before-define: 0 */
/**
* Given a rootNode - construct CSSOM
* -> get all source nodes (document & document fragments) within given root node
* -> recursively call `axe.utils.parseStylesheets` to resolve styles for each node
*
* @method preloadCssom
* @memberof `axe.utils`
* @param {Object} options composite options object
* @property {Array<String>} options.assets array of preloaded assets requested, eg: [`cssom`]
* @property {Number} options.timeout timeout
* @property {Object} options.treeRoot (optional) the DOM tree to be inspected
* @returns {Promise}
*/
axe.utils.preloadCssom = function preloadCssom({ treeRoot = axe._tree[0] }) {
/**
* get all `document` and `documentFragment` with in given `tree`
*/
const rootNodes = getAllRootNodesInTree(treeRoot);
if (!rootNodes.length) {
return Promise.resolve();
}
const dynamicDoc = document.implementation.createHTMLDocument(
'Dynamic document for loading cssom'
);
const convertDataToStylesheet = axe.utils.getStyleSheetFactory(dynamicDoc);
return getCssomForAllRootNodes(rootNodes, convertDataToStylesheet).then(
assets => flattenAssets(assets)
);
};
/**
* Returns am array of source nodes containing `document` and `documentFragment` in a given `tree`.
*
* @param {Object} treeRoot tree
* @returns {Array<Object>} array of objects, which each object containing a root and an optional `shadowId`
*/
function getAllRootNodesInTree(tree) {
let ids = [];
const rootNodes = axe.utils
.querySelectorAllFilter(tree, '*', node => {
if (ids.includes(node.shadowId)) {
return false;
}
ids.push(node.shadowId);
return true;
})
.map(node => {
return {
shadowId: node.shadowId,
rootNode: axe.utils.getRootNode(node.actualNode)
};
});
return axe.utils.uniqueArray(rootNodes, []);
}
/**
* Process CSSOM on all root nodes
*
* @param {Array<Object>} rootNodes array of root nodes, where node is an enhanced `document` or `documentFragment` object returned from `getAllRootNodesInTree`
* @param {Function} convertDataToStylesheet fn to convert given data to Stylesheet object
* @returns {Promise}
*/
function getCssomForAllRootNodes(rootNodes, convertDataToStylesheet) {
const promises = [];
rootNodes.forEach(({ rootNode, shadowId }, index) => {
const sheets = getStylesheetsOfRootNode(
rootNode,
shadowId,
convertDataToStylesheet
);
if (!sheets) {
return Promise.all(promises);
}
const rootIndex = index + 1;
const parseOptions = {
rootNode,
shadowId,
convertDataToStylesheet,
rootIndex
};
/**
* Note:
* `importedUrls` - keeps urls of already imported stylesheets, to prevent re-fetching
* eg: nested, cyclic or cross referenced `@import` urls
*/
const importedUrls = [];
const p = Promise.all(
sheets.map((sheet, sheetIndex) => {
const priority = [rootIndex, sheetIndex];
return axe.utils.parseStylesheet(
sheet,
parseOptions,
priority,
importedUrls
);
})
);
promises.push(p);
});
return Promise.all(promises);
}
/**
* Flatten CSSOM assets
*
* @param {[Array<Array<...>]} assets nested assets (varying depth)
* @returns {Array<Object>} Array of CSSOM object
*/
function flattenAssets(assets) {
return assets.reduce(
(acc, val) =>
Array.isArray(val) ? acc.concat(flattenAssets(val)) : acc.concat(val),
[]
);
}
/**
* Get stylesheet(s) for root
*
* @param {Object} options.rootNode `document` or `documentFragment`
* @param {String} options.shadowId an id if undefined denotes that given root is a document fragment/ shadowDOM
* @param {Function} options.convertDataToStylesheet a utility function to generate a style sheet from given data (text)
* @returns {Array<Object>} an array of stylesheets
*/
function getStylesheetsOfRootNode(rootNode, shadowId, convertDataToStylesheet) {
let sheets;
// nodeType === 11 -> DOCUMENT_FRAGMENT
if (rootNode.nodeType === 11 && shadowId) {
sheets = getStylesheetsFromDocumentFragment(
rootNode,
convertDataToStylesheet
);
} else {
sheets = getStylesheetsFromDocument(rootNode);
}
return filterStylesheetsWithSameHref(sheets);
}
/**
* Get stylesheets from `documentFragment`
*
* @property {Object} options.rootNode `documentFragment`
* @property {Function} options.convertDataToStylesheet a utility function to generate a stylesheet from given data
* @returns {Array<Object>}
*/
function getStylesheetsFromDocumentFragment(rootNode, convertDataToStylesheet) {
return (
Array.from(rootNode.children)
.filter(filerStyleAndLinkAttributesInDocumentFragment)
// Reducer to convert `<style></style>` and `<link>` references to `CSSStyleSheet` object
.reduce((out, node) => {
const nodeName = node.nodeName.toUpperCase();
const data = nodeName === 'STYLE' ? node.textContent : node;
const isLink = nodeName === 'LINK';
const stylesheet = convertDataToStylesheet({
data,
isLink,
root: rootNode
});
out.push(stylesheet.sheet);
return out;
}, [])
);
}
/**
* Get stylesheets from `document`
* -> filter out stylesheet that are `media=print`
*
* @param {Object} rootNode `document`
* @returns {Array<Object>}
*/
function getStylesheetsFromDocument(rootNode) {
return Array.from(rootNode.styleSheets).filter(sheet =>
filterMediaIsPrint(sheet.media.mediaText)
);
}
/**
* Get all `<style></style>` and `<link>` attributes
* -> limit to only `style` or `link` attributes with `rel=stylesheet` and `media != print`
*
* @param {Object} node HTMLElement
* @returns {Boolean}
*/
function filerStyleAndLinkAttributesInDocumentFragment(node) {
const nodeName = node.nodeName.toUpperCase();
const linkHref = node.getAttribute('href');
const linkRel = node.getAttribute('rel');
const isLink =
nodeName === 'LINK' &&
linkHref &&
linkRel &&
node.rel.toUpperCase().includes('STYLESHEET');
const isStyle = nodeName === 'STYLE';
return isStyle || (isLink && filterMediaIsPrint(node.media));
}
/**
* Exclude `link[rel='stylesheet]` attributes where `media=print`
*
* @param {String} media media value eg: 'print'
* @returns {Boolean}
*/
function filterMediaIsPrint(media) {
if (!media) {
return true;
}
return !media.toUpperCase().includes('PRINT');
}
/**
* Exclude any duplicate `stylesheets`, that share the same `href`
*
* @param {Array<Object>} sheets stylesheets
* @returns {Array<Object>}
*/
function filterStylesheetsWithSameHref(sheets) {
let hrefs = [];
return sheets.filter(sheet => {
if (!sheet.href) {
// include sheets without `href`
return true;
}
// if `href` is present, ensure they are not duplicates
if (hrefs.includes(sheet.href)) {
return false;
}
hrefs.push(sheet.href);
return true;
});
}