UNPKG

@axe-core/webdriverjs

Version:

Provides a method to inject and analyze web pages using axe

508 lines (501 loc) 14.8 kB
// src/index.ts import axe2 from "axe-core"; // src/utils/index.ts var normalizeContext = (include, exclude) => { const base = { exclude: [] }; if (exclude.length && Array.isArray(base.exclude)) { base.exclude.push(...exclude); } if (include.length) { base.include = include; } return base; }; // src/axe-injector.ts import { error } from "selenium-webdriver"; import axe from "axe-core"; var { source } = axe; var { StaleElementReferenceError } = error; var AxeInjectorLegacy = class { driver; axeSource; options; config; didLogError; constructor({ driver, axeSource, builderOptions, config }) { this.driver = driver; this.axeSource = axeSource || source; this.config = config ? JSON.stringify(config) : ""; this.options = builderOptions || {}; this.didLogError = false; this.options.noSandbox = typeof this.options.noSandbox === "boolean" ? this.options.noSandbox : false; this.options.logIframeErrors = typeof this.options.logIframeErrors === "boolean" ? this.options.logIframeErrors : false; } /** * Checks to make sure that the error thrown was not a stale iframe * @param {Error} error * @returns {void} */ errorHandler(err) { if (this.didLogError) { return; } this.didLogError = true; let msg; if (err instanceof StaleElementReferenceError) { msg = "Tried to inject into a removed iframe. This will not affect the analysis of the rest of the page but you might want to ensure the page has finished updating before starting the analysis."; } else { msg = "Failed to inject axe-core into one of the iframes!"; } if (this.options.logIframeErrors) { console.error(msg); return; } throw new Error(msg); } /** * Get axe-core source and configurations * @returns {String} */ get script() { return ` ${this.axeSource} ${this.config ? `axe.configure(${this.config})` : ""} axe.configure({ branding: { application: 'webdriverjs' } }) `; } /** * Removes the `sandbox` attribute from iFrames * @returns {Promise<void>} */ async sandboxBuster() { return new Promise((resolve, reject) => { this.driver.executeAsyncScript( ` var callback = arguments[arguments.length - 1]; var iframes = Array.from( document.querySelectorAll('iframe[sandbox]') ); var removeSandboxAttr = clone => attr => { if (attr.name === 'sandbox') return; clone.setAttribute(attr.name, attr.value); }; var replaceSandboxedIframe = iframe => { var clone = document.createElement('iframe'); var promise = new Promise( iframeLoaded => (clone.onload = iframeLoaded) ); Array.from(iframe.attributes).forEach(removeSandboxAttr(clone)); iframe.parentElement.replaceChild(clone, iframe); return promise; }; Promise.all(iframes.map(replaceSandboxedIframe)).then(callback); ` ).then(() => resolve()).catch((e) => reject(e)); }); } /** * Injects into the provided `frame` and its child `frames` * @param {WebElement[]} framePath * @returns {Promise<void>} */ async handleFrame(framePath) { await this.driver.switchTo().defaultContent(); for (const frame of framePath) { await this.driver.switchTo().frame(frame); } if (this.options.noSandbox) { await this.sandboxBuster(); } await this.driver.executeScript(this.script); const ifs = await this.driver.findElements({ tagName: "iframe" }); const fs = await this.driver.findElements({ tagName: "frame" }); const frames = ifs.concat(fs); for (const childFrames of frames) { framePath.push(childFrames); try { await this.handleFrame(framePath); } catch (error2) { this.errorHandler(error2); } finally { framePath.pop(); } } } /** * Injects into all frames * @returns {Promise<void>} */ async injectIntoAllFrames() { await this.driver.switchTo().defaultContent(); if (this.options.noSandbox) { await this.sandboxBuster(); } await this.driver.executeScript(this.script); const ifs = await this.driver.findElements({ tagName: "iframe" }); const fs = await this.driver.findElements({ tagName: "frame" }); const frames = ifs.concat(fs); for (const childFrame of frames) { try { await this.handleFrame([childFrame]); } catch (err) { this.errorHandler(err); } } return this.driver.switchTo().defaultContent(); } }; // src/browser.ts function axeSourceInject(driver, axeSource, config) { return promisify( driver.executeScript(` ${axeSource}; window.axe.configure({ branding: { application: 'webdriverjs' } }); var config = ${JSON.stringify(config)}; if (config) { window.axe.configure(config); } var runPartial = typeof window.axe.runPartial === 'function'; return { runPartialSupported: runPartial }; `) ); } function axeRunPartial(driver, context, options) { return promisify( driver.executeAsyncScript(` var callback = arguments[arguments.length - 1]; var context = ${JSON.stringify(context)} || document; var options = ${JSON.stringify(options)} || {}; window.axe.runPartial(context, options).then(res => JSON.stringify(res)).then(callback); `) ); } function axeFinishRun(driver, axeSource, config, partialResults, options) { const sizeLimit = 15e6; const partialString = JSON.stringify(partialResults); function chunkResults(result) { const chunk = JSON.stringify(result.substring(0, sizeLimit)); return promisify( driver.executeScript( ` window.partialResults ??= ''; window.partialResults += ${chunk}; ` ) ).then(() => { if (result.length > sizeLimit) { return chunkResults(result.substr(sizeLimit)); } }); } return chunkResults(partialString).then(() => { return promisify( driver.executeAsyncScript( ` var callback = arguments[arguments.length - 1]; ${axeSource}; window.axe.configure({ branding: { application: 'webdriverjs' } }); var config = ${JSON.stringify(config)}; if (config) { window.axe.configure(config); } var partialResults = JSON.parse(window.partialResults).map(res => JSON.parse(res)); var options = ${JSON.stringify(options || {})}; window.axe.finishRun(partialResults, options).then(res => JSON.stringify(res)).then(callback); ` ) ); }).then((res) => JSON.parse(res)); } function axeGetFrameContext(driver, context) { return promisify( driver.executeScript(` var context = ${JSON.stringify(context)} var frameContexts = window.axe.utils.getFrameContexts(context); return frameContexts.map(function (frameContext) { return Object.assign(frameContext, { href: window.location.href, // For debugging frame: axe.utils.shadowSelect(frameContext.frameSelector) }); }); `) ); } function axeRunLegacy(driver, context, options, config) { return promisify( driver.executeAsyncScript( ` var callback = arguments[arguments.length - 1]; var context = ${JSON.stringify(context)} || document; var options = ${JSON.stringify(options)} || {}; var config = ${JSON.stringify(config)} || null; if (config) { window.axe.configure(config); } window.axe.run(context, options).then(res => JSON.stringify(res)).then(callback); ` ).then((res) => JSON.parse(res)) ); } function promisify(thenable) { return new Promise((resolve, reject) => { thenable.then(resolve, reject); }); } // src/index.ts import assert from "assert"; var { source: source2 } = axe2; var AxeBuilder = class { driver; axeSource; includes; excludes; option; config; builderOptions; legacyMode = false; errorUrl; constructor(driver, axeSource, builderOptions) { this.driver = driver; this.axeSource = axeSource || source2; this.includes = []; this.excludes = []; this.option = {}; this.config = null; this.builderOptions = builderOptions || {}; this.errorUrl = "https://github.com/dequelabs/axe-core-npm/blob/develop/packages/webdriverjs/error-handling.md"; } /** * Selector to include in analysis. * This may be called any number of times. */ include(selector) { this.includes.push(selector); return this; } /** * Selector to exclude in analysis. * This may be called any number of times. */ exclude(selector) { this.excludes.push(selector); return this; } /** * Set options to be passed into axe-core */ options(options) { this.option = options; return this; } /** * Limit analysis to only the specified rules. * Cannot be used with `AxeBuilder#withTags` */ withRules(rules) { rules = Array.isArray(rules) ? rules : [rules]; this.option.runOnly = { type: "rule", values: rules }; return this; } /** * Limit analysis to only specified tags. * Cannot be used with `AxeBuilder#withRules` */ withTags(tags) { tags = Array.isArray(tags) ? tags : [tags]; this.option.runOnly = { type: "tag", values: tags }; return this; } /** * Set the list of rules to skip when running an analysis. */ disableRules(rules) { rules = Array.isArray(rules) ? rules : [rules]; this.option.rules = {}; for (const rule of rules) { this.option.rules[rule] = { enabled: false }; } return this; } /** * Set configuration for `axe-core`. * This value is passed directly to `axe.configure()` */ configure(config) { if (typeof config !== "object") { throw new Error( "AxeBuilder needs an object to configure. See axe-core configure API." ); } this.config = config; return this; } /** * Performs an analysis and retrieves results. */ async analyze(callback) { return new Promise((resolve, reject) => { return this.analyzePromise().then((results) => { callback?.(null, results); resolve(results); }).catch((err) => { if (callback) { callback(err, null); } else { reject(err); } }); }); } /** * Use frameMessenger with <same_origin_only> * * This disables use of axe.runPartial() which is called in each frame, and * axe.finishRun() which is called in a blank page. This uses axe.run() instead, * but with the restriction that cross-origin frames will not be tested. */ setLegacyMode(legacyMode = true) { this.legacyMode = legacyMode; return this; } /** * Analyzes the page, returning a promise */ async analyzePromise() { const context = normalizeContext(this.includes, this.excludes); await this.driver.switchTo().defaultContent(); const { runPartialSupported } = await axeSourceInject( this.driver, this.axeSource, this.config ); if (runPartialSupported !== true || this.legacyMode) { return this.runLegacy(context); } const { pageLoad } = await this.driver.manage().getTimeouts(); this.driver.manage().setTimeouts({ pageLoad: 1e3 }); let partials; try { partials = await this.runPartialRecursive(context); } finally { this.driver.manage().setTimeouts({ pageLoad }); } try { return await this.finishRun(partials); } catch (error2) { throw new Error( `${error2.message} Please check out ${this.errorUrl}` ); } } /** * Use axe.run() to get results from the page */ async runLegacy(context) { const { driver, axeSource, builderOptions } = this; let config = this.config; if (!this.legacyMode) { config = { ...config || {}, allowedOrigins: ["<unsafe_all_origins>"] }; } const injector = new AxeInjectorLegacy({ driver, axeSource, config, builderOptions }); await injector.injectIntoAllFrames(); return axeRunLegacy(this.driver, context, this.option, this.config); } /** * Get partial results from the current context and its child frames */ async runPartialRecursive(context, frameStack = []) { if (frameStack.length) { await axeSourceInject(this.driver, this.axeSource, this.config); } const frameContexts = await axeGetFrameContext(this.driver, context); const partials = [ await axeRunPartial(this.driver, context, this.option) ]; for (const { frameContext, frameSelector, frame } of frameContexts) { try { assert(frame, `Expect frame of "${frameSelector}" to be defined`); await this.driver.switchTo().frame(frame); partials.push( ...await this.runPartialRecursive(frameContext, [ ...frameStack, frame ]) ); await this.driver.switchTo().parentFrame(); } catch { const win = await this.driver.getWindowHandle(); await this.driver.switchTo().window(win); for (const frameElm of frameStack) { await this.driver.switchTo().frame(frameElm); } partials.push("null"); } } return partials; } /** * Use axe.finishRun() to turn partial results into actual results */ async finishRun(partials) { const { driver, axeSource, config, option } = this; const win = await driver.getWindowHandle(); await driver.switchTo().window(win); try { const beforeHandles = await driver.getAllWindowHandles(); await driver.executeScript(`window.open('about:blank', '_blank')`); const afterHandles = await driver.getAllWindowHandles(); const newHandles = afterHandles.filter( (afterHandle) => beforeHandles.indexOf(afterHandle) === -1 ); if (newHandles.length !== 1) { throw new Error("Unable to determine window handle"); } const newHandle = newHandles[0]; await driver.switchTo().window(newHandle); await driver.get("about:blank"); } catch (error2) { throw new Error( `switchTo failed. Are you using updated browser drivers? Driver reported: ${error2}` ); } const res = await axeFinishRun(driver, axeSource, config, partials, option); await driver.close(); await driver.switchTo().window(win); return res; } }; export { AxeBuilder, AxeBuilder as default };