Skip to content

Commit

Permalink
Add aXe rule and infrastructure to evaluate JS
Browse files Browse the repository at this point in the history
* Create a new method in the collectors `evaluate` that accepts
  a `script` in `string` format and will return a `Promise`.
* Document how to evaluate JavaScript.
* Add `aXe` rule with configuration and validation.
* Refactor how collectors are tested. Now each area has its own
  section instead of each collector its file.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Fix #129
Fix #128

Close #178
  • Loading branch information
molant authored and alrra committed May 6, 2017
1 parent 788b0d1 commit 19a1286
Show file tree
Hide file tree
Showing 21 changed files with 953 additions and 348 deletions.
1 change: 1 addition & 0 deletions .sonarrc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
},
"formatter": "json",
"rules": {
"axe": "warning",
"disallowed-headers": "warning",
"disown-opener": "warning",
"lang-attribute": "warning",
Expand Down
43 changes: 43 additions & 0 deletions docs/developer-guide/rules/how-to-evaluate-javascript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# How to evaluate JavaScript

Sometimes a rule needs to evaluate some JavaScript in the context of the page.
To do that you need to use `context.evaluate`. This method will always return
a `Promise` even if your code does not return one.

One important thing is that your code needs to be wrapped in an Immediate
Invoked Function Expression to work.

The following scripts will work:

<!-- eslint-disable -->
```js
const script =
`(function() {
return true;
}())`;

context.evaluate(script);
```

```js
const script =
`(function() {
return Promise.resolve(true);
}())`;

context.evaluate(script);
```

The following does not:

```js
const script = `return true;`;

context.evaluate(script);
```

```js
const script = `return Promise.resolve(true);`;

context.evaluate(script);
```
93 changes: 93 additions & 0 deletions docs/user-guide/rules/axe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Accessibility assesment with aXe

aXe is the Accessibility Engine for automated testing of HTML-based user
interfaces. This rules performs the default accessibility tests (WCAG 2.0
Level A and Level AA rules) and alerts if something fails.

## Why is this important?

> The Web is an increasingly important resource in many aspects of life: education,
employment, government, commerce, health care, recreation, and more. It is
essential that the Web be accessible in order to provide **equal access** and **equal
opportunity** to people with disabilities. An accessible Web can also help people
with disabilities more actively participate in society.
>
> The Web offers the possibility of **unprecedented access to information and
interaction** for many people with disabilities. That is, the accessibility barriers
to print, audio, and visual media can be much more easily overcome through Web
technologies.
>
> The document ["Social Factors in Developing a Web Accessibility Business Case for
Your Organization"](https://www.w3.org/WAI/bcase/soc) discusses how the Web
impacts the lives of people with disabilities, the overlap with digital divide
issues, and Web accessibility as an aspect of corporate social responsibility.
>
> Another important consideration for organizations is that Web accessibility is
required by laws and policies in some cases.

***From [WAI's Introduction to Web Accessibility](https://www.w3.org/WAI/intro/accessibility.php).***

## What does the rule check?

By default this rule runs all the [WCAG 2.0](https://www.w3.org/TR/WCAG20/)
Level A and Level AA rules included in [axe-core](https://github.com/dequelabs/axe-core/)
with `document` as the target. Visit the
[full list of default enabled rules](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md)
for more information of what they do.

## Can the rule be configured?

This rule uses
[`axe.run`](https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#api-name-axerun)
and the default values ([WCAG 2.0](https://www.w3.org/TR/WCAG20/) Level A and
Level AA rules) over the `document`.
You can modify what rules or categories are executed via an `options` object
that follows
[aXe's documentation](https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter).

Some examples of configurations:

* Run only WCAG 2.0 Level A rules:

```json
{
"axe": ["error", {
"runOnly": {
"type": "tag",
"values": ["wcag2a"]
}
}]
}
```

* Run only a specified set of rules:

```json
{
"axe": ["error", {
"runOnly": {
"type": "rule",
"values": ["ruleId1", "ruleId2", "ruleId3" ]
}
}]
}
```

* Run all enabled rules except for a list of rules:

```json
{
"axe": ["error",{
"rules": {
"color-contrast": { "enabled": false },
"valid-lang": { "enabled": false }
}
}]
}
```

## Further Reading

* [Deque Univeristy](https://dequeuniversity.com/)
* [aXe core GitHub page](https://github.com/dequelabs/axe-core)
* [Web Accessibility Initiative (WAI)](https://www.w3.org/WAI/)
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"homepage": "https://github.com/MicrosoftEdge/Sonar#readme",
"dependencies": {
"browserslist": "^2.1.1",
"axe-core": "^2.2.0",
"chalk": "^1.1.3",
"chrome-remote-interface": "^0.22.0",
"debug": "^2.6.1",
Expand Down Expand Up @@ -84,7 +85,7 @@
"sinon": "^2.1.0",
"ts-node": "^3.0.2",
"typedoc": "^0.6.0",
"typescript": "^2.2.2",
"typescript": "^2.3.2",
"typescript-eslint-parser": "^2.1.0"
},
"ava": {
Expand All @@ -94,7 +95,7 @@
"files": [
"dist/tests/**/*.js"
],
"failFast": false,
"failFast": true,
"concurrency": 5,
"timeout": "1m"
},
Expand Down
82 changes: 81 additions & 1 deletion src/lib/collectors/cdp/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ class CDPCollector implements ICollector {
}
}

async fetchContent(target: URL | string, customHeaders?: object): Promise<INetworkData> {
public async fetchContent(target: URL | string, customHeaders?: object): Promise<INetworkData> {
// TODO: This should create a new tab, navigate to the
// resource and control what is received somehow via an event.
let req;
Expand Down Expand Up @@ -531,6 +531,86 @@ class CDPCollector implements ICollector {
};
}

/**
* The `exceptionDetails` provided by the debugger protocol does not contain the useful
* information such as name, message, and stack trace of the error when it's wrapped in a
* promise. Instead, map to a successful object that contains this information.
* @param {string|Error} err The error to convert
* istanbul ignore next
*/
private wrapRuntimeEvalErrorInBrowser(e) {
const err = e || new Error();
const fallbackMessage = typeof err === 'string' ? err : 'unknown error';

return {
__failedInBrowser: true,
message: err.message || fallbackMessage,
name: err.name || 'Error',
stack: err.stack || (new Error()).stack
};
}

/** Asynchronoulsy evaluates the given JavaScript code into the browser.
*
* This awesomeness comes from lighthouse
*/
public evaluate(code): Promise<any> {

return new Promise(async (resolve, reject) => {
// If this gets to 60s and it hasn't been resolved, reject the Promise.
const asyncTimeout = setTimeout(
(() => {
reject(new Error('The asynchronous expression exceeded the allotted time of 60s'));
}), 60000);

try {
const expression = `(function wrapInNativePromise() {
const __nativePromise = window.__nativePromise || Promise;
return new __nativePromise(function (resolve) {
return __nativePromise.resolve()
.then(_ => ${code})
.catch(function ${this.wrapRuntimeEvalErrorInBrowser.toString()})
.then(resolve);
});
}())`;

const result = await this._client.Runtime.evaluate({
awaitPromise: true,
// We need to explicitly wrap the raw expression for several purposes:
// 1. Ensure that the expression will be a native Promise and not a polyfill/non-Promise.
// 2. Ensure that errors in the expression are captured by the Promise.
// 3. Ensure that errors captured in the Promise are converted into plain-old JS Objects
// so that they can be serialized properly b/c JSON.stringify(new Error('foo')) === '{}'
expression,
includeCommandLineAPI: true,
returnByValue: true
});

clearTimeout(asyncTimeout);
const value = result.result.value;

if (result.exceptionDetails) {
// An error occurred before we could even create a Promise, should be *very* rare
return reject(new Error('an unexpected driver error occurred'));
}

if (value && value.__failedInBrowser) {
return reject(Object.assign(new Error(), value));
}

return resolve(value);
} catch (err) {
clearTimeout(asyncTimeout);

return reject(err);
}
});
}

public querySelectorAll(selector: string) {
return this._dom.querySelectorAll(selector);
}

// ------------------------------------------------------------------------------
// Getters
// ------------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/lib/collectors/jsdom/jsdom-async-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class JSDOMAsyncHTMLDocument implements IAsyncHTMLDocument {
// Public methods
// ------------------------------------------------------------------------------

querySelectorAll(selector: string) {
querySelectorAll(selector: string): Promise<JSDOMAsyncHTMLElement[]> {
const elements = Array.prototype.slice.call(this._document.querySelectorAll(selector))
.map((element) => {
return new JSDOMAsyncHTMLElement(element); // eslint-disable-line no-use-before-define
Expand Down
29 changes: 26 additions & 3 deletions src/lib/collectors/jsdom/jsdom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,19 @@

import * as path from 'path';
import * as url from 'url';
import * as vm from 'vm';

import * as jsdom from 'jsdom/lib/old-api';

import { debug as d } from '../../utils/debug';
/* eslint-disable no-unused-vars */
import {
IAsyncHTMLDocument, IAsyncHTMLElement, ICollector, ICollectorBuilder,
IAsyncHTMLElement, ICollector, ICollectorBuilder,
IElementFoundEvent, IFetchEndEvent, IFetchErrorEvent, IManifestFetchErrorEvent, IManifestFetchEnd, ITraverseDownEvent, ITraverseUpEvent,
INetworkData, URL
} from '../../types';
/* eslint-enable */
import { JSDOMAsyncHTMLElement } from './jsdom-async-html';
import { JSDOMAsyncHTMLElement, JSDOMAsyncHTMLDocument } from './jsdom-async-html';
import { readFileAsync } from '../../utils/misc';
import { Requester } from '../utils/requester'; //eslint-disable-line
import { Sonar } from '../../sonar'; // eslint-disable-line no-unused-vars
Expand Down Expand Up @@ -66,6 +67,8 @@ class JSDOMCollector implements ICollector {
private _finalHref: string;
private _targetNetworkData: INetworkData;
private _manifestIsSpecified: boolean = false;
private _window: Window;
private _document: JSDOMAsyncHTMLDocument;

constructor(server: Sonar, config: object) {
this._options = Object.assign({}, defaultOptions, config);
Expand Down Expand Up @@ -345,6 +348,9 @@ class JSDOMCollector implements ICollector {
return;
}

this._window = window;
this._document = new JSDOMAsyncHTMLDocument(window.document);

/* Even though `done()` is called after window.onload (so all resoruces and scripts executed),
we might want to wait a few seconds if the site is lazy loading something. */
setTimeout(async () => {
Expand Down Expand Up @@ -381,7 +387,8 @@ class JSDOMCollector implements ICollector {
}

public close() {
// With JSDOM there is nothing to release
this._window.close();

return Promise.resolve();
}

Expand Down Expand Up @@ -410,6 +417,22 @@ class JSDOMCollector implements ICollector {
return this._fetchUrl(parsedTarget, customHeaders);
}

public evaluate(source: string) {
//TODO: Have a timeout the same way CDP does
const script = new vm.Script(source);
const result = jsdom.evalVMScript(this._window, script);

if (result[Symbol.toStringTag] === 'Promise') {
return result;
}

return Promise.resolve(result);
}

public querySelectorAll(selector: string): Promise<JSDOMAsyncHTMLElement[]> {
return this._document.querySelectorAll(selector);
}

// ------------------------------------------------------------------------------
// Getters
// ------------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/lib/formatters/json/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const formatter: IFormatter = {
const sortedMessages = _.sortBy(msgs, ['line', 'column']);

logger.log(`${resource}: ${msgs.length} issues`);
logger.log(sortedMessages);
logger.log(JSON.stringify(sortedMessages, null, 2));
});
}
};
Expand Down
Loading

0 comments on commit 19a1286

Please sign in to comment.