Skip to content

Commit 5d6c1fa

Browse files
authored
feat(preload): cssom assets (#958)
1 parent ddeac7f commit 5d6c1fa

17 files changed

+945
-17
lines changed

doc/API.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ The options parameter is flexible way to configure how `axe.run` operates. The d
328328
Additionally, there are a number or properties that allow configuration of different options:
329329

330330
| Property | Default | Description |
331-
|-----------------|:-------:|:----------------------------:|
331+
|-----------------|:-------|:----------------------------|
332332
| `runOnly` | n/a | Limit which rules are executed, based on names or tags
333333
| `rules` | n/a | Allow customizing a rule's properties (including { enable: false })
334334
| `reporter` | `v1` | Which reporter to use (see [Configuration](#api-name-axeconfigure))
@@ -339,6 +339,7 @@ Additionally, there are a number or properties that allow configuration of diffe
339339
| `elementRef` | `false` | Return element references in addition to the target
340340
| `restoreScroll` | `false` | Scrolls elements back to before axe started
341341
| `frameWaitTime` | `60000` | How long (in milliseconds) axe waits for a response from embedded frames before timing out
342+
| `preload` | `false` | Any additional assets (eg: cssom) to preload before running rules. [See here for configuration details](#preload-configuration-details)
342343

343344
###### Options Parameter Examples
344345

@@ -460,6 +461,28 @@ Additionally, there are a number or properties that allow configuration of diffe
460461
```
461462
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.
462463

464+
###### <a id='preload-configuration-details'></a> Preload Configuration in Options Parameter
465+
466+
The preload attribute in options parameter accepts a `boolean` or an `object` where an array of assets can be specified.
467+
468+
1. Specifying a `boolean`
469+
470+
```js
471+
preload: true
472+
```
473+
474+
2. Specifying an `object`
475+
```js
476+
preload: { assets: ['cssom'], timeout: 50000 }
477+
```
478+
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:
479+
480+
| Asset Type | Description |
481+
|:-----------|:------------|
482+
| `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 |
483+
484+
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.
485+
463486
##### Callback Parameter
464487

465488
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.

lib/checks/aria/aria-hidden-body.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
"fail": "aria-hidden=true should not be present on the document body"
99
}
1010
}
11-
}
11+
}

lib/commons/dom/get-root-node.js

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,6 @@
77
* @instance
88
* @param {Element} node
99
* @returns {DocumentFragment|Document}
10+
* @deprecated use axe.utils.getRootNode
1011
*/
11-
dom.getRootNode = function(node) {
12-
var doc = (node.getRootNode && node.getRootNode()) || document; // this is for backwards compatibility
13-
if (doc === node) {
14-
// disconnected node
15-
doc = document;
16-
}
17-
return doc;
18-
};
12+
dom.getRootNode = axe.utils.getRootNode;

lib/core/constants.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
results: [],
3232
resultGroups: [],
3333
resultGroupMap: {},
34-
impact: Object.freeze(['minor', 'moderate', 'serious', 'critical'])
34+
impact: Object.freeze(['minor', 'moderate', 'serious', 'critical']),
35+
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.
36+
preloadAssetsTimeout: 10000
3537
};
3638

3739
definitions.forEach(function(definition) {

lib/core/utils/get-root-node.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/* global axe */
2+
3+
/**
4+
* Return the document or document fragment (shadow DOM)
5+
* @method getRootNode
6+
* @memberof axe.utils
7+
* @instance
8+
* @param {Element} node
9+
* @returns {DocumentFragment|Document}
10+
*/
11+
axe.utils.getRootNode = function getRootNode(node) {
12+
var doc = (node.getRootNode && node.getRootNode()) || document; // this is for backwards compatibility
13+
if (doc === node) {
14+
// disconnected node
15+
doc = document;
16+
}
17+
return doc;
18+
};

lib/core/utils/preload-cssom.js

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* Returns a then(able) queue of CSSStyleSheet(s)
3+
* @param {Object} ownerDocument document object to be inspected for stylesheets
4+
* @param {number} timeout on network request for stylesheet that need to be externally fetched
5+
* @param {Function} convertTextToStylesheetFn a utility function to generate a style sheet from text
6+
* @return {Object} queue
7+
* @private
8+
*/
9+
function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
10+
/**
11+
* Make an axios get request to fetch a given resource and resolve
12+
* @method getExternalStylesheet
13+
* @private
14+
* @param {Object} param an object with properties to configure the external XHR
15+
* @property {Object} param.resolve resolve callback on queue
16+
* @property {Object} param.reject reject callback on queue
17+
* @property {String} param.url string representing the url of the resource to load
18+
* @property {Number} param.timeout timeout to about network call
19+
*/
20+
function getExternalStylesheet({ resolve, reject, url }) {
21+
axe.imports
22+
.axios({
23+
method: 'get',
24+
url,
25+
timeout
26+
})
27+
.then(({ data }) => {
28+
const sheet = convertTextToStylesheetFn({
29+
data,
30+
isExternal: true,
31+
shadowId
32+
});
33+
resolve(sheet);
34+
})
35+
.catch(reject);
36+
}
37+
38+
const q = axe.utils.queue();
39+
40+
// iterate to decipher multi-level nested sheets if any (this is essential to retrieve styles from shadowDOM)
41+
Array.from(root.styleSheets).forEach(sheet => {
42+
// ignore disabled sheets
43+
if (sheet.disabled) {
44+
return;
45+
}
46+
// attempt to retrieve cssRules, or for external sheets make a XMLHttpRequest
47+
try {
48+
// accessing .cssRules throws for external (cross-domain) sheets, which is handled in the catch
49+
const cssRules = sheet.cssRules;
50+
// read all css rules in the sheet
51+
const rules = Array.from(cssRules);
52+
53+
// filter rules that are included by way of @import or nested link
54+
const importRules = rules.filter(r => r.href);
55+
56+
// if no import or nested link rules, with in these cssRules
57+
// return current sheet
58+
if (!importRules.length) {
59+
q.defer(resolve =>
60+
resolve({
61+
sheet,
62+
isExternal: false,
63+
shadowId
64+
})
65+
);
66+
return;
67+
}
68+
69+
// if any import rules exists, fetch via `href` which eventually constructs a sheet with results from resource
70+
importRules.forEach(rule => {
71+
q.defer((resolve, reject) => {
72+
getExternalStylesheet({ resolve, reject, url: rule.href });
73+
});
74+
});
75+
76+
// in the same sheet - get inline rules in <style> tag or in a CSSStyleSheet excluding @import or nested link
77+
const inlineRules = rules.filter(rule => !rule.href);
78+
79+
// concat all cssText into a string for inline rules
80+
const inlineRulesCssText = inlineRules
81+
.reduce((out, rule) => {
82+
out.push(rule.cssText);
83+
return out;
84+
}, [])
85+
.join();
86+
// create and return a sheet with inline rules
87+
q.defer(resolve =>
88+
resolve(
89+
convertTextToStylesheetFn({
90+
data: inlineRulesCssText,
91+
shadowId,
92+
isExternal: false
93+
})
94+
)
95+
);
96+
} catch (e) {
97+
// if no href, do not attempt to make an XHR, but this is preventive check
98+
// NOTE: as further enhancements to resolve nested @imports are done, a decision to throw an Error if necessary here will be made.
99+
if (!sheet.href) {
100+
return;
101+
}
102+
// external sheet -> make an xhr and q the response
103+
q.defer((resolve, reject) => {
104+
getExternalStylesheet({ resolve, reject, url: sheet.href });
105+
});
106+
}
107+
}, []);
108+
// return
109+
return q;
110+
}
111+
112+
/**
113+
* Returns an array of objects with root(document)
114+
* @param {Object} treeRoot the DOM tree to be inspected
115+
* @return {Array<Object>} array of objects, which each object containing a root (document) and an optional shadowId
116+
* @private
117+
*/
118+
function getAllRootsInTree(tree) {
119+
let ids = [];
120+
const documents = axe.utils
121+
.querySelectorAllFilter(tree, '*', node => {
122+
if (ids.includes(node.shadowId)) {
123+
return false;
124+
}
125+
ids.push(node.shadowId);
126+
return true;
127+
})
128+
.map(node => {
129+
return {
130+
shadowId: node.shadowId,
131+
root: axe.utils.getRootNode(node.actualNode)
132+
};
133+
});
134+
return documents;
135+
}
136+
137+
/**
138+
* @method preloadCssom
139+
* @memberof axe.utils
140+
* @instance
141+
* @param {Object} object argument which is a composite object, with attributes timeout, treeRoot(optional), resolve & reject
142+
* @property {Number} timeout timeout for any network calls made
143+
* @property {Object} treeRoot the DOM tree to be inspected
144+
* @return {Object} a queue with results of cssom assets
145+
*/
146+
axe.utils.preloadCssom = function preloadCssom({
147+
timeout,
148+
treeRoot = axe._tree[0]
149+
}) {
150+
const roots = axe.utils.uniqueArray(getAllRootsInTree(treeRoot), []);
151+
const q = axe.utils.queue();
152+
153+
if (!roots.length) {
154+
return q;
155+
}
156+
157+
const dynamicDoc = document.implementation.createHTMLDocument();
158+
159+
/**
160+
* Convert text content to CSSStyleSheet
161+
* @method convertTextToStylesheet
162+
* @private
163+
* @param {Object} param an object with properties to construct stylesheet
164+
* @property {String} param.data text content of the stylesheet
165+
* @property {Boolean} param.isExternal flag to notify if the resource was fetched from the network
166+
* @property {Object} param.doc implementation document to create style elements
167+
* @property {String} param.shadowId (Optional) shadowId if shadowDOM
168+
*/
169+
function convertTextToStylesheet({ data, isExternal, shadowId }) {
170+
const style = dynamicDoc.createElement('style');
171+
style.type = 'text/css';
172+
style.appendChild(dynamicDoc.createTextNode(data));
173+
dynamicDoc.head.appendChild(style);
174+
return {
175+
sheet: style.sheet,
176+
isExternal,
177+
shadowId
178+
};
179+
}
180+
181+
q.defer((resolve, reject) => {
182+
// as there can be multiple documents (root document, shadow document fragments, and frame documents)
183+
// reduce these into a queue
184+
roots
185+
.reduce((out, root) => {
186+
out.defer((resolve, reject) => {
187+
loadCssom(root, timeout, convertTextToStylesheet)
188+
.then(resolve)
189+
.catch(reject);
190+
});
191+
return out;
192+
}, axe.utils.queue())
193+
// await loading all such documents assets, and concat results into one object
194+
.then(assets => {
195+
resolve(
196+
assets.reduce((out, cssomSheets) => {
197+
return out.concat(cssomSheets);
198+
}, [])
199+
);
200+
})
201+
.catch(reject);
202+
});
203+
204+
return q;
205+
};

0 commit comments

Comments
 (0)