pa11y
Version:
Pa11y is your automated accessibility testing pal
471 lines (418 loc) • 14 kB
JavaScript
;
const runAction = require('./action');
const option = require('./option');
const fs = require('fs');
const path = require('path');
const pkg = require('../package.json');
const puppeteer = require('puppeteer');
const semver = require('semver');
const runnersJavascript = {};
module.exports = pa11y;
/**
* Run accessibility tests on a web page.
* @public
* @param {String} url - The URL to run tests against.
* @param {Object} [options={}] - Options to change the way tests run.
* @param {Function} [callback] - An optional callback to use instead of promises.
* @returns {Promise} Returns a promise which resolves with a results object.
*/
async function pa11y(url, options = {}, callback) {
const state = {};
let pa11yError;
let pa11yResults;
[url, options, callback] = option.parseArguments(url, options, pa11y.defaults, callback);
try {
// Verify that the given options are valid
option.verifyOptions(options, pa11y.allowedStandards);
// Call the actual Pa11y test runner with
// a timeout if it takes too long
pa11yResults = await runPa11yTest(url, options, state, {
signal: AbortSignal.timeout(options.timeout)
});
} catch (error) {
if (callback) {
pa11yError = error;
} else {
throw error;
}
} finally {
await stateCleanup(state);
}
// Run callback if present, and resolve with pa11yResults
return callback ? callback(pa11yError, pa11yResults) : pa11yResults;
}
/**
* Internal Pa11y test runner.
* @private
* @param {String} url - The URL to run tests against.
* @param {Object} options - Options to change the way tests run.
* @param {Object} state - The current pa11y internal state, fields will be mutated by
* this function.
* @returns {Promise} Returns a promise which resolves with a results object.
*/
async function runPa11yTest(url, options, state) {
options.log.info(`Running Pa11y on URL ${url}`);
await setBrowser(options, state);
await setPage(url, options, state);
await interceptRequests(options, state);
await gotoUrl(url, options, state);
await runActionsList(options, state);
await injectRunners(options, state);
// Launch the test runner!
options.log.debug('Running Pa11y on the page');
/* istanbul ignore next */
if (options.wait > 0) {
options.log.debug(`Waiting for ${options.wait}ms`);
}
const results = await runPa11yWithOptions(options, state);
options.log.debug(`Document title: "${results.documentTitle}"`);
await saveScreenCapture(options, state);
return results;
}
/**
* Ensures that puppeteer resources are freed and listeners removed.
* @private
* @param {Object} state - The last-known state of the test-run.
* @returns {Promise} A promise which resolves when resources are released
*/
async function stateCleanup(state) {
if (state.browser && state.autoClose) {
await state.browser.close();
} else if (state.page) {
state.page.off('request', state.requestInterceptCallback);
state.page.off('console', state.consoleCallback);
if (state.autoClosePage) {
await state.page.close();
}
}
}
/**
* Sets or initialises the browser.
* @private
* @param {Object} options - Options to change the way tests run.
* @param {Object} state - The current pa11y internal state, fields will be mutated by
* this function.
* @returns {Promise} A promise which resolves when resources are released
*/
async function setBrowser(options, state) {
if (options.browser) {
options.log.debug(
'Using a pre-configured Headless Chrome instance, ' +
'the `chromeLaunchConfig` option will be ignored'
);
state.browser = options.browser;
state.autoClose = false;
} else {
// Launch a Headless Chrome browser. We use a
// state object which is accessible from the
// wrapping function
options.log.debug('Launching Headless Chrome');
state.browser = await puppeteer.launch(
options.chromeLaunchConfig
);
state.autoClose = true;
}
}
/**
* Configures the browser page to be used for the test.
* @private
* @param {Object} url - The URL to test against
* @param {Object} [options] - Options to change the way tests run.
* @param {Object} state - The current pa11y internal state, fields will be mutated by
* this function.
* @returns {Promise} A promise which resolves when the page has been configured.
*/
async function setPage(url, options, state) {
if (options.browser && options.page) {
state.page = options.page;
state.autoClosePage = false;
} else {
state.page = await state.browser.newPage();
state.autoClosePage = true;
}
// Listen for console logs on the page so that they can be output
// when debugging is enabled
state.consoleCallback = message => {
if (message && message.text()) {
options.log.debug(`Browser Console: ${message.text()}`);
}
};
state.page.on('console', state.consoleCallback);
options.log.debug('Opening URL in Headless Chrome');
if (options.userAgent) {
await state.page.setUserAgent(options.userAgent);
}
await state.page.setViewport(options.viewport);
}
/**
* Configures the browser page to intercept requests if necessary
* @private
* @param {Object} [options] - Options to change the way tests run.
* @param {Object} state - The current pa11y internal state, fields will be mutated by
* this function.
* @returns {Promise} A promise which resolves immediately if no listeners are necessary
* or after listener functions have been attached.
*/
async function interceptRequests(options, state) {
// Avoid to use `page.setRequestInterception` when not necessary
// because it occasionally stops page load:
// https://github.com/GoogleChrome/puppeteer/issues/3111
// https://github.com/GoogleChrome/puppeteer/issues/3121
const shouldInterceptRequests =
(options.headers && Object.keys(options.headers).length) ||
(options.method && options.method.toLowerCase() !== 'get') ||
options.postData;
if (!shouldInterceptRequests) {
return;
}
// Intercept page requests, we need to do this in order
// to set the HTTP method or post data
await state.page.setRequestInterception(true);
// Intercept requests so we can set the HTTP method
// and post data. We only want to make changes to the
// first request that's handled, which is the request
// for the page we're testing
let interceptionHandled = false;
state.requestInterceptCallback = interceptedRequest => {
const overrides = {};
if (!interceptionHandled) {
// Override the request method
options.log.debug(`Setting request method: ${options.method} `);
overrides.method = options.method;
// Override the request headers (and include the user-agent)
overrides.headers = {};
for (const [key, value] of Object.entries(options.headers)) {
overrides.headers[key.toLowerCase()] = value;
}
// eslint-disable-next-line max-len
options.log.debug(`Setting request headers:${JSON.stringify(overrides.headers, null, 4)}`);
// Override the request POST data if present
if (options.postData) {
overrides.postData = options.postData;
options.log.debug(`Setting request POST data: ${overrides.postData}`);
}
interceptionHandled = true;
}
interceptedRequest.continue(overrides);
};
state.page.on('request', state.requestInterceptCallback);
}
/**
* Instructs the page to go to the provided url unless options.ignoreUrl is true
* @private
* @param {String} [url] - The URL of the page to be tested.
* @param {Object} [options] - Options to change the way tests run.
* @param {Object} state - The current pa11y internal state, fields will be mutated by
* this function.
* @returns {Promise} A promise which resolves when the page URL has been set
*/
async function gotoUrl(url, options, state) {
// Navigate to the URL we're going to test
if (!options.ignoreUrl) {
await state.page.goto(url, {
waitUntil: 'networkidle2',
timeout: options.timeout
});
}
}
/**
* Carries out a synchronous list of actions in the page
* @private
* @param {Object} options - Options to change the way tests run.
* @param {Object} state - The current pa11y internal state, fields will be mutated by
* this function.
* @returns {Promise} A promise which resolves when all actions have completed
*/
async function runActionsList(options, state) {
if (options.actions.length) {
options.log.info('Running actions');
for (const action of options.actions) {
await runAction(state.browser, state.page, options, action);
}
options.log.info('Finished running actions');
}
}
/**
* Loads the test runners and Pa11y client-side scripts if required
* @private
* @param {Object} options - Options to change the way tests run.
* @param {Object} state - The current pa11y internal state, fields will be mutated by
* this function.
* @returns {Promise} A promise which resolves when all runners have been injected and evaluated
*/
async function injectRunners(options, state) {
// We only load these files once on the first run of Pa11y as they don't
// change between runs
if (!runnersJavascript.pa11y) {
runnersJavascript.pa11y = fs.readFileSync(path.join(__dirname, 'runner.js'), 'utf-8');
}
for (const runner of options.runners) {
if (!runnersJavascript[runner]) {
options.log.debug(`Loading runner: ${runner}`);
runnersJavascript[runner] = loadRunnerScript(runner);
}
}
// Inject the test runners
options.log.debug('Injecting Pa11y');
await state.page.evaluate(runnersJavascript.pa11y);
for (const runner of options.runners) {
options.log.debug(`Injecting runner: ${runner}`);
await state.page.evaluate(runnersJavascript[runner]);
}
}
/**
* Sends a request to the page to instruct the injected pa11y script to run with the
* provided options
* @private
* @param {Object} options - Options to change the way tests run.
* @param {Object} state - The current pa11y internal state, fields will be mutated by
* this function.
* @returns {Promise} A promise which resolves with the results of the pa11y evaluation
*/
function runPa11yWithOptions(options, state) {
/* eslint-disable no-underscore-dangle */
return state.page.evaluate(runOptions => {
return window.__pa11y.run(runOptions);
}, {
hideElements: options.hideElements,
ignore: options.ignore,
pa11yVersion: pkg.version,
rootElement: options.rootElement,
rules: options.rules,
runners: options.runners,
standard: options.standard,
wait: options.wait
});
/* eslint-enable no-underscore-dangle */
}
/**
* Generates a screen capture if required by the provided options
* @private
* @param {Object} options - Options to change the way tests run.
* @param {Object} state - The current pa11y internal state, fields will be mutated by
* this function.
* @returns {Promise} A promise which resolves when the screenshot is complete
*/
async function saveScreenCapture(options, state) {
// Generate a screen capture
if (options.screenCapture) {
options.log.info(
`Capturing screen, saving to "${options.screenCapture}"`
);
try {
await state.page.screenshot({
path: options.screenCapture,
fullPage: true
});
} catch (error) {
options.log.error(`Error capturing screen: ${error.message}`);
}
}
}
/**
* Load a Pa11y runner module.
* @param {String} runner - The name of the runner.
* @return {Object} Returns the required module.
*/
function loadRunnerFile(runner) {
switch (runner) {
// Load internal runners directly
case 'axe':
return require(`${__dirname}/runners/axe`);
case 'htmlcs':
return require(`${__dirname}/runners/htmlcs`);
default:
return require(runner);
}
}
/**
* Assert that a Pa11y runner is compatible with a version of Pa11y.
* @param {String} runnerName - The name of the runner.
* @param {String} runnerSupportString - The runner support string (a semver range).
* @param {String} pa11yVersion - The version of Pa11y to test support for.
* @throws {Error} Throws an error if the runner does not support the given version of Pa11y
* @returns {void}
*/
function assertRunnerCompatibility(runnerName, runnerSupportString, pa11yVersion) {
if (!runnerSupportString || !semver.satisfies(pa11yVersion, runnerSupportString)) {
throw new Error([
`The installed "${runnerName}" runner does not support Pa11y ${pa11yVersion}`,
'Please update your version of Pa11y or the runner',
`Runner Support: ${runnerSupportString}`,
`Pa11y Version: ${pa11yVersion}`
].join('\n'));
}
}
/**
* Loads a runner script
* @param {String} runner - The name of the runner.
* @throws {Error} Throws an error if the runner does not support the given version of Pa11y
* @returns {String} Javascript source of the runner
*/
function loadRunnerScript(runner) {
const runnerModule = loadRunnerFile(runner);
let runnerBundle = '';
assertRunnerCompatibility(runner, runnerModule.supports, pkg.version);
for (const runnerScript of runnerModule.scripts) {
runnerBundle += '\n\n';
runnerBundle += fs.readFileSync(runnerScript, 'utf-8');
}
return `
;${runnerBundle};
;window.__pa11y.runners['${runner}'] = ${runnerModule.run.toString()};
`;
}
/* istanbul ignore next */
const noop = () => { /* No-op */ };
/**
* Default options (excluding 'level', 'reporter', and 'threshold' which are only
* relevant when calling bin/pa11y from the CLI)
* @public
*/
pa11y.defaults = {
actions: [],
browser: null,
chromeLaunchConfig: {
ignoreHTTPSErrors: true
},
headers: {},
hideElements: null,
ignore: [],
ignoreUrl: false,
includeNotices: false,
includeWarnings: false,
log: {
debug: noop,
error: noop,
info: noop
},
method: 'GET',
postData: null,
rootElement: null,
rules: [],
runners: [
'htmlcs'
],
screenCapture: null,
standard: 'WCAG2AA',
timeout: 60000,
userAgent: `pa11y/${pkg.version}`,
viewport: {
width: 1280,
height: 1024
},
wait: 0
};
/**
* Allowed a11y standards.
* @public
*/
pa11y.allowedStandards = [
'WCAG2A',
'WCAG2AA',
'WCAG2AAA'
];
/**
* Alias the `isValidAction` method
*/
pa11y.isValidAction = runAction.isValidAction;