UNPKG

pa11y

Version:

Pa11y is your automated accessibility testing pal

532 lines (473 loc) 15.8 kB
'use strict'; const runAction = require('./action'); const option = require('./option'); const fs = require('fs'); const path = require('path'); const {version: pa11yVersion} = require('../package.json'); const puppeteer = require('puppeteer'); const {requireFirst} = require('../lib/helpers'); const semver = require('semver'); const runnersJavascript = {}; module.exports = pa11y; /** * Pa11yRunner type with required properties. * @typedef {Object} Pa11yRunner * @property {Function} run Runner.run(). * @property {String} supports This runner's semantic versioning support * @property {Array<String>} scripts This runner's script dependencies */ /** * Pa11y's reporting level. * @typedef { 'error' | 'warning' | 'notice' } Pa11yLevel */ /** * Signature for Pa11y's callback function. * @typedef {(error: Error, results: Pa11yResult[]) => void} Pa11yCallback */ /** * Run accessibility tests on a web page. * Note: actual signature is variadic and parameters shift left on sibling absence. * @public * @param {String} url The URL to run tests against. * @param {Object} [options={}] Options to change the way tests run. * @param {Pa11yCallback} [callback] An optional callback to use instead of promises. * @returns {Promise<Object>} A promise of Pa11y's findings. */ async function pa11y(url, options = {}, callback) { [url, options, callback] = option.parseArguments(url, options, pa11y.defaults, callback); const state = {}; let pa11yError; let pa11yResults; try { option.verifyOptions(options, pa11y.allowedStandards); // Call the Pa11y test runner, applying a timeout. pa11yResults = await runPa11yTest(url, options, state, { signal: AbortSignal.timeout(options.timeout) }); } catch (error) { if (callback) { pa11yError = error; } else { throw error; } } finally { await stateCleanup(state); } 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(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} [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(options, state) { if (options.browser && options.page) { options.log.debug('Using provided browser page'); state.page = options.page; state.autoClosePage = false; } else { options.log.debug('Opening new browser page'); 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); if (options.userAgent) { options.log.debug(`Setting page User-Agent to "${options.userAgent}"`); await state.page.setUserAgent(options.userAgent); } options.log.debug('Setting page viewport'); 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) { options.log.debug(`Navigating to ${url}`); 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 {Pa11yConfiguration} 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 of the results of Pa11y's evaluation */ const runPa11yWithOptions = ({ hideElements, ignore, levelCapWhenNeedsReview, rootElement, rules, runners, standard, wait }, state) => state.page.evaluate( /* eslint-disable-next-line no-underscore-dangle */ runOptions => window.__pa11y.run(runOptions), { pa11yVersion, hideElements, ignore, levelCapWhenNeedsReview, rootElement, rules, runners, standard, wait } ); /** * 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) { 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) { let runnerModule; try { // Load built-in runners by name from the runners directory. if (['axe', 'htmlcs'].includes(runner)) { runnerModule = require(path.join(__dirname, 'runners', runner)); } else { runnerModule = requireFirst([ // Standard runner module name `pa11y-runner-${runner}`, // Absolute path of runner or runner module full name runner, // Relative path of runner path.join(process.cwd(), runner) ], null); } } catch (error) { console.error( `An error occurred when loading the "${runner}" runner. This is not an error` ); console.error('with Pa11y itself, please check your runner configuration or contact the'); console.error('creator of this runner\n'); console.error(error.stack); throw error; } if (!runnerModule) { throw new Error(`Runner "${runner}" could not be found`); } return runnerModule; } /** * 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')); } } /** * A stringified runner, validated and ready for insertion into the DOM, * with `run` available via `window.__pa11y.runners` * @typedef {String} StringifiedRunner */ /** * Load a runner script. * @param {String} name The runner's name. For example, 'axe'. * @returns {StringifiedRunner} Stringified runner. * @throws {Error} Error, indicating the runner does not support this version of Pa11y. */ function loadRunnerScript(name) { const {scripts, supports, run} = loadRunnerFile(name); assertRunnerCompatibility(name, supports, pa11yVersion); const runnerBundle = scripts.map( script => fs.readFileSync(script, 'utf-8') ).join('\n\n'); // JSON.stringify escapes all characters required to produce a valid quoted string. return ` ;${runnerBundle}; ;window.__pa11y.runners[${JSON.stringify(name)}] = ${run.toString()}; `; } /* istanbul ignore next */ // eslint-disable-next-line no-empty-function const noop = () => {}; /** * Pa11y's configuration. * @typedef {pa11y.defaults} Pa11yConfiguration */ /** * Default options (excluding 'level', 'reporter', and 'threshold' which are only * relevant when calling bin/pa11y from the CLI) */ 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 }, levelCapWhenNeedsReview: 'error', method: 'GET', postData: null, rootElement: null, rules: [], runners: [ 'htmlcs' ], screenCapture: null, standard: 'WCAG2AA', timeout: 60000, userAgent: `pa11y/${pa11yVersion}`, viewport: { width: 1280, height: 1024 }, wait: 0 }; /** * A set of accessibility guidelines supported by Pa11y. * Pa11y will treat this as an upper-bound, testing to this standard and each 'below'. * @typedef { 'WCAG2A' | 'WCAG2AA' | 'WCAG2AAA' } Pa11yStandard */ /** * Pa11y's supported accessibility standards * @type { Array<Pa11yStandard> } */ pa11y.allowedStandards = [ 'WCAG2A', 'WCAG2AA', 'WCAG2AAA' ]; /** * Alias the `isValidAction` method */ pa11y.isValidAction = runAction.isValidAction;