diff --git a/lib/core/utils/preload-cssom.js b/lib/core/utils/preload-cssom.js index 6cea9cf1bb..fb62f23817 100644 --- a/lib/core/utils/preload-cssom.js +++ b/lib/core/utils/preload-cssom.js @@ -1,58 +1,82 @@ /** - * 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 + * Make an axios get request to fetch a given resource and resolve + * @method getExternalStylesheet + * @param {Object} options an object with properties to configure the external XHR + * @property {Object} options.resolve resolve callback on queue + * @property {Object} options.reject reject callback on queue + * @property {String} options.url string representing the url of the resource to load + * @property {Object} options.rootNode document or shadowDOM root document for which to process CSSOM + * @property {Number} options.timeout timeout to about network call + * @property {Function} options.getStyleSheet a utility function to generate a style sheet for a given text content + * @property {String} options.shadowId an id if undefined denotes that given root is a shadowRoot + * @property {Number} options.priority css applied priority + * @returns resolve with stylesheet object * @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, - root - }); - resolve(sheet); - }) - .catch(reject); - } - - const q = axe.utils.queue(); +function getExternalStylesheet(options) { + const { + resolve, + reject, + url, + rootNode, + timeout, + getStyleSheet, + shadowId, + priority + } = options; + axe.imports + .axios({ + method: 'get', + url, + timeout + }) + .then(({ data }) => { + const sheet = getStyleSheet({ + data, + isExternal: true, + shadowId, + root: rootNode, + priority + }); + resolve(sheet); + }) + .catch(reject); +} - // handle .styleSheets non existent on certain shadowDOM root - const rootStyleSheets = root.styleSheets - ? Array.from(root.styleSheets) - : null; - if (!rootStyleSheets) { - return q; - } +/** + * Get stylesheet(s) from shadowDOM + * @param {Object} documentFragment document fragment node + * @param {Function} getStyleSheet helper function to get stylesheet object + * @returns an array of stylesheet objects + */ +function getSheetsFromShadowDom(documentFragment, getStyleSheet) { + return Array.from(documentFragment.children).reduce((out, node) => { + const nodeName = node.nodeName.toUpperCase(); + if (nodeName !== 'STYLE' && nodeName !== 'LINK') { + return out; + } + if (nodeName === 'STYLE') { + const dynamicSheet = getStyleSheet({ data: node.textContent }); + out.push(dynamicSheet.sheet); + } + if (nodeName === 'LINK' && !node.media.includes('print')) { + const dynamicSheet = getStyleSheet({ data: node, isLink: true }); + out.push(dynamicSheet.sheet); + } + return out; + }, []); +} - // convenience array fot help unique sheets if duplicated by same `href` - // both external and internal sheets +/** + * Filter a given array of stylesheet objects + * @param {Array} styleSheets array of stylesheets + * @returns an filtered array of stylesheets + */ +function filterStyleSheets(styleSheets) { let sheetHrefs = []; - // filter out sheets, that should not be accounted for... - const sheets = rootStyleSheets.filter(sheet => { - // FILTER > sheets with the same href (if exists) + return styleSheets.filter(sheet => { + // 1) FILTER > sheets with the same href let sheetAlreadyExists = false; if (sheet.href) { if (!sheetHrefs.includes(sheet.href)) { @@ -61,81 +85,112 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) { sheetAlreadyExists = true; } } - // FILTER > media='print' - // Note: - // Chrome does this automagically, Firefox returns every sheet - // hence the need to filter + // 2) FILTER > media='print' const isPrintMedia = Array.from(sheet.media).includes('print'); - // FILTER > disabled - // Firefox does not respect `disabled` attribute on stylesheet - // Hence decided not to filter out disabled for the time being - // return return !isPrintMedia && !sheetAlreadyExists; }); +} + +/** + * Returns a then(able) queue of CSSStyleSheet(s) + * @method loadCssom + * @private + * @param {Object} options an object with attributes essential to load CSSOM + * @property {Object} options.rootNode document or shadowDOM root document for which to process CSSOM + * @property {Number} options.rootIndex a number representing the index of the document or shadowDOM, used for priority + * @property {String} options.shadowId an id if undefined denotes that given root is a shadowRoot + * @property {Number} options.timeout abort duration for network request + * @param {Function} options.getStyleSheet a utility function to generate a style sheet for a given text content + * @return {Object} queue + */ +function loadCssom(options) { + const { rootNode, rootIndex, shadowId, getStyleSheet } = options; + const q = axe.utils.queue(); + const styleSheets = + rootNode.nodeType === 11 && shadowId + ? getSheetsFromShadowDom(rootNode, getStyleSheet) + : Array.from(rootNode.styleSheets); + const sheets = filterStyleSheets(styleSheets); + + sheets.forEach((sheet, sheetIndex) => { + /* eslint max-statements: ["error", 20] */ + const priority = [rootIndex, sheetIndex]; - // iterate to decipher multi-level nested sheets if any (this is essential to retrieve styles from shadowDOM) - sheets.forEach(sheet => { - // 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 + // The following line throws an error on cross-origin style sheets: const cssRules = sheet.cssRules; - // read all css rules in the sheet const rules = Array.from(cssRules); + if (!rules.length) { + return; + } // 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, - root + root: rootNode, + priority }) ); return; } - // if any import rules exists, fetch via `href` which eventually constructs a sheet with results from resource + // for import rules, fetch via `href` importRules.forEach(rule => { q.defer((resolve, reject) => { - getExternalStylesheet({ resolve, reject, url: rule.href }); + getExternalStylesheet({ + resolve, + reject, + url: rule.href, + priority, + ...options + }); }); }); // in the same sheet - get inline rules in ' + + '
Some text
' + + '
green
' + + '
red
' + + '

Heading

'; + getPreload(shadowFixture) + .then(function(results) { + var sheets = results[0]; + // verify count + assert.lengthOf(sheets, 7); + // 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;}' + ); + // verify priority of shadowId sheets is higher than base document + var anySheetFromBaseDocument = sheets.filter(function(s) { + return !s.shadowId; + })[0]; + var anySheetFromShadowDocument = sheets.filter(function(s) { + return s.shadowId; + })[0]; + // shadow dom priority is greater than base doc + assert.isAbove( + anySheetFromShadowDocument.priority[0], + anySheetFromBaseDocument.priority[0] + ); + done(); + }) + .catch(done); + } + ); + + (shadowSupported ? it : xit)( + 'should return styles from shadow dom (handles multiple ' + + '' + '
Some text
' + + '' + '
green
' + '
red
' + - '' + + '
red
' + '

Heading

'; - getPreload(fixture) + getPreload(shadowFixture) .then(function(results) { var sheets = results[0]; // verify count - assert.isAtLeast(sheets.length, 4); + assert.lengthOf(sheets, 8); // verify that the last non external sheet with shadowId has green selector var nonExternalsheetsWithShadowId = sheets .filter(function(s) { @@ -246,20 +309,65 @@ describe('preload cssom integration test', function() { .filter(function(s) { return s.shadowId; }); + assertStylesheet( + nonExternalsheetsWithShadowId[ + nonExternalsheetsWithShadowId.length - 2 + ].sheet, + '.green', + '.green{background-color:green;}' + ); + assertStylesheet( + nonExternalsheetsWithShadowId[ + nonExternalsheetsWithShadowId.length - 1 + ].sheet, + '.notGreen', + '.notGreen{background-color:orange;}' + ); + done(); + }) + .catch(done); + } + ); + + (shadowSupported ? it : xit)( + 'should return styles from shadow dom (handles mulitple ' + + '' + + '

Heading

'; + getPreload(shadowFixture) + .then(function(results) { + var sheets = results[0]; + // verify count + assert.lengthOf(sheets, 6); + + // 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; + }); + // there are no inline styles in shadowRoot + assert.lengthOf(nonExternalsheetsWithShadowId, 0); + + // ensure the output of shadowRoot sheet is that of expected external mocked response + var externalsheetsWithShadowId = sheets + .filter(function(s) { + return s.isExternal; + }) + .filter(function(s) { + return s.shadowId; + }); + assertStylesheet( + externalsheetsWithShadowId[0].sheet, + 'body', + 'body{overflow:auto;}' + ); - // Issue - https://github.com/dequelabs/axe-core/issues/1082 - if ( - nonExternalsheetsWithShadowId && - nonExternalsheetsWithShadowId.length - ) { - assertStylesheet( - nonExternalsheetsWithShadowId[ - nonExternalsheetsWithShadowId.length - 1 - ].sheet, - '.green', - '.green{background-color:green;}' - ); - } done(); }) .catch(done);