diff --git a/doc/API.md b/doc/API.md index 5e7f730b4a..9ceed246a6 100644 --- a/doc/API.md +++ b/doc/API.md @@ -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)) @@ -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 @@ -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. +###### 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. diff --git a/lib/checks/aria/aria-hidden-body.json b/lib/checks/aria/aria-hidden-body.json index c4d2d85cca..bad069615e 100644 --- a/lib/checks/aria/aria-hidden-body.json +++ b/lib/checks/aria/aria-hidden-body.json @@ -8,4 +8,4 @@ "fail": "aria-hidden=true should not be present on the document body" } } -} +} \ No newline at end of file diff --git a/lib/commons/dom/get-root-node.js b/lib/commons/dom/get-root-node.js index 59fbd86556..3aa7209ef1 100644 --- a/lib/commons/dom/get-root-node.js +++ b/lib/commons/dom/get-root-node.js @@ -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; diff --git a/lib/core/constants.js b/lib/core/constants.js index ed13e6e334..af9897ba86 100644 --- a/lib/core/constants.js +++ b/lib/core/constants.js @@ -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) { diff --git a/lib/core/utils/get-root-node.js b/lib/core/utils/get-root-node.js new file mode 100644 index 0000000000..43c13a046c --- /dev/null +++ b/lib/core/utils/get-root-node.js @@ -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; +}; diff --git a/lib/core/utils/preload-cssom.js b/lib/core/utils/preload-cssom.js new file mode 100644 index 0000000000..b3388dba38 --- /dev/null +++ b/lib/core/utils/preload-cssom.js @@ -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 + + + diff --git a/test/integration/full/preload-cssom/preload-cssom-shadow-blue.css b/test/integration/full/preload-cssom/preload-cssom-shadow-blue.css new file mode 100644 index 0000000000..97c8b38329 --- /dev/null +++ b/test/integration/full/preload-cssom/preload-cssom-shadow-blue.css @@ -0,0 +1,3 @@ +.blue{ + background-color: blue; +} \ No newline at end of file diff --git a/test/integration/full/preload-cssom/preload-cssom-shadow-red.css b/test/integration/full/preload-cssom/preload-cssom-shadow-red.css new file mode 100644 index 0000000000..513b61263b --- /dev/null +++ b/test/integration/full/preload-cssom/preload-cssom-shadow-red.css @@ -0,0 +1,3 @@ +.red{ + background-color: red; +} \ No newline at end of file diff --git a/test/integration/full/preload-cssom/preload-cssom.css b/test/integration/full/preload-cssom/preload-cssom.css new file mode 100644 index 0000000000..53398d8833 --- /dev/null +++ b/test/integration/full/preload-cssom/preload-cssom.css @@ -0,0 +1,5 @@ +@import 'preload-cssom-shadow-blue.css'; + +html { + font-weight: inherit; +} \ No newline at end of file diff --git a/test/integration/full/preload-cssom/preload-cssom.html b/test/integration/full/preload-cssom/preload-cssom.html new file mode 100644 index 0000000000..74ff0bdbf0 --- /dev/null +++ b/test/integration/full/preload-cssom/preload-cssom.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
blue on parent page
+ + + + + diff --git a/test/integration/full/preload-cssom/preload-cssom.js b/test/integration/full/preload-cssom/preload-cssom.js new file mode 100644 index 0000000000..af380a6a0f --- /dev/null +++ b/test/integration/full/preload-cssom/preload-cssom.js @@ -0,0 +1,279 @@ +/* global axe, Promise */ +describe('preload cssom integration test pass', function() { + 'use strict'; + + var origAxios; + var shadowSupported = axe.testUtils.shadowSupport.v1; + + before(function(done) { + function start() { + // cache original axios object + if (axe.imports.axios) { + origAxios = axe.imports.axios; + } + // run axe + axe.run( + { + preload: true + }, + function(err) { + assert.isNull(err); + done(); + } + ); + } + if (document.readyState !== 'complete') { + window.addEventListener('load', start); + } else { + start(); + } + }); + + function createStub(shouldReject) { + /** + * This is a simple override to stub `axe.imports.axios`, until the test-suite is enhanced. + * Did not use any library such as sinon for this, as sinon.stub have difficulties working under selenium webdriver + * Also a generic XHR override was overlooked under webdriver + */ + axe.imports.axios = function stubbedAxios() { + return new Promise(function(resolve, reject) { + if (shouldReject) { + reject(new Error('Fake Error')); + } + resolve({ + data: 'body{overflow:auto;}' + }); + }); + }; + } + + function restoreStub() { + if (origAxios) { + axe.imports.axios = origAxios; + } + } + + function assertStylesheet(sheet, selectorText, cssText) { + assert.isDefined(sheet); + assert.property(sheet, 'cssRules'); + assert.equal(sheet.cssRules[0].selectorText, selectorText); + assert.equal(sheet.cssRules[0].cssText.replace(/\s/g, ''), cssText); + } + + function getPreload(root) { + var config = { + asset: 'cssom', + timeout: 10000, + treeRoot: axe.utils.getFlattenedTree(root ? root : document) + }; + return axe.utils.preloadCssom(config); + } + + function commonTestsForRootAndFrame(root) { + shouldIt( + 'should return external stylesheet from cross-domain and verify response', + function(done) { + getPreload(root) + .then(function(results) { + var sheets = results[0]; + var externalSheet = sheets.filter(function(s) { + return s.isExternal; + })[0].sheet; + assertStylesheet(externalSheet, 'body', 'body{overflow:auto;}'); + done(); + }) + .catch(done); + } + ); + + shouldIt('should reject if external stylesheet fail to load', function( + done + ) { + restoreStub(); + createStub(true); + var doneCalled = false; + getPreload(root) + .then(done) + .catch(function(error) { + assert.equal(error.message, 'Fake Error'); + if (!doneCalled) { + done(); + } + doneCalled = true; + }); + }); + } + + beforeEach(function() { + createStub(); + }); + + afterEach(function() { + restoreStub(); + }); + + var shouldIt = window.PHANTOMJS ? it.skip : it; + + describe('tests for current top level document', function() { + shouldIt( + 'should return inline stylesheets defined using ' + + '
Some text
' + + '
green
' + + '
red
' + + '' + + '

Heading

'; + getPreload(fixture) + .then(function(results) { + var sheets = results[0]; + // verify count + assert.lengthOf(sheets, 8); + // verify that the last non external sheet with shadowId has green selector + var nonExternalsheetsWithShadowId = sheets + .filter(function(s) { + return !s.isExternal; + }) + .filter(function(s) { + return s.shadowId; + }); + assertStylesheet( + nonExternalsheetsWithShadowId[ + nonExternalsheetsWithShadowId.length - 1 + ].sheet, + '.green', + '.green{background-color:green;}' + ); + done(); + }) + .catch(done); + } + ); + } + + commonTestsForRootAndFrame(); + }); + + describe('tests for nested iframe', function() { + var frame; + + before(function() { + frame = document.getElementById('frame1').contentDocument; + }); + + shouldIt( + 'should return correct number of stylesheets, ignores disabled', + function(done) { + getPreload(frame) + .then(function(results) { + var sheets = results[0]; + assert.lengthOf(sheets, 3); + done(); + }) + .catch(done); + } + ); + + shouldIt( + 'should return inline stylesheets defined using