@axe-core/webdriverjs
Version:
Provides a method to inject and analyze web pages using axe
556 lines (546 loc) • 17 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
AxeBuilder: () => AxeBuilder,
default: () => AxeBuilder
});
module.exports = __toCommonJS(src_exports);
var import_axe_core2 = __toESM(require("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
var import_selenium_webdriver = require("selenium-webdriver");
var import_axe_core = __toESM(require("axe-core"));
var { source } = import_axe_core.default;
var { StaleElementReferenceError } = import_selenium_webdriver.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
var import_assert = __toESM(require("assert"));
var { source: source2 } = import_axe_core2.default;
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 {
(0, import_assert.default)(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;
}
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
AxeBuilder
});
if (module.exports.default) {
var ___default_export = module.exports.default;
var ___export_entries = Object.entries(module.exports);
module.exports = ___default_export;
___export_entries.forEach(([key, value]) => {
if (module.exports[key]) {
throw new Error(`Export "${key}" already exists on default export`);
}
module.exports[key] = value;
});
}