Skip to content

Commit

Permalink
feat(preload): cssom assets (#958)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeeyyy authored Aug 8, 2018
1 parent ddeac7f commit 5d6c1fa
Show file tree
Hide file tree
Showing 17 changed files with 945 additions and 17 deletions.
25 changes: 24 additions & 1 deletion doc/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ The options parameter is flexible way to configure how `axe.run` operates. The d
Additionally, there are a number or properties that allow configuration of different options:

| Property | Default | Description |
|-----------------|:-------:|:----------------------------:|
|-----------------|:-------|:----------------------------|
| `runOnly` | n/a | Limit which rules are executed, based on names or tags
| `rules` | n/a | Allow customizing a rule's properties (including { enable: false })
| `reporter` | `v1` | Which reporter to use (see [Configuration](#api-name-axeconfigure))
Expand All @@ -339,6 +339,7 @@ Additionally, there are a number or properties that allow configuration of diffe
| `elementRef` | `false` | Return element references in addition to the target
| `restoreScroll` | `false` | Scrolls elements back to before axe started
| `frameWaitTime` | `60000` | How long (in milliseconds) axe waits for a response from embedded frames before timing out
| `preload` | `false` | Any additional assets (eg: cssom) to preload before running rules. [See here for configuration details](#preload-configuration-details)
###### Options Parameter Examples
Expand Down Expand Up @@ -460,6 +461,28 @@ Additionally, there are a number or properties that allow configuration of diffe
```
This example will process all of the "violations", "incomplete", and "inapplicable" result types. Since "passes" was not specified, it will only process the first pass for each rule, if one exists. As a result, the results object's `passes` array will have a length of either `0` or `1`. On a series of extremely large pages, this would improve performance considerably.

###### <a id='preload-configuration-details'></a> Preload Configuration in Options Parameter

The preload attribute in options parameter accepts a `boolean` or an `object` where an array of assets can be specified.

1. Specifying a `boolean`

```js
preload: true
```

2. Specifying an `object`
```js
preload: { assets: ['cssom'], timeout: 50000 }
```
The `assets` attribute expects an array of preload(able) constraints to be fetched. The current set of values supported for `assets` is listed in the following table:

| Asset Type | Description |
|:-----------|:------------|
| `cssom` | This asset type preloads all CSS Stylesheets rulesets specified in the page. The stylessheets can be an external cross-domain resource, a relative stylesheet or an inline style with in the head tag of the document. If the stylesheet is an external cross-domain a network request is made. An object representing the CSS Rules from each stylesheet is made available to the checks evaluate function as `preloadedAssets` at run-time |

The `timeout` attribute in the object configuration is `optional` and has a fallback default value (10000ms). The `timeout` is essential for any network dependent assets that are preloaded, where-in if a given request takes longer than the specified/ default value, the operation is aborted.

##### Callback Parameter

The callback parameter is a function that will be called when the asynchronous `axe.run` function completes. The callback function is passed two parameters. The first parameter will be an error thrown inside of aXe if axe.run could not complete. If axe completed correctly the first parameter will be null, and the second parameter will be the results object.
Expand Down
2 changes: 1 addition & 1 deletion lib/checks/aria/aria-hidden-body.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
"fail": "aria-hidden=true should not be present on the document body"
}
}
}
}
10 changes: 2 additions & 8 deletions lib/commons/dom/get-root-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,6 @@
* @instance
* @param {Element} node
* @returns {DocumentFragment|Document}
* @deprecated use axe.utils.getRootNode
*/
dom.getRootNode = function(node) {
var doc = (node.getRootNode && node.getRootNode()) || document; // this is for backwards compatibility
if (doc === node) {
// disconnected node
doc = document;
}
return doc;
};
dom.getRootNode = axe.utils.getRootNode;
4 changes: 3 additions & 1 deletion lib/core/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
results: [],
resultGroups: [],
resultGroupMap: {},
impact: Object.freeze(['minor', 'moderate', 'serious', 'critical'])
impact: Object.freeze(['minor', 'moderate', 'serious', 'critical']),
preloadAssets: Object.freeze(['cssom']), // overtime this array will grow with other preload asset types, this constant is to verify if a requested preload type by the user via the configuration is supported by axe.
preloadAssetsTimeout: 10000
};

definitions.forEach(function(definition) {
Expand Down
18 changes: 18 additions & 0 deletions lib/core/utils/get-root-node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* global axe */

/**
* Return the document or document fragment (shadow DOM)
* @method getRootNode
* @memberof axe.utils
* @instance
* @param {Element} node
* @returns {DocumentFragment|Document}
*/
axe.utils.getRootNode = function getRootNode(node) {
var doc = (node.getRootNode && node.getRootNode()) || document; // this is for backwards compatibility
if (doc === node) {
// disconnected node
doc = document;
}
return doc;
};
205 changes: 205 additions & 0 deletions lib/core/utils/preload-cssom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* Returns a then(able) queue of CSSStyleSheet(s)
* @param {Object} ownerDocument document object to be inspected for stylesheets
* @param {number} timeout on network request for stylesheet that need to be externally fetched
* @param {Function} convertTextToStylesheetFn a utility function to generate a style sheet from text
* @return {Object} queue
* @private
*/
function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
/**
* Make an axios get request to fetch a given resource and resolve
* @method getExternalStylesheet
* @private
* @param {Object} param an object with properties to configure the external XHR
* @property {Object} param.resolve resolve callback on queue
* @property {Object} param.reject reject callback on queue
* @property {String} param.url string representing the url of the resource to load
* @property {Number} param.timeout timeout to about network call
*/
function getExternalStylesheet({ resolve, reject, url }) {
axe.imports
.axios({
method: 'get',
url,
timeout
})
.then(({ data }) => {
const sheet = convertTextToStylesheetFn({
data,
isExternal: true,
shadowId
});
resolve(sheet);
})
.catch(reject);
}

const q = axe.utils.queue();

// iterate to decipher multi-level nested sheets if any (this is essential to retrieve styles from shadowDOM)
Array.from(root.styleSheets).forEach(sheet => {
// ignore disabled sheets
if (sheet.disabled) {
return;
}
// attempt to retrieve cssRules, or for external sheets make a XMLHttpRequest
try {
// accessing .cssRules throws for external (cross-domain) sheets, which is handled in the catch
const cssRules = sheet.cssRules;
// read all css rules in the sheet
const rules = Array.from(cssRules);

// filter rules that are included by way of @import or nested link
const importRules = rules.filter(r => r.href);

// if no import or nested link rules, with in these cssRules
// return current sheet
if (!importRules.length) {
q.defer(resolve =>
resolve({
sheet,
isExternal: false,
shadowId
})
);
return;
}

// if any import rules exists, fetch via `href` which eventually constructs a sheet with results from resource
importRules.forEach(rule => {
q.defer((resolve, reject) => {
getExternalStylesheet({ resolve, reject, url: rule.href });
});
});

// in the same sheet - get inline rules in <style> tag or in a CSSStyleSheet excluding @import or nested link
const inlineRules = rules.filter(rule => !rule.href);

// concat all cssText into a string for inline rules
const inlineRulesCssText = inlineRules
.reduce((out, rule) => {
out.push(rule.cssText);
return out;
}, [])
.join();
// create and return a sheet with inline rules
q.defer(resolve =>
resolve(
convertTextToStylesheetFn({
data: inlineRulesCssText,
shadowId,
isExternal: false
})
)
);
} catch (e) {
// if no href, do not attempt to make an XHR, but this is preventive check
// NOTE: as further enhancements to resolve nested @imports are done, a decision to throw an Error if necessary here will be made.
if (!sheet.href) {
return;
}
// external sheet -> make an xhr and q the response
q.defer((resolve, reject) => {
getExternalStylesheet({ resolve, reject, url: sheet.href });
});
}
}, []);
// return
return q;
}

/**
* Returns an array of objects with root(document)
* @param {Object} treeRoot the DOM tree to be inspected
* @return {Array<Object>} array of objects, which each object containing a root (document) and an optional shadowId
* @private
*/
function getAllRootsInTree(tree) {
let ids = [];
const documents = axe.utils
.querySelectorAllFilter(tree, '*', node => {
if (ids.includes(node.shadowId)) {
return false;
}
ids.push(node.shadowId);
return true;
})
.map(node => {
return {
shadowId: node.shadowId,
root: axe.utils.getRootNode(node.actualNode)
};
});
return documents;
}

/**
* @method preloadCssom
* @memberof axe.utils
* @instance
* @param {Object} object argument which is a composite object, with attributes timeout, treeRoot(optional), resolve & reject
* @property {Number} timeout timeout for any network calls made
* @property {Object} treeRoot the DOM tree to be inspected
* @return {Object} a queue with results of cssom assets
*/
axe.utils.preloadCssom = function preloadCssom({
timeout,
treeRoot = axe._tree[0]
}) {
const roots = axe.utils.uniqueArray(getAllRootsInTree(treeRoot), []);
const q = axe.utils.queue();

if (!roots.length) {
return q;
}

const dynamicDoc = document.implementation.createHTMLDocument();

/**
* Convert text content to CSSStyleSheet
* @method convertTextToStylesheet
* @private
* @param {Object} param an object with properties to construct stylesheet
* @property {String} param.data text content of the stylesheet
* @property {Boolean} param.isExternal flag to notify if the resource was fetched from the network
* @property {Object} param.doc implementation document to create style elements
* @property {String} param.shadowId (Optional) shadowId if shadowDOM
*/
function convertTextToStylesheet({ data, isExternal, shadowId }) {
const style = dynamicDoc.createElement('style');
style.type = 'text/css';
style.appendChild(dynamicDoc.createTextNode(data));
dynamicDoc.head.appendChild(style);
return {
sheet: style.sheet,
isExternal,
shadowId
};
}

q.defer((resolve, reject) => {
// as there can be multiple documents (root document, shadow document fragments, and frame documents)
// reduce these into a queue
roots
.reduce((out, root) => {
out.defer((resolve, reject) => {
loadCssom(root, timeout, convertTextToStylesheet)
.then(resolve)
.catch(reject);
});
return out;
}, axe.utils.queue())
// await loading all such documents assets, and concat results into one object
.then(assets => {
resolve(
assets.reduce((out, cssomSheets) => {
return out.concat(cssomSheets);
}, [])
);
})
.catch(reject);
});

return q;
};
Loading

0 comments on commit 5d6c1fa

Please sign in to comment.