Skip to content

Commit 32ed8da

Browse files
WilcoFiersCopilotstrakerdbjorge
authored
feat: incomplete with node on which an error occurred (#4863)
Part of #4860 If a check or matches method throws, catch the error and add a node with an `error-occurred` none check. This way incomplete results are always reported. This only happens for axe.run / axe.runPartial. `rule.runSync()` still throws if an occurs in a rule or check. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com> Co-authored-by: Dan Bjorge <dan.bjorge@deque.com> Co-authored-by: WilcoFiers <530687+WilcoFiers@users.noreply.github.com>
1 parent 0da4301 commit 32ed8da

File tree

25 files changed

+1394
-677
lines changed

25 files changed

+1394
-677
lines changed

axe.d.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ declare namespace axe {
155155
toolOptions: RunOptions;
156156
passes: Result[];
157157
violations: Result[];
158-
incomplete: Result[];
158+
incomplete: IncompleteResult[];
159159
inapplicable: Result[];
160160
}
161161
interface Result {
@@ -167,6 +167,9 @@ declare namespace axe {
167167
tags: TagValue[];
168168
nodes: NodeResult[];
169169
}
170+
interface IncompleteResult extends Result {
171+
error?: Omit<SupportError, 'errorNode'>;
172+
}
170173
interface NodeResult {
171174
html: string;
172175
impact?: ImpactValue;
@@ -204,6 +207,21 @@ declare namespace axe {
204207
fail: string | { [key: string]: string };
205208
incomplete?: string | { [key: string]: string };
206209
}
210+
interface SupportError {
211+
name: string;
212+
message: string;
213+
stack: string;
214+
ruleId?: string;
215+
method?: string;
216+
cause?: SerialError;
217+
errorNode?: DqElement;
218+
}
219+
interface SerialError {
220+
message: string;
221+
stack: string;
222+
name: string;
223+
cause?: SerialError;
224+
}
207225
interface CheckLocale {
208226
[key: string]: CheckMessages;
209227
}
@@ -461,7 +479,13 @@ declare namespace axe {
461479
isLabelledShadowDomSelector: (
462480
selector: unknown
463481
) => selector is LabelledShadowDomSelector;
464-
482+
SupportError: (
483+
error: Error,
484+
ruleId?: string,
485+
method?: string,
486+
errorNode?: DqElement
487+
) => SupportError;
488+
serializeError: (error: Error) => SerialError;
465489
DqElement: DqElementConstructor;
466490
uuid: (
467491
options?: { random?: Uint8Array | Array<number> },
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"id": "error-occurred",
3+
"evaluate": "exists-evaluate",
4+
"metadata": {
5+
"messages": {
6+
"pass": "",
7+
"incomplete": "Axe encountered an error; test the page for this type of problem manually"
8+
}
9+
}
10+
}

lib/core/base/audit.js

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
preload,
1010
findBy,
1111
ruleShouldRun,
12-
performanceTimer
12+
performanceTimer,
13+
serializeError
1314
} from '../utils';
1415
import { doT } from '../imports';
1516
import constants from '../constants';
@@ -368,14 +369,24 @@ export default class Audit {
368369
after(results, options) {
369370
const rules = this.rules;
370371
return results.map(ruleResult => {
372+
if (ruleResult.error) {
373+
return ruleResult;
374+
}
371375
const rule = findBy(rules, 'id', ruleResult.id);
372376
if (!rule) {
373377
// If you see this, you're probably running the Mocha tests with the axe extension installed
374378
throw new Error(
375379
'Result for unknown rule. You may be running mismatch axe-core versions'
376380
);
377381
}
378-
return rule.after(ruleResult, options);
382+
try {
383+
return rule.after(ruleResult, options);
384+
} catch (err) {
385+
if (options.debug) {
386+
throw err;
387+
}
388+
return createIncompleteErrorResult(rule, err);
389+
}
379390
});
380391
}
381392
/**
@@ -732,36 +743,37 @@ function getDefferedRule(rule, context, options) {
732743
rule.run(
733744
context,
734745
options,
735-
// resolve callback for rule `run`
736-
ruleResult => {
737-
// resolve
738-
resolve(ruleResult);
739-
},
740-
// reject callback for rule `run`
746+
ruleResult => resolve(ruleResult),
741747
err => {
742-
// if debug - construct error details
743-
if (!options.debug) {
744-
const errResult = Object.assign(new RuleResult(rule), {
745-
result: constants.CANTTELL,
746-
description: 'An error occured while running this rule',
747-
message: err.message,
748-
stack: err.stack,
749-
error: err,
750-
// Add a serialized reference to the node the rule failed on for easier debugging.
751-
// See https://github.com/dequelabs/axe-core/issues/1317.
752-
errorNode: err.errorNode
753-
});
754-
// resolve
755-
resolve(errResult);
756-
} else {
757-
// reject
748+
if (options.debug) {
758749
reject(err);
750+
} else {
751+
resolve(createIncompleteErrorResult(rule, err));
759752
}
760753
}
761754
);
762755
};
763756
}
764757

758+
function createIncompleteErrorResult(rule, error) {
759+
const { errorNode } = error;
760+
const serialError = serializeError(error);
761+
const none = [
762+
{
763+
id: 'error-occurred',
764+
result: undefined,
765+
data: serialError,
766+
relatedNodes: []
767+
}
768+
];
769+
const node = errorNode || new DqElement(document.documentElement);
770+
return Object.assign(new RuleResult(rule), {
771+
error: serialError,
772+
result: constants.CANTTELL,
773+
nodes: [{ any: [], all: [], none, node }]
774+
});
775+
}
776+
765777
/**
766778
* For all the rules, create the helpUrl and add it to the data for that rule
767779
*/

lib/core/base/rule.js

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/*global SupportError */
21
import { createExecutionContext } from './check';
32
import RuleResult from './rule-result';
43
import {
@@ -8,7 +7,8 @@ import {
87
queue,
98
DqElement,
109
select,
11-
assert
10+
assert,
11+
RuleError
1212
} from '../utils';
1313
import { isVisibleToScreenReaders } from '../../commons/dom';
1414
import constants from '../constants';
@@ -181,7 +181,16 @@ Rule.prototype.runChecks = function runChecks(
181181
const check = self._audit.checks[c.id || c];
182182
const option = getCheckOption(check, self.id, options);
183183
checkQueue.defer((res, rej) => {
184-
check.run(node, option, context, res, rej);
184+
check.run(node, option, context, res, error => {
185+
rej(
186+
new RuleError({
187+
ruleId: self.id,
188+
method: `${check.id}#evaluate`,
189+
errorNode: new DqElement(node),
190+
error
191+
})
192+
);
193+
});
185194
});
186195
});
187196

@@ -235,8 +244,7 @@ Rule.prototype.run = function run(context, options = {}, resolve, reject) {
235244
// Matches throws an error when it lacks support for document methods
236245
nodes = this.gatherAndMatchNodes(context, options);
237246
} catch (error) {
238-
// Exit the rule execution if matches fails
239-
reject(new SupportError({ cause: error, ruleId: this.id }));
247+
reject(error);
240248
return;
241249
}
242250

@@ -312,15 +320,7 @@ Rule.prototype.runSync = function runSync(context, options = {}) {
312320
}
313321

314322
const ruleResult = new RuleResult(this);
315-
let nodes;
316-
317-
try {
318-
nodes = this.gatherAndMatchNodes(context, options);
319-
} catch (error) {
320-
// Exit the rule execution if matches fails
321-
throw new SupportError({ cause: error, ruleId: this.id });
322-
}
323-
323+
const nodes = this.gatherAndMatchNodes(context, options);
324324
if (options.performanceTimer) {
325325
this._logGatherPerformance(nodes);
326326
}
@@ -451,7 +451,18 @@ Rule.prototype.gatherAndMatchNodes = function gatherAndMatchNodes(
451451
performanceTimer.mark(markMatchesStart);
452452
}
453453

454-
nodes = nodes.filter(node => this.matches(node.actualNode, node, context));
454+
nodes = nodes.filter(node => {
455+
try {
456+
return this.matches(node.actualNode, node, context);
457+
} catch (error) {
458+
throw new RuleError({
459+
ruleId: this.id,
460+
method: `#matches`,
461+
errorNode: new DqElement(node),
462+
error
463+
});
464+
}
465+
});
455466

456467
if (options.performanceTimer) {
457468
performanceTimer.mark(markMatchesEnd);
@@ -542,12 +553,20 @@ function sanitizeNodes(result) {
542553
*/
543554
Rule.prototype.after = function after(result, options) {
544555
const afterChecks = findAfterChecks(this);
545-
const ruleID = this.id;
546556
afterChecks.forEach(check => {
547557
const beforeResults = findCheckResults(result.nodes, check.id);
548-
const checkOption = getCheckOption(check, ruleID, options);
549-
550-
const afterResults = check.after(beforeResults, checkOption.options);
558+
const checkOption = getCheckOption(check, this.id, options);
559+
let afterResults;
560+
try {
561+
afterResults = check.after(beforeResults, checkOption.options);
562+
} catch (error) {
563+
throw new RuleError({
564+
ruleId: this.id,
565+
method: `${check.id}#after`,
566+
errorNode: result.nodes?.[0]?.node,
567+
error
568+
});
569+
}
551570

552571
if (this.reviewOnFail) {
553572
afterResults.forEach(checkResult => {

lib/core/constants.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,15 @@ const constants = {
4747
timeout: 10000
4848
}),
4949
allOrigins: '<unsafe_all_origins>',
50-
sameOrigin: '<same_origin>'
50+
sameOrigin: '<same_origin>',
51+
serializableErrorProps: Object.freeze([
52+
'message',
53+
'stack',
54+
'name',
55+
'code',
56+
'ruleId',
57+
'method'
58+
])
5159
};
5260

5361
definitions.forEach(definition => {

lib/core/index.js

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,3 @@ if (typeof window.getComputedStyle === 'function') {
2525
}
2626
// local namespace for common functions
2727
let commons;
28-
29-
function SupportError(error) {
30-
this.name = 'SupportError';
31-
this.cause = error.cause;
32-
this.message = `\`${error.cause}\` - feature unsupported in your environment.`;
33-
if (error.ruleId) {
34-
this.ruleId = error.ruleId;
35-
this.message += ` Skipping ${this.ruleId} rule.`;
36-
}
37-
this.stack = new Error().stack;
38-
}
39-
SupportError.prototype = Object.create(Error.prototype);
40-
SupportError.prototype.constructor = SupportError;

lib/core/utils/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export { default as ruleShouldRun } from './rule-should-run';
8787
export { default as filterHtmlAttrs } from './filter-html-attrs';
8888
export { default as select } from './select';
8989
export { default as sendCommandToFrame } from './send-command-to-frame';
90+
export { default as serializeError } from './serialize-error';
91+
export { default as RuleError } from './rule-error';
9092
export { default as setScrollState } from './set-scroll-state';
9193
export { default as shadowSelect } from './shadow-select';
9294
export { default as shadowSelectAll } from './shadow-select-all';

lib/core/utils/merge-results.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ function mergeResults(frameResults, options) {
9595
if (ruleResult.nodes.length) {
9696
spliceNodes(res.nodes, ruleResult.nodes);
9797
}
98+
if (ruleResult.error) {
99+
res.error ??= ruleResult.error;
100+
}
98101
}
99102
});
100103
});

lib/core/utils/rule-error.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import serializeError from './serialize-error';
2+
3+
export default class RuleError extends Error {
4+
constructor({ error, ruleId, method, errorNode }) {
5+
super();
6+
this.name = error.name ?? 'RuleError';
7+
this.message = error.message;
8+
this.stack = error.stack;
9+
if (error.cause) {
10+
this.cause = serializeError(error.cause);
11+
}
12+
if (ruleId) {
13+
this.ruleId = ruleId;
14+
this.message += ` Skipping ${this.ruleId} rule.`;
15+
}
16+
if (method) {
17+
this.method = method;
18+
}
19+
if (errorNode) {
20+
this.errorNode = errorNode;
21+
}
22+
}
23+
}

lib/core/utils/serialize-error.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import constants from '../constants';
2+
3+
/**
4+
* Serializes an error to a JSON object
5+
* @param e - The error to serialize
6+
* @returns A JSON object representing the error
7+
*/
8+
export default function serializeError(err, iteration = 0) {
9+
if (typeof err !== 'object' || err === null) {
10+
return { message: String(err) };
11+
}
12+
const serial = {};
13+
for (const prop of constants.serializableErrorProps) {
14+
if (['string', 'number', 'boolean'].includes(typeof err[prop])) {
15+
serial[prop] = err[prop];
16+
}
17+
}
18+
// Recursively serialize cause up to 10 levels deep
19+
if (err.cause) {
20+
serial.cause =
21+
iteration < 10 ? serializeError(err.cause, iteration + 1) : '...';
22+
}
23+
return serial;
24+
}

0 commit comments

Comments
 (0)