From 381b2c37db9154abd5675e9171fcb46b3b8c87c8 Mon Sep 17 00:00:00 2001 From: Dan Bjorge Date: Mon, 21 Aug 2023 12:18:38 -0400 Subject: [PATCH] refactor: make element spec processing more cosistent (#4093) * DqElement.setSerializer initial impl * implemented and passing integration tests * Separated nodeSerializer * Cleanup & tests * prevent html: "Undefined" when noHtml is set * Update test/core/public/run-partial.js * Tweaks * Change to nodeSerializer.update * WIP: Delay DqElement serialization * Delay node serialization * Tweak * Simplify a bit further * Cleanup * Improve performance * Update raw reporter * DqElement in checkHelper * Update lib/core/utils/check-helper.js Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com> * Update lib/core/utils/dq-element.js * Update lib/core/utils/node-serializer.js Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com> --------- Co-authored-by: Wilco Fiers Co-authored-by: Wilco Fiers Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com> --- doc/run-partial.md | 2 + lib/core/base/audit.js | 2 + lib/core/base/check.js | 6 +- lib/core/base/rule.js | 4 +- lib/core/public/finish-run.js | 4 +- lib/core/public/load.js | 3 + lib/core/public/run-partial.js | 25 +- .../reporters/helpers/process-aggregate.js | 57 ++-- lib/core/reporters/raw.js | 15 +- lib/core/utils/check-helper.js | 2 +- lib/core/utils/collect-results-from-frames.js | 3 + lib/core/utils/dq-element.js | 53 +++- lib/core/utils/index.js | 1 + lib/core/utils/merge-results.js | 22 +- lib/core/utils/node-serializer.js | 133 +++++++++ test/core/base/audit.js | 197 ++++++------ test/core/base/rule.js | 42 +-- test/core/public/run-partial.js | 26 ++ test/core/public/run-rules.js | 27 +- .../reporters/helpers/process-aggregate.js | 81 ++++- test/core/reporters/raw-env.js | 31 +- test/core/reporters/raw.js | 31 +- test/core/utils/check-helper.js | 69 ++--- test/core/utils/dq-element.js | 34 ++- test/core/utils/node-serializer.js | 280 ++++++++++++++++++ .../configure-options/configure-options.js | 3 +- .../serializer/custom-source-serializer.js | 12 + .../full/serializer/frames/level1.html | 12 + .../full/serializer/frames/level2-a.html | 9 + .../full/serializer/frames/level2-b.html | 9 + .../full/serializer/serializer.html | 29 ++ .../integration/full/serializer/serializer.js | 46 +++ 32 files changed, 1008 insertions(+), 262 deletions(-) create mode 100644 lib/core/utils/node-serializer.js create mode 100644 test/core/utils/node-serializer.js create mode 100644 test/integration/full/serializer/custom-source-serializer.js create mode 100644 test/integration/full/serializer/frames/level1.html create mode 100644 test/integration/full/serializer/frames/level2-a.html create mode 100644 test/integration/full/serializer/frames/level2-b.html create mode 100644 test/integration/full/serializer/serializer.html create mode 100644 test/integration/full/serializer/serializer.js diff --git a/doc/run-partial.md b/doc/run-partial.md index ae7114d8ea..a2570d2a92 100644 --- a/doc/run-partial.md +++ b/doc/run-partial.md @@ -13,6 +13,8 @@ const axeResults = await axe.finishRun(partialResults, options); **note**: The code in this page uses native DOM methods. This will only work on frames with the same origin. Scripts do not have access to `contentWindow` of cross-origin frames. Use of `runPartial` and `finishRun` in browser drivers like Selenium and Puppeteer works in the same way. +**note**: Because `axe.runPartial()` is designed to be serialized, it will not return element references even if the `elementRef` option is set. + ## axe.runPartial(context, options): Promise When using `axe.runPartial()` it is important to keep in mind that the `context` may be different for different frames. For example, `context` can be done in such a way that in frame A, `main` is excluded, and in frame B `footer` is. The `axe.utils.getFrameContexts` method will provide a list of frames that must be tested, and what context to test it with. diff --git a/lib/core/base/audit.js b/lib/core/base/audit.js index c454719be9..47b57f66d9 100644 --- a/lib/core/base/audit.js +++ b/lib/core/base/audit.js @@ -4,6 +4,7 @@ import standards from '../../standards'; import RuleResult from './rule-result'; import { clone, + DqElement, queue, preload, findBy, @@ -270,6 +271,7 @@ export default class Audit { */ run(context, options, resolve, reject) { this.normalizeOptions(options); + DqElement.setRunOptions(options); // TODO: es-modules_selectCache axe._selectCache = []; diff --git a/lib/core/base/check.js b/lib/core/base/check.js index 593c9b521e..961188bf02 100644 --- a/lib/core/base/check.js +++ b/lib/core/base/check.js @@ -1,6 +1,6 @@ import metadataFunctionMap from './metadata-function-map'; import CheckResult from './check-result'; -import { DqElement, checkHelper, deepMerge } from '../utils'; +import { nodeSerializer, checkHelper, deepMerge } from '../utils'; export function createExecutionContext(spec) { /*eslint no-eval:0 */ @@ -108,7 +108,7 @@ Check.prototype.run = function run(node, options, context, resolve, reject) { // possible reference error. if (node && node.actualNode) { // Save a reference to the node we errored on for futher debugging. - e.errorNode = new DqElement(node).toJSON(); + e.errorNode = nodeSerializer.toSpec(node); } reject(e); return; @@ -162,7 +162,7 @@ Check.prototype.runSync = function runSync(node, options, context) { // possible reference error. if (node && node.actualNode) { // Save a reference to the node we errored on for futher debugging. - e.errorNode = new DqElement(node).toJSON(); + e.errorNode = nodeSerializer.toSpec(node); } throw e; } diff --git a/lib/core/base/rule.js b/lib/core/base/rule.js index 32c8534ebc..a5995adfe4 100644 --- a/lib/core/base/rule.js +++ b/lib/core/base/rule.js @@ -258,7 +258,7 @@ Rule.prototype.run = function run(context, options = {}, resolve, reject) { .then(results => { const result = getResult(results); if (result) { - result.node = new DqElement(node, options); + result.node = new DqElement(node); ruleResult.nodes.push(result); // mark rule as incomplete rather than failure for rules with reviewOnFail @@ -327,7 +327,7 @@ Rule.prototype.runSync = function runSync(context, options = {}) { const result = getResult(results); if (result) { - result.node = node.actualNode ? new DqElement(node, options) : null; + result.node = node.actualNode ? new DqElement(node) : null; ruleResult.nodes.push(result); // mark rule as incomplete rather than failure for rules with reviewOnFail diff --git a/lib/core/public/finish-run.js b/lib/core/public/finish-run.js index a82f08ff1f..31e6417c2a 100644 --- a/lib/core/public/finish-run.js +++ b/lib/core/public/finish-run.js @@ -3,7 +3,7 @@ import { mergeResults, publishMetaData, finalizeRuleResult, - DqElement, + nodeSerializer, clone } from '../utils'; @@ -47,7 +47,7 @@ function getMergedFrameSpecs({ } // Include the selector/ancestry/... from the parent frames return childFrameSpecs.map(childFrameSpec => { - return DqElement.mergeSpecs(childFrameSpec, parentFrameSpec); + return nodeSerializer.mergeSpecs(childFrameSpec, parentFrameSpec); }); } diff --git a/lib/core/public/load.js b/lib/core/public/load.js index 2c263f2982..124c849b12 100644 --- a/lib/core/public/load.js +++ b/lib/core/public/load.js @@ -2,6 +2,7 @@ import Audit from '../base/audit'; import cleanup from './cleanup'; import runRules from './run-rules'; import respondable from '../utils/respondable'; +import nodeSerializer from '../utils/node-serializer'; /** * Sets up Rules, Messages and default options for Checks, must be invoked before attempting analysis @@ -33,6 +34,8 @@ function runCommand(data, keepalive, callback) { context, options, (results, cleanupFn) => { + // Serialize all DqElements + results = nodeSerializer.mapRawResults(results); resolve(results); // Cleanup AFTER resolve so that selectors can be generated cleanupFn(); diff --git a/lib/core/public/run-partial.js b/lib/core/public/run-partial.js index ef3ffd2b45..50ab6465da 100644 --- a/lib/core/public/run-partial.js +++ b/lib/core/public/run-partial.js @@ -1,7 +1,7 @@ import Context from '../base/context'; import teardown from './teardown'; import { - DqElement, + nodeSerializer, getSelectorData, assert, getEnvironmentData @@ -22,17 +22,17 @@ export default function runPartial(...args) { axe._selectorData = getSelectorData(contextObj.flatTree); axe._running = true; + // Even in the top frame, we don't support this with runPartial + options.elementRef = false; + return ( new Promise((res, rej) => { axe._audit.run(contextObj, options, res, rej); }) .then(results => { - results = results.map(({ nodes, ...result }) => ({ - nodes: nodes.map(serializeNode), - ...result - })); + results = nodeSerializer.mapRawResults(results); const frames = contextObj.frames.map(({ node }) => { - return new DqElement(node, options).toJSON(); + return nodeSerializer.toSpec(node); }); let environmentData; if (contextObj.initiator) { @@ -50,16 +50,3 @@ export default function runPartial(...args) { }) ); } - -function serializeNode({ node, ...nodeResult }) { - nodeResult.node = node.toJSON(); - for (const type of ['any', 'all', 'none']) { - nodeResult[type] = nodeResult[type].map( - ({ relatedNodes, ...checkResult }) => ({ - ...checkResult, - relatedNodes: relatedNodes.map(relatedNode => relatedNode.toJSON()) - }) - ); - } - return nodeResult; -} diff --git a/lib/core/reporters/helpers/process-aggregate.js b/lib/core/reporters/helpers/process-aggregate.js index 46b93b0e33..dfd1436144 100644 --- a/lib/core/reporters/helpers/process-aggregate.js +++ b/lib/core/reporters/helpers/process-aggregate.js @@ -1,4 +1,5 @@ import constants from '../../constants'; +import { nodeSerializer } from '../../utils'; const resultKeys = constants.resultGroups; @@ -43,19 +44,8 @@ export default function processAggregate(results, options) { if (Array.isArray(ruleResult.nodes) && ruleResult.nodes.length > 0) { ruleResult.nodes = ruleResult.nodes.map(subResult => { if (typeof subResult.node === 'object') { - subResult.html = subResult.node.source; - if (options.elementRef && !subResult.node.fromFrame) { - subResult.element = subResult.node.element; - } - if (options.selectors !== false || subResult.node.fromFrame) { - subResult.target = subResult.node.selector; - } - if (options.ancestry) { - subResult.ancestry = subResult.node.ancestry; - } - if (options.xpath) { - subResult.xpath = subResult.node.xpath; - } + const serialElm = trimElementSpec(subResult.node, options); + Object.assign(subResult, serialElm); } delete subResult.result; delete subResult.node; @@ -86,23 +76,32 @@ function normalizeRelatedNodes(node, options) { .filter(checkRes => Array.isArray(checkRes.relatedNodes)) .forEach(checkRes => { checkRes.relatedNodes = checkRes.relatedNodes.map(relatedNode => { - const res = { - html: relatedNode?.source ?? 'Undefined' - }; - if (options.elementRef && !relatedNode?.fromFrame) { - res.element = relatedNode?.element ?? null; - } - if (options.selectors !== false || relatedNode?.fromFrame) { - res.target = relatedNode?.selector ?? [':root']; - } - if (options.ancestry) { - res.ancestry = relatedNode?.ancestry ?? [':root']; - } - if (options.xpath) { - res.xpath = relatedNode?.xpath ?? ['/']; - } - return res; + return trimElementSpec(relatedNode, options); }); }); }); } + +function trimElementSpec(elmSpec = {}, runOptions) { + // Pass options to limit which properties are calculated + elmSpec = nodeSerializer.dqElmToSpec(elmSpec, runOptions); + const serialElm = {}; + if (axe._audit.noHtml) { + serialElm.html = null; + } else { + serialElm.html = elmSpec.source ?? 'Undefined'; + } + if (runOptions.elementRef && !elmSpec.fromFrame) { + serialElm.element = elmSpec.element ?? null; + } + if (runOptions.selectors !== false || elmSpec.fromFrame) { + serialElm.target = elmSpec.selector ?? [':root']; + } + if (runOptions.ancestry) { + serialElm.ancestry = elmSpec.ancestry ?? [':root']; + } + if (runOptions.xpath) { + serialElm.xpath = elmSpec.xpath ?? ['/']; + } + return serialElm; +} diff --git a/lib/core/reporters/raw.js b/lib/core/reporters/raw.js index b9b78fa041..eae5e5f381 100644 --- a/lib/core/reporters/raw.js +++ b/lib/core/reporters/raw.js @@ -1,3 +1,5 @@ +import { nodeSerializer } from '../utils'; + const rawReporter = (results, options, callback) => { if (typeof options === 'function') { callback = options; @@ -13,16 +15,9 @@ const rawReporter = (results, options, callback) => { const transformedResult = { ...result }; const types = ['passes', 'violations', 'incomplete', 'inapplicable']; for (const type of types) { - // Some tests don't include all of the types, so we have to guard against that here. - // TODO: ensure tests always use "proper" results to avoid having these hacks in production code paths. - if (transformedResult[type] && Array.isArray(transformedResult[type])) { - transformedResult[type] = transformedResult[type].map( - ({ node, ...typeResult }) => { - node = typeof node?.toJSON === 'function' ? node.toJSON() : node; - return { node, ...typeResult }; - } - ); - } + transformedResult[type] = nodeSerializer.mapRawNodeResults( + transformedResult[type] + ); } return transformedResult; diff --git a/lib/core/utils/check-helper.js b/lib/core/utils/check-helper.js index 0156e60e5a..fbedf3645c 100644 --- a/lib/core/utils/check-helper.js +++ b/lib/core/utils/check-helper.js @@ -43,7 +43,7 @@ function checkHelper(checkResult, options, resolve, reject) { node = node.actualNode; } if (node instanceof window.Node) { - const dqElm = new DqElement(node, options); + const dqElm = new DqElement(node); checkResult.relatedNodes.push(dqElm); } }); diff --git a/lib/core/utils/collect-results-from-frames.js b/lib/core/utils/collect-results-from-frames.js index 6d37f57b42..be17f8ccfd 100644 --- a/lib/core/utils/collect-results-from-frames.js +++ b/lib/core/utils/collect-results-from-frames.js @@ -19,6 +19,9 @@ export default function collectResultsFromFrames( resolve, reject ) { + // elementRefs can't be passed across frame boundaries + options = { ...options, elementRef: false }; + var q = queue(); var frames = parentContent.frames; diff --git a/lib/core/utils/dq-element.js b/lib/core/utils/dq-element.js index db1c5f65bb..3cf790fe1a 100644 --- a/lib/core/utils/dq-element.js +++ b/lib/core/utils/dq-element.js @@ -3,6 +3,9 @@ import getAncestry from './get-ancestry'; import getXpath from './get-xpath'; import getNodeFromTree from './get-node-from-tree'; import AbstractVirtualNode from '../base/virtual-node/abstract-virtual-node'; +import cache from '../base/cache'; + +const CACHE_KEY = 'DqElm.RunOptions'; function truncate(str, maxLength) { maxLength = maxLength || 300; @@ -30,9 +33,14 @@ function getSource(element) { * "Serialized" `HTMLElement`. It will calculate the CSS selector, * grab the source (outerHTML) and offer an array for storing frame paths * @param {HTMLElement} element The element to serialize + * @param {Object} options Propagated from axe.run/etc * @param {Object} spec Properties to use in place of the element when instantiated on Elements from other frames */ -function DqElement(elm, options = {}, spec = {}) { +function DqElement(elm, options = null, spec = {}) { + if (!options) { + options = cache.get(CACHE_KEY) ?? {}; + } + this.spec = spec; if (elm instanceof AbstractVirtualNode) { this._virtualNode = elm; @@ -48,6 +56,8 @@ function DqElement(elm, options = {}, spec = {}) { */ this.fromFrame = this.spec.selector?.length > 1; + this._includeElementInJson = options.elementRef; + if (options.absolutePaths) { this._options = { toRoot: true }; } @@ -106,14 +116,24 @@ DqElement.prototype = { return this._element; }, + /** + * Converts to a "spec", a form suitable for use with JSON.stringify + * (*not* to pre-stringified JSON) + * @returns {Object} + */ toJSON() { - return { + const spec = { selector: this.selector, source: this.source, xpath: this.xpath, ancestry: this.ancestry, - nodeIndexes: this.nodeIndexes + nodeIndexes: this.nodeIndexes, + fromFrame: this.fromFrame }; + if (this._includeElementInJson) { + spec.element = this._element; + } + return spec; } }; @@ -122,14 +142,29 @@ DqElement.fromFrame = function fromFrame(node, options, frame) { return new DqElement(frame.element, options, spec); }; -DqElement.mergeSpecs = function mergeSpec(node, frame) { +DqElement.mergeSpecs = function mergeSpecs(child, parentFrame) { + // Parameter order reversed for backcompat return { - ...node, - selector: [...frame.selector, ...node.selector], - ancestry: [...frame.ancestry, ...node.ancestry], - xpath: [...frame.xpath, ...node.xpath], - nodeIndexes: [...frame.nodeIndexes, ...node.nodeIndexes] + ...child, + selector: [...parentFrame.selector, ...child.selector], + ancestry: [...parentFrame.ancestry, ...child.ancestry], + xpath: [...parentFrame.xpath, ...child.xpath], + nodeIndexes: [...parentFrame.nodeIndexes, ...child.nodeIndexes], + fromFrame: true }; }; +/** + * Set the default options to be used + * @param {Object} RunOptions Options passed to axe.run() + * @property {boolean} elementRef Add element when toJSON is called + * @property {boolean} absolutePaths Use absolute path fro selectors + */ +DqElement.setRunOptions = function setRunOptions({ + elementRef, + absolutePaths +}) { + cache.set(CACHE_KEY, { elementRef, absolutePaths }); +}; + export default DqElement; diff --git a/lib/core/utils/index.js b/lib/core/utils/index.js index ad166f1326..6e18e7f99c 100644 --- a/lib/core/utils/index.js +++ b/lib/core/utils/index.js @@ -56,6 +56,7 @@ export { export { default as matchAncestry } from './match-ancestry'; export { default as memoize } from './memoize'; export { default as mergeResults } from './merge-results'; +export { default as nodeSerializer } from './node-serializer'; export { default as nodeSorter } from './node-sorter'; export { default as nodeLookup } from './node-lookup'; export { default as parseCrossOriginStylesheet } from './parse-crossorigin-stylesheet'; diff --git a/lib/core/utils/merge-results.js b/lib/core/utils/merge-results.js index 8d1f2af6dc..e5c9547adf 100644 --- a/lib/core/utils/merge-results.js +++ b/lib/core/utils/merge-results.js @@ -1,4 +1,4 @@ -import DqElement from './dq-element'; +import nodeSerializer from './node-serializer'; import getAllChecks from './get-all-checks'; import findBy from './find-by'; @@ -6,17 +6,17 @@ import findBy from './find-by'; * Adds the owning frame's CSS selector onto each instance of DqElement * @private * @param {Array} resultSet `nodes` array on a `RuleResult` - * @param {HTMLElement} frameElement The frame element - * @param {String} frameSelector Unique CSS selector for the frame + * @param {Object} options Propagated from axe.run/etc + * @param {Object} frameSpec The spec describing the owning frame (see nodeSerializer.toSpec) */ function pushFrame(resultSet, options, frameSpec) { resultSet.forEach(res => { - res.node = DqElement.fromFrame(res.node, options, frameSpec); + res.node = nodeSerializer.mergeSpecs(res.node, frameSpec); const checks = getAllChecks(res); checks.forEach(check => { check.relatedNodes = check.relatedNodes.map(node => - DqElement.fromFrame(node, options, frameSpec) + nodeSerializer.mergeSpecs(node, frameSpec) ); }); }); @@ -68,8 +68,10 @@ function normalizeResult(result) { /** * Merges one or more RuleResults (possibly from different frames) into one RuleResult * @private - * @param {Array} frameResults Array of objects including the RuleResults as `results` and frame as `frame` - * @return {Array} The merged RuleResults; should only have one result per rule + * @param {Array} frameResults Array of objects including the RuleResults as `results` and + * owning frame as either an Element `frameElement` or a spec `frameSpec` (see nodeSerializer.toSpec) + * @param {Object} options Propagated from axe.run/etc + * @return {Array} The merged RuleResults; should only have one result per rule */ function mergeResults(frameResults, options) { const mergedResult = []; @@ -79,7 +81,7 @@ function mergeResults(frameResults, options) { return; } - const frameSpec = getFrameSpec(frameResult, options); + const frameSpec = getFrameSpec(frameResult); results.forEach(ruleResult => { if (ruleResult.nodes && frameSpec) { pushFrame(ruleResult.nodes, options, frameSpec); @@ -128,9 +130,9 @@ function nodeIndexSort(nodeIndexesA = [], nodeIndexesB = []) { export default mergeResults; -function getFrameSpec(frameResult, options) { +function getFrameSpec(frameResult) { if (frameResult.frameElement) { - return new DqElement(frameResult.frameElement, options); + return nodeSerializer.toSpec(frameResult.frameElement); } else if (frameResult.frameSpec) { return frameResult.frameSpec; } diff --git a/lib/core/utils/node-serializer.js b/lib/core/utils/node-serializer.js new file mode 100644 index 0000000000..8a3c805f0d --- /dev/null +++ b/lib/core/utils/node-serializer.js @@ -0,0 +1,133 @@ +import assert from './assert'; +import DqElement from './dq-element'; + +let customSerializer = null; + +const nodeSerializer = { + /** + * @param {Object} newSerializer + * @property {Function} toSpec (Optional) Converts a DqElement to a "spec", a form + * suitable for JSON.stringify to consume. Output must include all properties + * that DqElement.toJSON() would have. Will always be invoked from the + * input element's original page context. + * @property {Function} mergeSpecs (Optional) Merges two specs (produced by toSpec) which + * represent element's parent frame and an element, respectively. Will + * *not* necessarily be invoked from *either* node's original page context. + * This operation must be associative, that is, these two expressions must + * produce the same result: + * - mergeSpecs(a, mergeSpecs(b, c)) + * - mergeSpecs(mergeSpecs(a, b), c) + */ + update(serializer) { + assert(typeof serializer === 'object', 'serializer must be an object'); + customSerializer = serializer; + }, + + /** + * Converts an Element or VirtualNode to something that can be serialized. + * @param {Element|VirtualNode} node + * @return {Object} A "spec", a form suitable for JSON.stringify to consume. + */ + toSpec(node) { + return nodeSerializer.dqElmToSpec(new DqElement(node)); + }, + + /** + * Converts an DqElement to a serializable object. Optionally provide runOptions + * to limit which properties are included. + * @param {DqElement|SpecObject} dqElm + * @param {Object} runOptions (Optional) Set of options passed into rules or checks + * @param {Boolean} runOptions.selectors (Optional) Include selector in output + * @param {Boolean} runOptions.ancestry (Optional) Include ancestry in output + * @param {Boolean} runOptions.xpath (Optional) Include xpath in output + * @return {SpecObject} A "spec", a form suitable for JSON.stringify to consume. + */ + dqElmToSpec(dqElm, runOptions) { + if (dqElm instanceof DqElement === false) { + return dqElm; + } + // Optionally remove selector, ancestry, xpath + // to prevent unnecessary calculations + if (runOptions) { + dqElm = cloneLimitedDqElement(dqElm, runOptions); + } + + if (typeof customSerializer?.toSpec === 'function') { + return customSerializer.toSpec(dqElm); + } + return dqElm.toJSON(); + }, + + /** + * Merges two specs (produced by toSpec) which represent + * element's parent frame and an element, + * @param {Object} nodeSpec + * @param {Object} parentFrameSpec + * @returns {Object} The merged spec + */ + mergeSpecs(nodeSpec, parentFrameSpec) { + if (typeof customSerializer?.mergeSpecs === 'function') { + return customSerializer.mergeSpecs(nodeSpec, parentFrameSpec); + } + return DqElement.mergeSpecs(nodeSpec, parentFrameSpec); + }, + + /** + * Convert DqElements in RawResults to serialized nodes + * @param {undefined|RawNodeResult[]} rawResults + * @returns {undefined|RawNodeResult[]} + */ + mapRawResults(rawResults) { + return rawResults.map(rawResult => ({ + ...rawResult, + nodes: nodeSerializer.mapRawNodeResults(rawResult.nodes) + })); + }, + + /** + * Convert DqElements in RawNodeResults to serialized nodes + * @param {undefined|RawNodeResult[]} rawResults + * @returns {undefined|RawNodeResult[]} + */ + mapRawNodeResults(nodeResults) { + return nodeResults?.map(({ node, ...nodeResult }) => { + nodeResult.node = nodeSerializer.dqElmToSpec(node); + + for (const type of ['any', 'all', 'none']) { + nodeResult[type] = nodeResult[type].map( + ({ relatedNodes, ...checkResult }) => { + checkResult.relatedNodes = relatedNodes.map( + nodeSerializer.dqElmToSpec + ); + return checkResult; + } + ); + } + return nodeResult; + }); + } +}; + +export default nodeSerializer; + +/** + * Create a new DqElement with only the properties we actually want serialized + * This prevents nodeSerializer from generating selectors / xpath / ancestry + * when it's not needed. The rest is dummy data to prevent possible errors. + */ +function cloneLimitedDqElement(dqElm, runOptions) { + const fromFrame = dqElm.fromFrame; + const { ancestry: hasAncestry, xpath: hasXpath } = runOptions; + const hasSelectors = runOptions.selectors !== false || fromFrame; + + dqElm = new DqElement(dqElm.element, runOptions, { + source: dqElm.source, + nodeIndexes: dqElm.nodeIndexes, + selector: hasSelectors ? dqElm.selector : [':root'], + ancestry: hasAncestry ? dqElm.ancestry : [':root'], + xpath: hasXpath ? dqElm.xpath : '/' + }); + + dqElm.fromFrame = fromFrame; + return dqElm; +} diff --git a/test/core/base/audit.js b/test/core/base/audit.js index 5830ff5da6..7160c0db1c 100644 --- a/test/core/base/audit.js +++ b/test/core/base/audit.js @@ -2,7 +2,8 @@ describe('Audit', () => { const Audit = axe._thisWillBeDeletedDoNotUse.base.Audit; const Rule = axe._thisWillBeDeletedDoNotUse.base.Rule; const ver = axe.version.substring(0, axe.version.lastIndexOf('.')); - let a, getFlattenedTree; + const { fixtureSetup } = axe.testUtils; + let audit; const isNotCalled = function (err) { throw err || new Error('Reject should not be called'); }; @@ -69,24 +70,21 @@ describe('Audit', () => { const fixture = document.getElementById('fixture'); let origAuditRun; - let origAxeUtilsPreload; beforeEach(() => { - a = new Audit(); + audit = new Audit(); mockRules.forEach(function (r) { - a.addRule(r); + audit.addRule(r); }); mockChecks.forEach(function (c) { - a.addCheck(c); + audit.addCheck(c); }); - origAuditRun = a.run; + origAuditRun = audit.run; }); afterEach(() => { - fixture.innerHTML = ''; - axe._tree = undefined; - axe._selectCache = undefined; - a.run = origAuditRun; + axe.teardown(); + audit.run = origAuditRun; }); it('should be a function', () => { @@ -95,19 +93,19 @@ describe('Audit', () => { describe('defaults', () => { it('should set noHtml', () => { - const audit = new Audit(); + audit = new Audit(); assert.isFalse(audit.noHtml); }); it('should set allowedOrigins', () => { - const audit = new Audit(); + audit = new Audit(); assert.deepEqual(audit.allowedOrigins, [window.location.origin]); }); }); describe('Audit#_constructHelpUrls', () => { it('should create default help URLS', () => { - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', matches: 'function () {return "hello";}', @@ -124,7 +122,7 @@ describe('Audit', () => { }); }); it('should use changed branding', () => { - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', matches: 'function () {return "hello";}', @@ -142,7 +140,7 @@ describe('Audit', () => { }); }); it('should use changed application', () => { - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', matches: 'function () {return "hello";}', @@ -161,7 +159,7 @@ describe('Audit', () => { }); it('does not override helpUrls of different products', () => { - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target1', matches: 'function () {return "hello";}', @@ -206,7 +204,7 @@ describe('Audit', () => { }); it('understands prerelease type version numbers', () => { const tempVersion = axe.version; - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', matches: 'function () {return "hello";}', @@ -225,7 +223,7 @@ describe('Audit', () => { it('matches major release versions', () => { const tempVersion = axe.version; - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', matches: 'function () {return "hello";}', @@ -242,7 +240,7 @@ describe('Audit', () => { ); }); it('sets the lang query if locale has been set', () => { - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', matches: 'function () {return "hello";}', @@ -265,7 +263,7 @@ describe('Audit', () => { describe('Audit#setBranding', () => { it('should change the brand', () => { - const audit = new Audit(); + audit = new Audit(); assert.equal(audit.brand, 'axe'); assert.equal(audit.application, 'axeAPI'); audit.setBranding({ @@ -275,7 +273,7 @@ describe('Audit', () => { assert.equal(audit.application, 'axeAPI'); }); it('should change the application', () => { - const audit = new Audit(); + audit = new Audit(); assert.equal(audit.brand, 'axe'); assert.equal(audit.application, 'axeAPI'); audit.setBranding({ @@ -285,7 +283,7 @@ describe('Audit', () => { assert.equal(audit.application, 'thing'); }); it('should change the application when passed a string', () => { - const audit = new Audit(); + audit = new Audit(); assert.equal(audit.brand, 'axe'); assert.equal(audit.application, 'axeAPI'); audit.setBranding('thing'); @@ -293,7 +291,7 @@ describe('Audit', () => { assert.equal(audit.application, 'thing'); }); it('should call _constructHelpUrls', () => { - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', matches: 'function () {return "hello";}', @@ -312,7 +310,7 @@ describe('Audit', () => { }); }); it('should call _constructHelpUrls even when nothing changed', () => { - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', matches: 'function () {return "hello";}', @@ -329,7 +327,7 @@ describe('Audit', () => { }); }); it('should not replace custom set branding', () => { - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', matches: 'function () {return "hello";}', @@ -356,7 +354,7 @@ describe('Audit', () => { describe('Audit#addRule', () => { it('should override existing rule', () => { - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', matches: 'function () {return "hello";}', @@ -376,7 +374,7 @@ describe('Audit', () => { assert.equal(audit.rules[0].matches(), 'hello'); }); it('should otherwise push new rule', () => { - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'target', selector: 'bob' @@ -398,7 +396,7 @@ describe('Audit', () => { describe('Audit#resetRulesAndChecks', () => { it('should override newly created check', () => { - const audit = new Audit(); + audit = new Audit(); assert.equal(audit.checks.target, undefined); audit.addCheck({ id: 'target', @@ -410,7 +408,7 @@ describe('Audit', () => { assert.equal(audit.checks.target, undefined); }); it('should reset locale', () => { - const audit = new Audit(); + audit = new Audit(); assert.equal(audit.lang, 'en'); audit.applyLocale({ lang: 'de' @@ -420,7 +418,7 @@ describe('Audit', () => { assert.equal(audit.lang, 'en'); }); it('should reset brand', () => { - const audit = new Audit(); + audit = new Audit(); assert.equal(audit.brand, 'axe'); audit.setBranding({ brand: 'test' @@ -430,7 +428,7 @@ describe('Audit', () => { assert.equal(audit.brand, 'axe'); }); it('should reset brand application', () => { - const audit = new Audit(); + audit = new Audit(); assert.equal(audit.application, 'axeAPI'); audit.setBranding({ application: 'test' @@ -450,14 +448,14 @@ describe('Audit', () => { }); it('should reset noHtml', () => { - const audit = new Audit(); + audit = new Audit(); audit.noHtml = true; audit.resetRulesAndChecks(); assert.isFalse(audit.noHtml); }); it('should reset allowedOrigins', () => { - const audit = new Audit(); + audit = new Audit(); audit.allowedOrigins = ['hello']; audit.resetRulesAndChecks(); assert.deepEqual(audit.allowedOrigins, [window.location.origin]); @@ -466,7 +464,7 @@ describe('Audit', () => { describe('Audit#addCheck', () => { it('should create a new check', () => { - const audit = new Audit(); + audit = new Audit(); assert.equal(audit.checks.target, undefined); audit.addCheck({ id: 'target', @@ -476,7 +474,7 @@ describe('Audit', () => { assert.deepEqual(audit.checks.target.options, { value: 'jane' }); }); it('should configure the metadata, if passed', () => { - const audit = new Audit(); + audit = new Audit(); assert.equal(audit.checks.target, undefined); audit.addCheck({ id: 'target', @@ -486,7 +484,7 @@ describe('Audit', () => { assert.equal(audit.data.checks.target.guy, 'bob'); }); it('should reconfigure existing check', () => { - const audit = new Audit(); + audit = new Audit(); const myTest = () => {}; audit.addCheck({ id: 'target', @@ -505,7 +503,7 @@ describe('Audit', () => { assert.deepEqual(audit.checks.target.options, { value: 'fred' }); }); it('should not turn messages into a function', () => { - const audit = new Audit(); + audit = new Audit(); const spec = { id: 'target', evaluate: 'function () { return "blah";}', @@ -523,7 +521,7 @@ describe('Audit', () => { }); it('should turn function strings into a function', () => { - const audit = new Audit(); + audit = new Audit(); const spec = { id: 'target', evaluate: 'function () { return "blah";}', @@ -543,7 +541,7 @@ describe('Audit', () => { describe('Audit#setAllowedOrigins', () => { it('should set allowedOrigins', () => { - const audit = new Audit(); + audit = new Audit(); audit.setAllowedOrigins([ 'https://deque.com', 'https://dequeuniversity.com' @@ -555,7 +553,7 @@ describe('Audit', () => { }); it('should normalize ', () => { - const audit = new Audit(); + audit = new Audit(); audit.setAllowedOrigins(['', 'https://deque.com']); assert.deepEqual(audit.allowedOrigins, [ window.location.origin, @@ -564,7 +562,7 @@ describe('Audit', () => { }); it('should normalize ', () => { - const audit = new Audit(); + audit = new Audit(); audit.setAllowedOrigins([ 'https://deque.com', '', @@ -576,13 +574,14 @@ describe('Audit', () => { describe('Audit#run', () => { it('should run all the rules', done => { - fixture.innerHTML = + fixtureSetup( '' + - '
bananas
' + - '' + - 'FAIL ME'; + '
bananas
' + + '' + + 'FAIL ME' + ); - a.run( + audit.run( { include: [axe.utils.getFlattenedTree(fixture)[0]] }, {}, function (results) { @@ -636,7 +635,7 @@ describe('Audit', () => { }); it('should not run rules disabled by the options', done => { - a.run( + audit.run( { include: [axe.utils.getFlattenedTree()[0]] }, { rules: { @@ -656,7 +655,7 @@ describe('Audit', () => { it('should ensure audit.run recieves preload options', done => { fixture.innerHTML = ''; - const audit = new Audit(); + audit = new Audit(); audit.addRule({ id: 'preload1', selector: '*' @@ -730,7 +729,7 @@ describe('Audit', () => { }); }; - const audit = new Audit(); + audit = new Audit(); // add a rule and check that does not need preload audit.addRule({ id: 'no-preload', @@ -811,7 +810,7 @@ describe('Audit', () => { }); }; - const audit = new Audit(); + audit = new Audit(); // add a rule and check that does not need preload audit.addRule({ id: 'no-preload', @@ -882,7 +881,7 @@ describe('Audit', () => { return Promise.reject(rejectionMsg); }; - const audit = new Audit(); + audit = new Audit(); // add a rule and check that does not need preload audit.addRule({ id: 'no-preload', @@ -946,7 +945,7 @@ describe('Audit', () => { // the actual axios call is invoked, and timedout immediately as timeout is set to 0.1 let preloadNeededCheckInvoked = false; - const audit = new Audit(); + audit = new Audit(); // add a rule and check that does not need preload audit.addRule({ id: 'no-preload', @@ -968,6 +967,7 @@ describe('Audit', () => { return true; } }); + axe.setup(); const preloadOptions = { preload: { @@ -1007,7 +1007,7 @@ describe('Audit', () => { assert.equal(axe._selectCache.length, 0); return false; }; - a.run( + audit.run( { include: [axe.utils.getFlattenedTree()[0]] }, {}, () => { @@ -1019,7 +1019,7 @@ describe('Audit', () => { }); it('should clear axe._selectCache', done => { - a.run( + audit.run( { include: [axe.utils.getFlattenedTree()[0]] }, { rules: {} @@ -1033,7 +1033,7 @@ describe('Audit', () => { }); it('should not run rules disabled by the configuration', done => { - const audit = new Audit(); + audit = new Audit(); const success = true; audit.rules.push( new Rule({ @@ -1063,7 +1063,7 @@ describe('Audit', () => { it("should call the rule's run function", done => { const targetRule = mockRules[mockRules.length - 1]; - const rule = axe.utils.findBy(a.rules, 'id', targetRule.id); + const rule = axe.utils.findBy(audit.rules, 'id', targetRule.id); let called = false; let orig; @@ -1073,7 +1073,7 @@ describe('Audit', () => { called = true; callback({}); }; - a.run( + audit.run( { include: [axe.utils.getFlattenedTree()[0]] }, {}, () => { @@ -1087,7 +1087,7 @@ describe('Audit', () => { it('should pass the option to the run function', done => { const targetRule = mockRules[mockRules.length - 1]; - const rule = axe.utils.findBy(a.rules, 'id', targetRule.id); + const rule = axe.utils.findBy(audit.rules, 'id', targetRule.id); let passed = false; let orig; let options; @@ -1101,7 +1101,7 @@ describe('Audit', () => { }; options = { rules: {} }; (options.rules[targetRule.id] = {}).data = 'monkeys'; - a.run( + audit.run( { include: [axe.utils.getFlattenedTree()[0]] }, options, () => { @@ -1114,7 +1114,7 @@ describe('Audit', () => { }); it('should skip pageLevel rules if context is not set to entire page', () => { - const audit = new Audit(); + audit = new Audit(); audit.rules.push( new Rule({ @@ -1141,7 +1141,7 @@ describe('Audit', () => { it('catches errors and passes them as a cantTell result', done => { const err = new Error('Launch the super sheep!'); - a.addRule({ + audit.addRule({ id: 'throw1', selector: '*', any: [ @@ -1150,7 +1150,7 @@ describe('Audit', () => { } ] }); - a.addCheck({ + audit.addCheck({ id: 'throw1-check1', evaluate: () => { throw err; @@ -1158,7 +1158,7 @@ describe('Audit', () => { }); axe._tree = axe.utils.getFlattenedTree(fixture); axe._selectorData = axe.utils.getSelectorData(axe._tree); - a.run( + audit.run( { include: [axe._tree[0]] }, { runOnly: { @@ -1179,7 +1179,7 @@ describe('Audit', () => { }); it('should not halt if errors occur', done => { - a.addRule({ + audit.addRule({ id: 'throw1', selector: '*', any: [ @@ -1188,13 +1188,13 @@ describe('Audit', () => { } ] }); - a.addCheck({ + audit.addCheck({ id: 'throw1-check1', evaluate: () => { throw new Error('Launch the super sheep!'); } }); - a.run( + audit.run( { include: [axe.utils.getFlattenedTree(fixture)[0]] }, { runOnly: { @@ -1217,11 +1217,11 @@ describe('Audit', () => { 'FAIL ME'; let checked = 'options not validated'; - a.normalizeOptions = () => { + audit.normalizeOptions = () => { checked = 'options validated'; }; - a.run( + audit.run( { include: [axe.utils.getFlattenedTree(fixture)[0]] }, {}, noop, @@ -1231,7 +1231,7 @@ describe('Audit', () => { }); it('should halt if an error occurs when debug is set', done => { - a.addRule({ + audit.addRule({ id: 'throw1', selector: '*', any: [ @@ -1240,7 +1240,7 @@ describe('Audit', () => { } ] }); - a.addCheck({ + audit.addCheck({ id: 'throw1-check1', evaluate: () => { throw new Error('Launch the super sheep!'); @@ -1250,7 +1250,7 @@ describe('Audit', () => { // check error node requires _selectorCache to be setup axe.setup(); - a.run( + audit.run( { include: [axe.utils.getFlattenedTree(fixture)[0]] }, { debug: true, @@ -1266,12 +1266,27 @@ describe('Audit', () => { } ); }); + + it('propagates DqElement options', async () => { + fixtureSetup(''); + const results = await new Promise((resolve, reject) => { + audit.run( + { include: [axe.utils.getFlattenedTree(fixture)[0]] }, + { elementRef: true, absolutePaths: true }, + resolve, + reject + ); + }); + const { node } = results[0].nodes[0]; + assert.equal(node.element, fixture.firstChild); + assert.equal(node.selector, 'html > body > #fixture > #input'); + }); }); describe('Audit#after', () => { it('should run Rule#after on any rule whose result is passed in', () => { /*eslint no-unused-vars:0*/ - const audit = new Audit(); + audit = new Audit(); let success = false; const options = [{ id: 'hehe', enabled: true, monkeys: 'bananas' }]; const results = [ @@ -1317,7 +1332,7 @@ describe('Audit', () => { negative1: { enabled: false } } }; - assert(a.normalizeOptions(opt), opt); + assert(audit.normalizeOptions(opt), opt); }); it('allows `value` as alternative to `values`', () => { @@ -1327,7 +1342,7 @@ describe('Audit', () => { value: ['positive1', 'positive2'] } }; - const out = a.normalizeOptions(opt); + const out = audit.normalizeOptions(opt); assert.deepEqual(out.runOnly.values, ['positive1', 'positive2']); assert.isUndefined(out.runOnly.value); }); @@ -1339,7 +1354,7 @@ describe('Audit', () => { values: ['positive1', 'positive2'] } }; - assert(a.normalizeOptions(opt).runOnly.type, 'rule'); + assert(audit.normalizeOptions(opt).runOnly.type, 'rule'); }); it('allows type: tags as an alternative to type: tag', () => { @@ -1349,7 +1364,7 @@ describe('Audit', () => { values: ['positive'] } }; - assert(a.normalizeOptions(opt).runOnly.type, 'tag'); + assert(audit.normalizeOptions(opt).runOnly.type, 'tag'); }); it('allows type: undefined as an alternative to type: tag', () => { @@ -1358,33 +1373,33 @@ describe('Audit', () => { values: ['positive'] } }; - assert(a.normalizeOptions(opt).runOnly.type, 'tag'); + assert(audit.normalizeOptions(opt).runOnly.type, 'tag'); }); it('allows runOnly as an array as an alternative to type: tag', () => { const opt = { runOnly: ['positive', 'negative'] }; - const out = a.normalizeOptions(opt); + const out = audit.normalizeOptions(opt); assert(out.runOnly.type, 'tag'); assert.deepEqual(out.runOnly.values, ['positive', 'negative']); }); it('allows runOnly as an array as an alternative to type: rule', () => { const opt = { runOnly: ['positive1', 'negative1'] }; - const out = a.normalizeOptions(opt); + const out = audit.normalizeOptions(opt); assert(out.runOnly.type, 'rule'); assert.deepEqual(out.runOnly.values, ['positive1', 'negative1']); }); it('allows runOnly as a string as an alternative to an array', () => { const opt = { runOnly: 'positive1' }; - const out = a.normalizeOptions(opt); + const out = audit.normalizeOptions(opt); assert(out.runOnly.type, 'rule'); assert.deepEqual(out.runOnly.values, ['positive1']); }); it('throws an error if runOnly contains both rules and tags', () => { assert.throws(() => { - a.normalizeOptions({ + audit.normalizeOptions({ runOnly: ['positive', 'negative1'] }); }); @@ -1392,14 +1407,14 @@ describe('Audit', () => { it('defaults runOnly to type: tag', () => { const opt = { runOnly: ['fakeTag'] }; - const out = a.normalizeOptions(opt); + const out = audit.normalizeOptions(opt); assert(out.runOnly.type, 'tag'); assert.deepEqual(out.runOnly.values, ['fakeTag']); }); it('throws an error runOnly.values not an array', () => { assert.throws(() => { - a.normalizeOptions({ + audit.normalizeOptions({ runOnly: { type: 'rule', values: { badProp: 'badValue' } @@ -1410,7 +1425,7 @@ describe('Audit', () => { it('throws an error runOnly.values an empty', () => { assert.throws(() => { - a.normalizeOptions({ + audit.normalizeOptions({ runOnly: { type: 'rule', values: [] @@ -1421,7 +1436,7 @@ describe('Audit', () => { it('throws an error runOnly.type is unknown', () => { assert.throws(() => { - a.normalizeOptions({ + audit.normalizeOptions({ runOnly: { type: 'something-else', values: ['wcag2aa'] @@ -1432,7 +1447,7 @@ describe('Audit', () => { it('throws an error when option.runOnly has an unknown rule', () => { assert.throws(() => { - a.normalizeOptions({ + audit.normalizeOptions({ runOnly: { type: 'rule', values: ['frakeRule'] @@ -1443,7 +1458,7 @@ describe('Audit', () => { it("doesn't throw an error when option.runOnly has an unknown tag", () => { assert.doesNotThrow(() => { - a.normalizeOptions({ + audit.normalizeOptions({ runOnly: { type: 'tags', values: ['fakeTag'] @@ -1454,7 +1469,7 @@ describe('Audit', () => { it('throws an error when option.rules has an unknown rule', () => { assert.throws(() => { - a.normalizeOptions({ + audit.normalizeOptions({ rules: { fakeRule: { enabled: false } } @@ -1467,7 +1482,7 @@ describe('Audit', () => { axe.log = function (m) { message = m; }; - a.normalizeOptions({ + audit.normalizeOptions({ runOnly: { type: 'tags', values: ['unknwon-tag'] @@ -1481,7 +1496,7 @@ describe('Audit', () => { axe.log = function (m) { message = m; }; - a.normalizeOptions({ + audit.normalizeOptions({ runOnly: { type: 'tags', values: ['wcag23aaa'] @@ -1495,7 +1510,7 @@ describe('Audit', () => { axe.log = function (m) { message = m; }; - a.normalizeOptions({ + audit.normalizeOptions({ runOnly: { type: 'tags', values: ['wcag23aaa', 'unknwon-tag'] diff --git a/test/core/base/rule.js b/test/core/base/rule.js index b421021b2e..421c06d76d 100644 --- a/test/core/base/rule.js +++ b/test/core/base/rule.js @@ -4,6 +4,7 @@ describe('Rule', () => { const metadataFunctionMap = axe._thisWillBeDeletedDoNotUse.base.metadataFunctionMap; const fixture = document.getElementById('fixture'); + const { fixtureSetup } = axe.testUtils; const noop = () => {}; const isNotCalled = function (err) { throw err || new Error('Reject should not be called'); @@ -114,8 +115,7 @@ describe('Rule', () => { ); }); it('should exclude hidden elements', () => { - fixture.innerHTML = - '
HEHEHE
'; + fixtureSetup('
HEHEHE
'); const rule = new Rule({}), result = rule.gather({ @@ -129,7 +129,7 @@ describe('Rule', () => { assert.lengthOf(result, 0); }); it('should include hidden elements if excludeHidden is false', () => { - fixture.innerHTML = '
'; + fixtureSetup('
'); const rule = new Rule({ excludeHidden: false @@ -258,7 +258,7 @@ describe('Rule', () => { }); it('should execute Check#run on its child checks - any', done => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); let success = false; const rule = new Rule( { @@ -290,7 +290,7 @@ describe('Rule', () => { }); it('should execute Check#run on its child checks - all', done => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); let success = false; const rule = new Rule( { @@ -322,7 +322,7 @@ describe('Rule', () => { }); it('should execute Check#run on its child checks - none', done => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); let success = false; const rule = new Rule( { @@ -355,7 +355,7 @@ describe('Rule', () => { }); it('should pass the matching option to check.run', done => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); const options = { checks: { cats: { @@ -394,7 +394,7 @@ describe('Rule', () => { }); it('should pass the matching option to check.run defined on the rule over global', done => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); const options = { rules: { cats: { @@ -485,7 +485,7 @@ describe('Rule', () => { axe.utils.DqElement = () => { isDqElementCalled = true; }; - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); }); afterEach(() => { @@ -616,7 +616,7 @@ describe('Rule', () => { }); it('should pass thrown errors to the reject param', done => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); const rule = new Rule( { none: ['cats'] @@ -647,7 +647,7 @@ describe('Rule', () => { }); it('should pass reject calls to the reject param', done => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); const rule = new Rule( { none: ['cats'] @@ -678,6 +678,7 @@ describe('Rule', () => { }); it('should mark checks as incomplete if reviewOnFail is set to true', done => { + axe.setup(); const rule = new Rule( { reviewOnFail: true, @@ -720,6 +721,7 @@ describe('Rule', () => { describe('NODE rule', () => { it('should create a RuleResult', () => { + axe.setup(); const orig = window.RuleResult; let success = false; window.RuleResult = function (r) { @@ -760,7 +762,9 @@ describe('Rule', () => { window.RuleResult = orig; }); + it('should execute rule callback', () => { + axe.setup(); let success = false; const rule = new Rule( @@ -905,7 +909,7 @@ describe('Rule', () => { }); it('should execute Check#runSync on its child checks - any', () => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); let success = false; const rule = new Rule( { @@ -936,7 +940,7 @@ describe('Rule', () => { }); it('should execute Check#runSync on its child checks - all', () => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); let success = false; const rule = new Rule( { @@ -967,7 +971,7 @@ describe('Rule', () => { }); it('should execute Check#run on its child checks - none', () => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); let success = false; const rule = new Rule( { @@ -999,7 +1003,7 @@ describe('Rule', () => { }); it('should pass the matching option to check.runSync', () => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); const options = { checks: { cats: { @@ -1038,7 +1042,7 @@ describe('Rule', () => { }); it('should pass the matching option to check.runSync defined on the rule over global', () => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); const options = { rules: { cats: { @@ -1131,7 +1135,7 @@ describe('Rule', () => { axe.utils.DqElement = () => { isDqElementCalled = true; }; - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); }); afterEach(() => { @@ -1317,7 +1321,7 @@ describe('Rule', () => { }); it('should pass thrown errors to the reject param', () => { - fixture.innerHTML = 'Hi'; + fixtureSetup('Hi'); const rule = new Rule( { none: ['cats'] @@ -1347,6 +1351,7 @@ describe('Rule', () => { }); it('should mark checks as incomplete if reviewOnFail is set to true', () => { + axe.setup(); const rule = new Rule( { reviewOnFail: true, @@ -1659,6 +1664,7 @@ describe('Rule', () => { describe('after', () => { it('should mark checks as incomplete if reviewOnFail is set to true for all', () => { + axe.setup(); const rule = new Rule( { id: 'cats', diff --git a/test/core/public/run-partial.js b/test/core/public/run-partial.js index 515de33548..3d99e5662f 100644 --- a/test/core/public/run-partial.js +++ b/test/core/public/run-partial.js @@ -55,6 +55,14 @@ describe('axe.runPartial', function () { .catch(done); }); + it('ignores { elementRef: true } option', async () => { + const options = { elementRef: true }; + const result = await axe.runPartial(options); + for (const nodeResult of result.results[0].nodes) { + assert.isUndefined(nodeResult.node.element); + } + }); + describe('result', function () { var partialResult; before(function (done) { @@ -91,6 +99,24 @@ describe('axe.runPartial', function () { assert.hasAllKeys(checkResult.node, dqElementKeys); }); + it('does not return DqElement objects', () => { + for (const result of partialResult.results) { + for (const nodeResult of result.nodes) { + assert.notInstanceOf(nodeResult.node, DqElement); + const checks = [ + ...nodeResult.any, + ...nodeResult.all, + ...nodeResult.none + ]; + for (const check of checks) { + for (const relatedNode of check.relatedNodes) { + assert.notInstanceOf(relatedNode, DqElement); + } + } + } + } + }); + it('can be serialized using JSON.stringify', function () { assert.doesNotThrow(function () { JSON.stringify(partialResult); diff --git a/test/core/public/run-rules.js b/test/core/public/run-rules.js index 778b236482..d04b853a80 100644 --- a/test/core/public/run-rules.js +++ b/test/core/public/run-rules.js @@ -43,11 +43,9 @@ describe('runRules', function () { } var fixture = document.getElementById('fixture'); - var memoizedFns; var isNotCalled; beforeEach(function () { - memoizedFns = axe._memoizedFns.slice(); isNotCalled = function (err) { throw err || new Error('Reject should not be called'); }; @@ -56,8 +54,7 @@ describe('runRules', function () { afterEach(function () { fixture.innerHTML = ''; axe._audit = null; - axe._tree = undefined; - axe._memoizedFns = memoizedFns; + axe.teardown(); }); it('should work', function (done) { @@ -92,7 +89,7 @@ describe('runRules', function () { assert.lengthOf(r[0].passes, 3); done(); }, - isNotCalled + err => done(err) ); }, 500); }); @@ -228,7 +225,8 @@ describe('runRules', function () { "/div[@id='target']" ], source: '
', - nodeIndexes: [12, 14] + nodeIndexes: [12, 14], + fromFrame: true }, any: [ { @@ -271,7 +269,8 @@ describe('runRules', function () { ], source: '
\n
\n
', - nodeIndexes: [12, 9] + nodeIndexes: [12, 9], + fromFrame: true }, any: [ { @@ -290,7 +289,8 @@ describe('runRules', function () { ], source: '
\n
\n
', - nodeIndexes: [12, 9] + nodeIndexes: [12, 9], + fromFrame: true } ] } @@ -538,7 +538,8 @@ describe('runRules', function () { ], xpath: ["/div[@id='target']"], source: '
Target!
', - nodeIndexes: [12] + nodeIndexes: [12], + fromFrame: false }, impact: 'moderate', any: [ @@ -582,7 +583,8 @@ describe('runRules', function () { 'html > body > div:nth-child(1) > div:nth-child(1)' ], source: '
Target!
', - nodeIndexes: [12] + nodeIndexes: [12], + fromFrame: false }, any: [ { @@ -599,7 +601,8 @@ describe('runRules', function () { ], xpath: ["/div[@id='target']"], source: '
Target!
', - nodeIndexes: [12] + nodeIndexes: [12], + fromFrame: false } ] } @@ -847,7 +850,7 @@ describe('runRules', function () { setTimeout(function () { axe._runRules( document, - { iframes: false }, + { iframes: false, elementRef: true }, function (r) { assert.lengthOf(r[0].passes, 1); assert.equal( diff --git a/test/core/reporters/helpers/process-aggregate.js b/test/core/reporters/helpers/process-aggregate.js index 5cdbbc9ee9..32f4f072c1 100644 --- a/test/core/reporters/helpers/process-aggregate.js +++ b/test/core/reporters/helpers/process-aggregate.js @@ -2,6 +2,7 @@ describe('helpers.processAggregate', function () { 'use strict'; var results, options; const helpers = axe._thisWillBeDeletedDoNotUse.helpers; + const fixture = document.getElementById('fixture'); beforeEach(function () { results = [ @@ -83,7 +84,7 @@ describe('helpers.processAggregate', function () { relatedNodes: [ { element: document.createElement('input'), - selector: '#dopel', + selector: ['#dopel'], source: [''], xpath: ['/main/input[@id="dopel"]'], ancestry: ['html > body > main > input:nth-child(2)'], @@ -176,6 +177,26 @@ describe('helpers.processAggregate', function () { }); }); + describe('axe.configure({ noHtml: true })', () => { + afterEach(() => { + axe.reset(); + }); + + it('sets html to null on nodes', () => { + axe.configure({ noHtml: true }); + const { passes, violations } = helpers.processAggregate(results, {}); + assert.isNull(passes[0].nodes[0].html); + assert.isNull(violations[0].nodes[0].html); + }); + + it('sets html to null on relatedNodes', () => { + axe.configure({ noHtml: true }); + const { passes, violations } = helpers.processAggregate(results, {}); + assert.isNull(passes[0].nodes[0].any[0].relatedNodes[0].html); + assert.isNull(violations[0].nodes[0].any[0].relatedNodes[0].html); + }); + }); + describe('`options` argument', function () { describe('`resultTypes` option', function () { it('should reduce the unwanted result types to 1 in the `resultObject`', function () { @@ -194,6 +215,21 @@ describe('helpers.processAggregate', function () { assert.isDefined(resultObject.incomplete); assert.isDefined(resultObject.inapplicable); }); + + it('should not compute selectors of filtered nodes', () => { + const dqElm = new axe.utils.DqElement(fixture); + Object.defineProperty(dqElm, 'ancestry', { + get() { + throw new Error('Should not be called'); + } + }); + results[0].passes[1].node = dqElm; + assert.doesNotThrow(() => { + helpers.processAggregate(results, { + resultTypes: ['violations'] + }); + }); + }); }); describe('`elementRef` option', function () { @@ -280,6 +316,19 @@ describe('helpers.processAggregate', function () { resultObject.passes[0].nodes[0].any[0].relatedNodes[0].target ); }); + + it('should not call DqElement.selector', () => { + const dqElm = new axe.utils.DqElement(fixture); + Object.defineProperty(dqElm, 'selector', { + get() { + throw new Error('Should not be called'); + } + }); + results[0].passes[0].node = dqElm; + assert.doesNotThrow(() => { + helpers.processAggregate(results, options); + }); + }); }); }); @@ -331,6 +380,21 @@ describe('helpers.processAggregate', function () { resultObject.passes[0].nodes[0].any[0].relatedNodes[0].ancestry ); }); + + it('should not call DqElement.ancestry', () => { + const dqElm = new axe.utils.DqElement(fixture, options, { + selector: ['div'] // prevent axe._selectorData error + }); + Object.defineProperty(dqElm, 'ancestry', { + get() { + throw new Error('Should not be called'); + } + }); + results[0].passes[0].node = dqElm; + assert.doesNotThrow(() => { + helpers.processAggregate(results, options); + }); + }); }); describe('when not set at all', function () { @@ -381,6 +445,21 @@ describe('helpers.processAggregate', function () { resultObject.passes[0].nodes[0].any[0].relatedNodes[0].xpath ); }); + + it('should not call DqElement.xpath', () => { + const dqElm = new axe.utils.DqElement(fixture, options, { + selector: ['div'] // prevent axe._selectorData error + }); + Object.defineProperty(dqElm, 'xpath', { + get() { + throw new Error('Should not be called'); + } + }); + results[0].passes[0].node = dqElm; + assert.doesNotThrow(() => { + helpers.processAggregate(results, options); + }); + }); }); }); }); diff --git a/test/core/reporters/raw-env.js b/test/core/reporters/raw-env.js index 84550453bd..e8ca9fb7c7 100644 --- a/test/core/reporters/raw-env.js +++ b/test/core/reporters/raw-env.js @@ -26,7 +26,8 @@ describe('reporters - raw-env', function () { any: [ { result: true, - data: 'minkey' + data: 'minkey', + relatedNodes: [] } ], all: [], @@ -50,7 +51,8 @@ describe('reporters - raw-env', function () { { result: false, data: 'pillock', - impact: 'cats' + impact: 'cats', + relatedNodes: [] } ], any: [], @@ -75,7 +77,8 @@ describe('reporters - raw-env', function () { { data: 'foon', impact: 'monkeys', - result: true + result: true, + relatedNodes: [] } ], any: [], @@ -93,10 +96,13 @@ describe('reporters - raw-env', function () { passes: [ { result: 'passed', + any: [], + all: [], none: [ { data: 'clueso', - result: true + result: true, + relatedNodes: [] } ], node: createDqElement() @@ -111,8 +117,9 @@ describe('reporters - raw-env', function () { axe._cache.set('selectorData', {}); }); - after(function () { + afterEach(function () { fixture.innerHTML = ''; + sinon.restore(); }); it('should serialize DqElements (#1195)', function () { @@ -149,4 +156,18 @@ describe('reporters - raw-env', function () { } ); }); + + it('uses nodeSerializer', done => { + var rawReporter = axe.getReporter('rawEnv'); + var spy = sinon.spy(axe.utils.nodeSerializer, 'mapRawNodeResults'); + rawReporter( + runResults, + {}, + function () { + assert.isTrue(spy.called); + done(); + }, + done + ); + }); }); diff --git a/test/core/reporters/raw.js b/test/core/reporters/raw.js index 61648759af..c49be13894 100644 --- a/test/core/reporters/raw.js +++ b/test/core/reporters/raw.js @@ -26,7 +26,8 @@ describe('reporters - raw', function () { any: [ { result: true, - data: 'minkey' + data: 'minkey', + relatedNodes: [] } ], all: [], @@ -50,7 +51,8 @@ describe('reporters - raw', function () { { result: false, data: 'pillock', - impact: 'cats' + impact: 'cats', + relatedNodes: [] } ], any: [], @@ -75,7 +77,8 @@ describe('reporters - raw', function () { { data: 'foon', impact: 'monkeys', - result: true + result: true, + relatedNodes: [] } ], any: [], @@ -93,10 +96,13 @@ describe('reporters - raw', function () { passes: [ { result: 'passed', + any: [], + all: [], none: [ { data: 'clueso', - result: true + result: true, + relatedNodes: [] } ], node: createDqElement() @@ -111,8 +117,9 @@ describe('reporters - raw', function () { axe._cache.set('selectorData', {}); }); - after(function () { + afterEach(function () { fixture.innerHTML = ''; + sinon.restore(); }); it('should serialize DqElements', function (done) { @@ -138,4 +145,18 @@ describe('reporters - raw', function () { }); }); }); + + it('uses nodeSerializer', done => { + var rawReporter = axe.getReporter('raw'); + var spy = sinon.spy(axe.utils.nodeSerializer, 'mapRawNodeResults'); + rawReporter( + runResults, + {}, + function () { + assert.isTrue(spy.called); + done(); + }, + done + ); + }); }); diff --git a/test/core/utils/check-helper.js b/test/core/utils/check-helper.js index b40948859e..41e2b09306 100644 --- a/test/core/utils/check-helper.js +++ b/test/core/utils/check-helper.js @@ -1,6 +1,5 @@ describe('axe.utils.checkHelper', () => { - const { queryFixture } = axe.testUtils; - const DqElement = axe.utils.DqElement; + const { queryFixture, fixtureSetup } = axe.testUtils; function noop() {} it('should be a function', () => { @@ -67,46 +66,46 @@ describe('axe.utils.checkHelper', () => { describe('relatedNodes', () => { const fixture = document.getElementById('fixture'); - afterEach(() => { - fixture.innerHTML = ''; + const getSelector = node => node.selector; + + it('returns DqElements', () => { + fixtureSetup('
'); + const target = {}; + const helper = axe.utils.checkHelper(target, noop); + helper.relatedNodes(fixture.children); + assert.instanceOf(target.relatedNodes[0], axe.utils.DqElement); }); it('should accept NodeList', () => { - fixture.innerHTML = '
'; + fixtureSetup('
'); const target = {}; const helper = axe.utils.checkHelper(target, noop); helper.relatedNodes(fixture.children); - assert.lengthOf(target.relatedNodes, 2); - assert.instanceOf(target.relatedNodes[0], DqElement); - assert.instanceOf(target.relatedNodes[1], DqElement); - assert.equal(target.relatedNodes[0].element, fixture.children[0]); - assert.equal(target.relatedNodes[1].element, fixture.children[1]); + const selectors = target.relatedNodes.map(getSelector); + assert.deepEqual(selectors, [['#t1'], ['#t2']]); }); it('should accept a single Node', () => { - fixture.innerHTML = '
'; + fixtureSetup('
'); const target = {}; const helper = axe.utils.checkHelper(target, noop); helper.relatedNodes(fixture.firstChild); - assert.lengthOf(target.relatedNodes, 1); - assert.instanceOf(target.relatedNodes[0], DqElement); - assert.equal(target.relatedNodes[0].element, fixture.firstChild); + const selectors = target.relatedNodes.map(getSelector); + assert.deepEqual(selectors, [['#t1']]); }); it('should accept an Array', () => { - fixture.innerHTML = '
'; + fixtureSetup('
'); + const target = {}; const helper = axe.utils.checkHelper(target, noop); helper.relatedNodes(Array.prototype.slice.call(fixture.children)); - assert.lengthOf(target.relatedNodes, 2); - assert.instanceOf(target.relatedNodes[0], DqElement); - assert.instanceOf(target.relatedNodes[1], DqElement); - assert.equal(target.relatedNodes[0].element, fixture.children[0]); - assert.equal(target.relatedNodes[1].element, fixture.children[1]); + const selectors = target.relatedNodes.map(getSelector); + assert.deepEqual(selectors, [['#t1'], ['#t2']]); }); it('should accept an array-like Object', () => { - fixture.innerHTML = '
'; + fixtureSetup('
'); const target = {}; const helper = axe.utils.checkHelper(target, noop); const nodes = { @@ -115,11 +114,8 @@ describe('axe.utils.checkHelper', () => { length: 2 }; helper.relatedNodes(nodes); - assert.lengthOf(target.relatedNodes, 2); - assert.instanceOf(target.relatedNodes[0], DqElement); - assert.instanceOf(target.relatedNodes[1], DqElement); - assert.equal(target.relatedNodes[0].element, fixture.children[0]); - assert.equal(target.relatedNodes[1].element, fixture.children[1]); + const selectors = target.relatedNodes.map(getSelector); + assert.deepEqual(selectors, [['#t1'], ['#t2']]); }); it('should accept a VirtualNode', () => { @@ -127,27 +123,24 @@ describe('axe.utils.checkHelper', () => { const target = {}; const helper = axe.utils.checkHelper(target, noop); helper.relatedNodes(vNode); - assert.lengthOf(target.relatedNodes, 1); - assert.instanceOf(target.relatedNodes[0], DqElement); - assert.equal(target.relatedNodes[0].element.nodeName, 'A'); + const selectors = target.relatedNodes.map(getSelector); + assert.deepEqual(selectors, [['#target']]); }); it('should accept an array of VirtualNodes', () => { const vNode = queryFixture(` -
+
`); const target = {}; const helper = axe.utils.checkHelper(target, noop); helper.relatedNodes(vNode.children); - assert.lengthOf(target.relatedNodes, 2); - assert.instanceOf(target.relatedNodes[0], DqElement); - assert.equal(target.relatedNodes[0].element.nodeName, 'A'); - assert.equal(target.relatedNodes[1].element.nodeName, 'B'); + const selectors = target.relatedNodes.map(getSelector); + assert.deepEqual(selectors, [['#a'], ['#b']]); }); it('should filter out non-nodes', () => { const vNode = queryFixture(` -
+
`); const target = {}; const helper = axe.utils.checkHelper(target, noop); @@ -162,10 +155,8 @@ describe('axe.utils.checkHelper', () => { }) ]; helper.relatedNodes(nodes); - assert.lengthOf(target.relatedNodes, 2); - assert.instanceOf(target.relatedNodes[0], DqElement); - assert.equal(target.relatedNodes[0].element.nodeName, 'A'); - assert.equal(target.relatedNodes[1].element.nodeName, 'B'); + const selectors = target.relatedNodes.map(getSelector); + assert.deepEqual(selectors, [['#target'], ['#b']]); }); it('should noop for non-node-like objects', () => { diff --git a/test/core/utils/dq-element.js b/test/core/utils/dq-element.js index 5ea8ea61d4..3e6f036f9e 100644 --- a/test/core/utils/dq-element.js +++ b/test/core/utils/dq-element.js @@ -163,11 +163,12 @@ describe('DqElement', function () { describe('toJSON', function () { it('should only stringify selector and source', function () { var spec = { - selector: 'foo > bar > joe', + selector: ['foo > bar > joe'], source: '', - xpath: '/foo/bar/joe', - ancestry: 'foo > bar > joe', - nodeIndexes: [123, 456] + xpath: ['/foo/bar/joe'], + ancestry: ['foo > bar > joe'], + nodeIndexes: [123], + fromFrame: false }; var div = document.createElement('div'); @@ -266,4 +267,29 @@ describe('DqElement', function () { }); }); }); + + describe('DqElement.setRunOptions', function () { + it('sets options for DqElement', function () { + axe.setup(); + var options = { absolutePaths: true, elementRef: true }; + DqElement.setRunOptions(options); + var dqElm = new DqElement(document.body); + + const { element, selector } = dqElm.toJSON(); + assert.equal(element, document.body); + assert.equal(selector, 'html > body'); + }); + + it('is reset by axe.teardown', () => { + var options = { absolutePaths: true, elementRef: true }; + DqElement.setRunOptions(options); + axe.teardown(); + + axe.setup(); + var dqElm = new DqElement(document.body); + const { element, selector } = dqElm.toJSON(); + assert.isUndefined(element); + assert.equal(selector, 'body'); + }); + }); }); diff --git a/test/core/utils/node-serializer.js b/test/core/utils/node-serializer.js new file mode 100644 index 0000000000..5d170de025 --- /dev/null +++ b/test/core/utils/node-serializer.js @@ -0,0 +1,280 @@ +describe('nodeSerializer', () => { + const { nodeSerializer, DqElement } = axe.utils; + const fixture = document.querySelector('#fixture'); + beforeEach(() => { + axe.setup(); + }); + + afterEach(() => { + nodeSerializer.update(null); + }); + + describe('.toSpec()', () => { + it('returns DqElement.toJSON() by default', () => { + const spec = nodeSerializer.toSpec(fixture); + const dqElm = new DqElement(fixture); + assert.deepEqual(spec, dqElm.toJSON()); + }); + + it('can be replaced with nodeSerializer.update({ toSpec: fn })', () => { + nodeSerializer.update({ + toSpec(dqElm) { + const json = dqElm.toJSON(); + json.source = 'Replaced'; + return json; + } + }); + + const spec = nodeSerializer.toSpec(fixture); + const dqElm = new DqElement(fixture); + assert.deepEqual(spec, { ...dqElm.toJSON(), source: 'Replaced' }); + }); + }); + + describe('.dqElmToSpec()', () => { + it('returns DqElement.toJSON() by default', () => { + const dqElm = new DqElement(fixture); + const spec = nodeSerializer.dqElmToSpec(dqElm); + assert.deepEqual(spec, dqElm.toJSON()); + }); + + it('can be replaced with nodeSerializer.update({ toSpec: fn })', () => { + nodeSerializer.update({ + toSpec(dqElm) { + const json = dqElm.toJSON(); + json.source = 'Replaced'; + return json; + } + }); + + const dqElm = new DqElement(fixture); + const spec = nodeSerializer.dqElmToSpec(dqElm); + assert.deepEqual(spec, { ...dqElm.toJSON(), source: 'Replaced' }); + }); + + it('optionally accepts runOptions, replacing real values with dummy values', () => { + const dqElm = new DqElement(fixture); + const spec = nodeSerializer.dqElmToSpec(dqElm, { + selectors: false, + xpath: false, + ancestry: false + }); + assert.deepEqual(spec, { + ...dqElm.toJSON(), + selector: [':root'], + ancestry: [':root'], + xpath: '/' + }); + }); + + it('returns selector when falsey but not false', () => { + const dqElm = new DqElement(fixture); + const spec = nodeSerializer.dqElmToSpec(dqElm, { selectors: null }); + assert.deepEqual(spec, { + ...dqElm.toJSON(), + ancestry: [':root'], + xpath: '/' + }); + }); + + it('returns selector if fromFrame, even if runOptions.selectors is false', () => { + const dqElm = new DqElement(fixture); + dqElm.fromFrame = true; + const spec = nodeSerializer.dqElmToSpec(dqElm, { + selectors: false + }); + assert.deepEqual(spec, { + ...dqElm.toJSON(), + ancestry: [':root'], + xpath: '/' + }); + }); + + it('skips computing props turned off with runOptions', () => { + const dqElm = new DqElement(fixture); + + const throws = () => { + throw new Error('Should not be called'); + }; + Object.defineProperty(dqElm, 'selector', { get: throws }); + Object.defineProperty(dqElm, 'ancestry', { get: throws }); + Object.defineProperty(dqElm, 'xpath', { get: throws }); + + assert.doesNotThrow(() => { + nodeSerializer.dqElmToSpec(dqElm, { + selectors: false, + xpath: false, + ancestry: false + }); + }); + }); + }); + + describe('.mergeSpecs()', () => { + const nodeSpec = { + source: '
', + selector: ['#fixture'], + ancestry: ['html > body > #fixture'], + nodeIndexes: [3], + xpath: ['html/body/div[1]'] + }; + const frameSpec = { + source: '', + selector: ['#frame'], + ancestry: ['html > body > #frame'], + nodeIndexes: [3], + xpath: ['html/body/iframe[1]'] + }; + + it('returns DqElement.mergeSpecs() by default', () => { + const combinedSpec = nodeSerializer.mergeSpecs(nodeSpec, frameSpec); + assert.deepEqual(combinedSpec, DqElement.mergeSpecs(nodeSpec, frameSpec)); + }); + + it('can be replaced with nodeSerializer.update({ mergeSpecs: fn })', () => { + nodeSerializer.update({ + mergeSpecs(childSpec, parentSpec) { + const spec = DqElement.mergeSpecs(childSpec, parentSpec); + spec.source = 'Replaced'; + return spec; + } + }); + const spec = nodeSerializer.mergeSpecs(nodeSpec, frameSpec); + assert.deepEqual(spec, { + ...DqElement.mergeSpecs(nodeSpec, frameSpec), + source: 'Replaced' + }); + }); + }); + + describe('.mapRawNodeResults()', () => { + it('returns undefined when passed undefined', () => { + assert.isUndefined(nodeSerializer.mapRawNodeResults(undefined)); + }); + + it('converts DqElements node to specs', () => { + const dqElm = new DqElement(fixture); + const rawNodeResults = [ + { + any: [], + all: [], + none: [ + { + id: 'nope', + data: null, + relatedNodes: [] + } + ], + node: dqElm, + result: 'failed' + } + ]; + + const serialized = nodeSerializer.mapRawNodeResults(rawNodeResults); + assert.deepEqual(serialized, [ + { + ...rawNodeResults[0], + node: dqElm.toJSON() + } + ]); + }); + + it('converts DqElements relatedNodes to specs', () => { + const dqElm = new DqElement(fixture); + const related = new DqElement(fixture.querySelector('p')); + const rawNodeResults = [ + { + any: [ + { + id: 'something', + data: null, + relatedNodes: [related] + } + ], + all: [ + { + id: 'everything', + data: null, + relatedNodes: [related] + } + ], + none: [ + { + id: 'nope', + data: null, + relatedNodes: [related] + } + ], + node: dqElm, + result: 'failed' + } + ]; + + const serialized = nodeSerializer.mapRawNodeResults(rawNodeResults); + assert.deepEqual(serialized[0].any, [ + { + ...rawNodeResults[0].any[0], + relatedNodes: [related.toJSON()] + } + ]); + assert.deepEqual(serialized[0].all, [ + { + ...rawNodeResults[0].all[0], + relatedNodes: [related.toJSON()] + } + ]); + assert.deepEqual(serialized[0].none, [ + { + ...rawNodeResults[0].none[0], + relatedNodes: [related.toJSON()] + } + ]); + }); + }); + + describe('.mapRawResults()', () => { + it('converts DqElements to specs', () => { + const dqElm = new DqElement(fixture); + const rawNodeResults = [ + { + any: [ + { + id: 'nope', + data: null, + relatedNodes: [dqElm] + } + ], + all: [], + none: [], + node: dqElm, + result: 'failed' + } + ]; + const rawResults = [ + { + id: 'test', + nodes: rawNodeResults + } + ]; + + const serialized = nodeSerializer.mapRawResults(rawResults); + assert.deepEqual(serialized, [ + { + ...rawResults[0], + nodes: [ + { + ...rawNodeResults[0], + node: dqElm.toJSON(), + any: [ + { + ...rawNodeResults[0].any[0], + relatedNodes: [dqElm.toJSON()] + } + ] + } + ] + } + ]); + }); + }); +}); diff --git a/test/integration/full/configure-options/configure-options.js b/test/integration/full/configure-options/configure-options.js index ae574ce8d3..43f83bb7f8 100644 --- a/test/integration/full/configure-options/configure-options.js +++ b/test/integration/full/configure-options/configure-options.js @@ -201,10 +201,9 @@ describe('Configure Options', () => { }; const iframe = document.createElement('iframe'); - iframe.src = '/test/mock/frames/context.html'; + iframe.src = '/test/mock/frames/noHtml-config.html'; iframe.onload = () => { axe.configure(config); - axe.run( '#target', { diff --git a/test/integration/full/serializer/custom-source-serializer.js b/test/integration/full/serializer/custom-source-serializer.js new file mode 100644 index 0000000000..d151847f3b --- /dev/null +++ b/test/integration/full/serializer/custom-source-serializer.js @@ -0,0 +1,12 @@ +axe.utils.nodeSerializer.update({ + toSpec(dqElm) { + const result = dqElm.toJSON(); + result.source = dqElm.element.id; + return result; + }, + mergeSpecs(childSpec, parentSpec) { + const result = axe.utils.DqElement.mergeSpecs(childSpec, parentSpec); + result.source = `${parentSpec.source} > ${childSpec.source}`; + return result; + } +}); diff --git a/test/integration/full/serializer/frames/level1.html b/test/integration/full/serializer/frames/level1.html new file mode 100644 index 0000000000..9e73f2e891 --- /dev/null +++ b/test/integration/full/serializer/frames/level1.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/test/integration/full/serializer/frames/level2-a.html b/test/integration/full/serializer/frames/level2-a.html new file mode 100644 index 0000000000..b7f39033ea --- /dev/null +++ b/test/integration/full/serializer/frames/level2-a.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/integration/full/serializer/frames/level2-b.html b/test/integration/full/serializer/frames/level2-b.html new file mode 100644 index 0000000000..49cca1d099 --- /dev/null +++ b/test/integration/full/serializer/frames/level2-b.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/integration/full/serializer/serializer.html b/test/integration/full/serializer/serializer.html new file mode 100644 index 0000000000..95f0a73250 --- /dev/null +++ b/test/integration/full/serializer/serializer.html @@ -0,0 +1,29 @@ + + + + Serializer Test + + + + + + + + + +
+ + + + + diff --git a/test/integration/full/serializer/serializer.js b/test/integration/full/serializer/serializer.js new file mode 100644 index 0000000000..3ae2766403 --- /dev/null +++ b/test/integration/full/serializer/serializer.js @@ -0,0 +1,46 @@ +describe('serializer', () => { + const { awaitNestedLoad, runPartialRecursive } = axe.testUtils; + + beforeEach(async () => { + await new Promise(res => awaitNestedLoad(res)); + }); + + const runOptions = { runOnly: 'html-lang-valid' }; + const expectedCustomNodeSources = [ + 'level0', + 'frame1 > level1', + 'frame1 > frame2-a > level2-a', + 'frame1 > frame2-b > level2-b' + ]; + + it('applies serializer hooks with axe.runPartial/finishRun', async () => { + const partialResults = await Promise.all( + runPartialRecursive(document, runOptions) + ); + const results = await axe.finishRun(partialResults, runOptions); + const nodesHtml = results.violations[0].nodes.map(n => n.html); + assert.deepStrictEqual(nodesHtml, expectedCustomNodeSources); + }); + + it('applies serializer hooks with axe.run', async () => { + const results = await axe.run(document, runOptions); + const nodesHtml = results.violations[0].nodes.map(n => n.html); + assert.deepStrictEqual(nodesHtml, expectedCustomNodeSources); + }); + + it('still supports axe.run with options.elementRef', async () => { + const results = await axe.run(document, { + ...runOptions, + elementRef: true + }); + const nodeElements = results.violations[0].nodes.map(n => n.element); + + assert.deepStrictEqual(nodeElements, [ + document.querySelector('html'), + // as usual, elementRef only works for the top frame + undefined, + undefined, + undefined + ]); + }); +});