-
Notifications
You must be signed in to change notification settings - Fork 255
/
pa11y.js
465 lines (417 loc) · 13.6 KB
/
pa11y.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
'use strict';
const runAction = require('./action');
const option = require('./option');
const fs = require('fs');
const pkg = require('../package.json');
const promiseTimeout = require('p-timeout');
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 promiseTimeout(
runPa11yTest(url, options, state),
options.timeout,
`Pa11y timed out (${options.timeout}ms)`
);
} catch (error) {
// Capture error if a callback is provided, otherwise reject with 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(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.removeListener('request', state.requestInterceptCallback);
state.page.removeListener('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) {
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 we can
// output them for debugging purposes
state.consoleCallback = message => {
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');
overrides.method = options.method;
// Override the request headers (and include the user-agent)
options.log.debug('Setting request headers');
overrides.headers = {};
for (const [key, value] of Object.entries(options.headers)) {
overrides.headers[key.toLowerCase()] = value;
}
// Override the request POST data if present
if (options.postData) {
options.log.debug('Setting request POST data');
overrides.postData = options.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(`${__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 reporter does not support the given version of Pa11y
* @returns {void}
*/
function assertReporterCompatibility(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',
`Reporter 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 reporter does not support the given version of Pa11y
* @returns {String} Javascript source of the runner
*/
function loadRunnerScript(runner) {
const runnerModule = loadRunnerFile(runner);
let runnerBundle = '';
assertReporterCompatibility(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: 30000,
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;