testaro 58.3.5 → 59.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTRIBUTING.md CHANGED
@@ -114,8 +114,6 @@ More complex Testaro rules are implemented in JavaScript. Some rules are _simpli
114
114
 
115
115
  The `selector` value is a CSS selector that identifies candidate elements for violation reporting. What makes this rule simplifiable, instead of simple, is that these elements may or may not be determined to violate the rule. Each of the elements identified by the selector must be further analyzed by the pruner. The pruner takes a Playwright locator as its argument and returns `true` if it finds that the element located by the locator violates the rule, or `false` if not.
116
116
 
117
- The `isDestructive` property should be set to `true` if your pruner modifies the page. Any pruner that calls the `isOperable()` function from the `operable` module does so.
118
-
119
117
  ### Complex rules
120
118
 
121
119
  Even more complex Testaro rules require analysis that cannot fit into the simple or simplifiable category. You can begin with existing JavaScript rules, or the `data/template.js` file, as an example.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testaro",
3
- "version": "58.3.5",
3
+ "version": "59.0.1",
4
4
  "description": "Run 1000 web accessibility tests from 11 tools and get a standardized report",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/procs/testaro.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2023–2024 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -129,7 +130,7 @@ const report = exports.report = async (withItems, all, ruleID, whats, ordinalSev
129
130
  // Performs a simplifiable test.
130
131
  exports.simplify = async (page, withItems, ruleData) => {
131
132
  const {
132
- ruleID, selector, pruner, isDestructive, complaints, ordinalSeverity, summaryTagName
133
+ ruleID, selector, pruner, complaints, ordinalSeverity, summaryTagName
133
134
  } = ruleData;
134
135
  // Get an object with initialized violation locators and result as properties.
135
136
  const all = await init(100, page, selector);
@@ -149,16 +150,6 @@ exports.simplify = async (page, withItems, ruleData) => {
149
150
  complaints.summary
150
151
  ];
151
152
  const result = await report(withItems, all, ruleID, whats, ordinalSeverity, summaryTagName);
152
- // If the pruner modifies the page:
153
- if (isDestructive) {
154
- // Reload the page.
155
- try {
156
- await page.reload({timeout: 15000});
157
- }
158
- catch(error) {
159
- console.log('ERROR: page reload timed out');
160
- }
161
- }
162
153
  // Return the result.
163
154
  return result;
164
155
  };
package/run.js CHANGED
@@ -214,10 +214,15 @@ const goTo = async (report, page, url, timeout, waitUntil) => {
214
214
  // Closes the current browser.
215
215
  const browserClose = async () => {
216
216
  if (browser) {
217
- let contexts = browser.contexts();
218
- for (const context of contexts) {
219
- await context.close();
220
- contexts = browser.contexts();
217
+ for (const context of browser.contexts()) {
218
+ try {
219
+ await context.close();
220
+ }
221
+ catch(error) {
222
+ console.log(
223
+ `ERROR trying to close context: ${error.message.slice(0, 200).replace(/\n.+/s, '')}`
224
+ );
225
+ }
221
226
  }
222
227
  await browser.close();
223
228
  browser = null;
@@ -1447,7 +1452,6 @@ exports.doJob = async (job, opts = {}) => {
1447
1452
  }
1448
1453
  });
1449
1454
  // Perform the acts and get a report.
1450
- console.log('Performing the job acts');
1451
1455
  report = await doActs(report, opts);
1452
1456
  // Add the end time and duration to the report.
1453
1457
  const endTime = new Date();
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2023 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -64,7 +65,6 @@ exports.reporter = async (page, withItems) => {
64
65
  return transformStyle === 'uppercase' && elText.length > 7;
65
66
  }
66
67
  }),
67
- isDestructive: false,
68
68
  complaints: {
69
69
  instance: 'Element contains all-capital text',
70
70
  summary: 'Elements contain all-capital text'
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2023 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -47,7 +48,6 @@ exports.reporter = async (page, withItems) => {
47
48
  const elText = el.textContent;
48
49
  return ['italic', 'oblique'].includes(elStyleDec.fontStyle) && elText.length > 39;
49
50
  }),
50
- isDestructive: false,
51
51
  complaints: {
52
52
  instance: 'Element contains all-italic or all-oblique text',
53
53
  summary: 'Elements contain all-italic or all-oblique text'
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2023–2024 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -292,13 +293,6 @@ exports.reporter = async (page, withItems, trialKeySpecs = []) => {
292
293
  excerpt: ''
293
294
  });
294
295
  }
295
- // Reload the page, because attributes of elements were modified.
296
- try {
297
- await page.reload({timeout: 15000});
298
- }
299
- catch(error) {
300
- console.log('ERROR: page reload timed out');
301
- }
302
296
  return {
303
297
  data,
304
298
  totals,
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2023 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -48,7 +49,6 @@ exports.reporter = async (page, withItems) => {
48
49
  return transform
49
50
  && ['matrix', 'perspective', 'rotate', 'scale', 'skew'].some(key => transform.includes(key));
50
51
  }),
51
- isDestructive: false,
52
52
  complaints: {
53
53
  instance: 'Element distorts its text',
54
54
  summary: 'Elements distort their texts'
package/testaro/focAll.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2021–2023 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -77,13 +78,6 @@ exports.reporter = async page => {
77
78
  tabFocused,
78
79
  discrepancy: tabFocused - focusableCount
79
80
  };
80
- // Reload the page, because properties were added to elements.
81
- try {
82
- await page.reload({timeout: 15000});
83
- }
84
- catch(error) {
85
- console.log('ERROR: page reload timed out');
86
- }
87
81
  const count = Math.abs(data.discrepancy);
88
82
  // Return the result.
89
83
  return {
package/testaro/focOp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2021–2024 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -70,12 +71,5 @@ exports.reporter = async (page, withItems) => {
70
71
  'Element is Tab-focusable but not operable', 'Elements are Tab-focusable but not operable'
71
72
  ];
72
73
  const result = await report(withItems, all, 'focOp', whats, 2);
73
- // Reload the page, because isOperable() modified it.
74
- try {
75
- await page.reload({timeout: 15000});
76
- }
77
- catch(error) {
78
- console.log('ERROR: page reload timed out');
79
- }
80
74
  return result;
81
75
  };
package/testaro/hover.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2021–2024 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -104,12 +105,5 @@ exports.reporter = async (page, withItems) => {
104
105
  'Hovering over the element __param__',
105
106
  'Hovering over elements adds elements to or subtracts elements from the page'
106
107
  ];
107
- // Reload the page, because hovering may have caused content changes.
108
- try {
109
- await page.reload({timeout: 15000});
110
- }
111
- catch(error) {
112
- console.log('ERROR: page reload timed out');
113
- }
114
108
  return await report(withItems, all, 'hover', whats, 0);
115
109
  };
@@ -40,7 +40,6 @@ exports.reporter = async (page, withItems) => {
40
40
  return /\.(?:png|jpe?g|gif|svg|webp|ico)(?:$|[?#])/i.test(href);
41
41
  });
42
42
  },
43
- isDestructive: false,
44
43
  complaints: {
45
44
  instance: 'Link destination is an image file',
46
45
  summary: 'Links have image files as their destinations'
@@ -48,7 +48,6 @@ exports.reporter = async (page, withItems) => {
48
48
  return true;
49
49
  });
50
50
  },
51
- isDestructive: false,
52
51
  complaints: {
53
52
  instance: 'Element is not the first child of a fieldset element',
54
53
  summary: 'legend elements are not the first children of fieldset elements'
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2023 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -48,7 +49,6 @@ exports.reporter = async (page, withItems) => {
48
49
  const title = await loc.getAttribute('title');
49
50
  return elData.excerpt.toLowerCase().includes(title.toLowerCase());
50
51
  },
51
- isDestructive: false,
52
52
  complaints: {
53
53
  instance: 'Link has a title attribute that repeats link text content',
54
54
  summary: 'Links have title attributes that repeat link text contents'
package/testaro/linkUl.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2021–2023 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -59,7 +60,6 @@ exports.reporter = async (page, withItems) => {
59
60
  return await isInlineLink(loc);
60
61
  }
61
62
  },
62
- isDestructive: false,
63
63
  complaints: {
64
64
  instance: 'Link is inline but has no underline',
65
65
  summary: 'Inline links are missing underlines'
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2022–2023 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -80,7 +81,6 @@ exports.reporter = async (page, withItems) => {
80
81
  return true;
81
82
  }
82
83
  }),
83
- isDestructive: false,
84
84
  complaints: {
85
85
  instance: 'Table is misused to arrange content',
86
86
  summary: 'Tables are misused to arrange content'
package/testaro/opFoc.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2023–2024 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -71,12 +72,5 @@ exports.reporter = async (page, withItems) => {
71
72
  'Elements are operable but not Tab-focusable'
72
73
  ];
73
74
  const result = await report(withItems, all, 'opFoc', whats, 3);
74
- // Reload the page, because isOperable() modified it.
75
- try {
76
- await page.reload({timeout: 15000});
77
- }
78
- catch(error) {
79
- console.log('ERROR: page reload timed out');
80
- }
81
75
  return result;
82
76
  };
@@ -39,7 +39,6 @@ exports.reporter = async (page, withItems) => {
39
39
  return ! el.hasAttribute('aria-selected');
40
40
  });
41
41
  },
42
- isDestructive: false,
43
42
  complaints: {
44
43
  instance: 'Element has an explicit option role but no aria-selected attribute',
45
44
  summary: 'Elements with explicit option roles have no aria-selected attributes'
package/testaro/tabNav.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /*
2
2
  © 2021–2024 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025 Jonathan Robert Pool. All rights reserved.
3
4
 
4
5
  MIT License
5
6
 
@@ -454,13 +455,6 @@ exports.reporter = async (page, withItems) => {
454
455
  const tabLists = await page.$$('[role=tablist]:visible');
455
456
  if (tabLists.length) {
456
457
  await testTabLists(tabLists, withItems, page);
457
- // Reload the page, because keyboard navigation may have triggered content changes.
458
- try {
459
- await page.reload({timeout: 15000});
460
- }
461
- catch(error) {
462
- console.log('ERROR: page reload timed out');
463
- }
464
458
  }
465
459
  // Get the totals of navigation errors, bad tabs, and bad tab lists.
466
460
  const totals = data.totals ? [
package/tests/testaro.js CHANGED
@@ -32,6 +32,8 @@
32
32
 
33
33
  // Module to perform common operations.
34
34
  const {init, report} = require('../procs/testaro');
35
+ // Function to launch a browser.
36
+ const {launch} = require('../run');
35
37
  // Module to handle files.
36
38
  const fs = require('fs/promises');
37
39
 
@@ -59,23 +61,17 @@ const futureEvalRulesCleanRoom = {
59
61
  // when preparing clean-room submissions.
60
62
  const futureRules = new Set([]);
61
63
  const evalRules = {
62
- altScheme: 'img elements with alt attributes having URLs as their entire values',
63
- captionLoc: 'caption elements that are not first children of table elements',
64
- datalistRef: 'elements with ambiguous or missing referenced datalist elements',
65
- secHeading: 'headings that violate the logical level order in their sectioning containers',
66
- textSem: 'semantically vague elements i, b, and/or small',
67
64
  adbID: 'elements with ambiguous or missing referenced descriptions',
68
- imageLink: 'links with image files as their destinations',
69
- legendLoc: 'legend elements that are not first children of fieldset elements',
70
- optRoleSel: 'Non-option elements with option roles that have no aria-selected attributes',
71
- phOnly: 'input elements with placeholders but no accessible names',
72
65
  allCaps: 'leaf elements with entirely upper-case text longer than 7 characters',
73
66
  allHidden: 'page that is entirely or mostly hidden',
74
67
  allSlanted: 'leaf elements with entirely italic or oblique text longer than 39 characters',
68
+ altScheme: 'img elements with alt attributes having URLs as their entire values',
75
69
  attVal: 'duplicate attribute values',
76
70
  autocomplete: 'name and email inputs without autocomplete attributes',
77
71
  bulk: 'large count of visible elements',
78
72
  buttonMenu: 'nonstandard keyboard navigation between items of button-controlled menus',
73
+ captionLoc: 'caption elements that are not first children of table elements',
74
+ datalistRef: 'elements with ambiguous or missing referenced datalist elements',
79
75
  distortion: 'distorted text',
80
76
  docType: 'document without a doctype property',
81
77
  dupAtt: 'elements with duplicate attributes',
@@ -89,7 +85,9 @@ const evalRules = {
89
85
  hover: 'hover-caused content changes',
90
86
  hovInd: 'hover indication nonstandard',
91
87
  hr: 'hr element instead of styles used for vertical segmentation',
88
+ imageLink: 'links with image files as their destinations',
92
89
  labClash: 'labeling inconsistencies',
90
+ legendLoc: 'legend elements that are not first children of fieldset elements',
93
91
  lineHeight: 'text with a line height less than 1.5 times its font size',
94
92
  linkAmb: 'links with identical texts but different destinations',
95
93
  linkExt: 'links that automatically open new windows',
@@ -101,13 +99,17 @@ const evalRules = {
101
99
  motion: 'motion without user request',
102
100
  nonTable: 'table elements used for layout',
103
101
  opFoc: 'operable elements that are not Tab-focusable',
102
+ optRoleSel: 'Non-option elements with option roles that have no aria-selected attributes',
103
+ phOnly: 'input elements with placeholders but no accessible names',
104
104
  pseudoP: 'adjacent br elements suspected of nonsemantically simulating p elements',
105
105
  radioSet: 'radio buttons not grouped into standard field sets',
106
106
  role: 'native-replacing explicit roles',
107
+ secHeading: 'headings that violate the logical level order in their sectioning containers',
107
108
  styleDiff: 'style inconsistencies',
108
109
  tabNav: 'nonstandard keyboard navigation between elements with the tab role',
109
110
  targetSmall: 'buttons, inputs, and non-inline links smaller than 44 pixels wide and high',
110
111
  targetTiny: 'buttons, inputs, and non-inline links smaller than 24 pixels wide and high',
112
+ textSem: 'semantically vague elements i, b, and/or small',
111
113
  titledEl: 'title attributes on inappropriate elements',
112
114
  zIndex: 'non-default Z indexes'
113
115
  };
@@ -117,6 +119,37 @@ const etcRules = {
117
119
  textNodes: 'data on specified text nodes',
118
120
  title: 'page title',
119
121
  };
122
+ // Tests that modify the page.
123
+ const contaminators = [
124
+ 'buttonMenu',
125
+ 'elements',
126
+ 'focAll',
127
+ 'focOp',
128
+ 'focInd',
129
+ 'hover',
130
+ 'hovInd',
131
+ 'motion',
132
+ 'opFoc',
133
+ 'tabNav',
134
+ 'textNodes'
135
+ ];
136
+ // Extraordinary time limits on rules.
137
+ const slowTestLimits = {
138
+ allCaps: 10,
139
+ buttonMenu: 15,
140
+ distortion: 10,
141
+ docType: 10,
142
+ focAll: 10,
143
+ focVis: 10,
144
+ hover: 10,
145
+ hovInd: 10,
146
+ labClash: 10,
147
+ lineHeight: 10,
148
+ motion: 15,
149
+ opFoc: 10,
150
+ tabNav: 10,
151
+ textSem: 10
152
+ };
120
153
 
121
154
  // ######## FUNCTIONS
122
155
 
@@ -148,6 +181,7 @@ const wait = ms => {
148
181
  };
149
182
  // Conducts and reports Testaro tests.
150
183
  exports.reporter = async (page, report, actIndex) => {
184
+ const url = await page.url();
151
185
  const act = report.acts[actIndex];
152
186
  const {args, stopOnFail, withItems} = act;
153
187
  const argRules = args ? Object.keys(args) : null;
@@ -178,18 +212,44 @@ exports.reporter = async (page, report, actIndex) => {
178
212
  ) {
179
213
  // Wait 1 second to prevent out-of-order logging with granular reporting.
180
214
  await wait(1000);
181
- // For each rule invoked except future rules:
182
- const calledRules = rules[0] === 'y'
215
+ let calledRules = rules[0] === 'y'
183
216
  ? rules.slice(1)
184
217
  : Object.keys(evalRules).filter(ruleID => ! rules.slice(1).includes(ruleID));
218
+ const calledContaminators = calledRules.filter(rule => contaminators.includes(rule)).sort();
219
+ const calledBenignRules = calledRules.filter(rule => ! contaminators.includes(rule)).sort();
185
220
  const testTimes = [];
186
- for (const rule of calledRules.filter(rule => ! futureRules.has(rule))) {
221
+ let contaminatorsStarted = false;
222
+ // Starting with the noncontaminators, for each rule invoked:
223
+ for (const rule of calledBenignRules.concat(calledContaminators)) {
224
+ const pageClosed = page.isClosed();
225
+ const isContaminator = contaminators.includes(rule);
226
+ // If it is a contaminator other than the first one or the page has closed:
227
+ if (contaminatorsStarted || pageClosed) {
228
+ // If the page has closed:
229
+ if (pageClosed) {
230
+ // Report this.
231
+ console.log(`WARNING: Relaunching browser for test ${rule} after abnormal closure`);
232
+ }
233
+ // Replace the browser and the page and navigate to the target.
234
+ await launch(
235
+ report,
236
+ process.env.DEBUG === 'true',
237
+ Number.parseInt(process.env.WAITS) || 0,
238
+ report.browserID,
239
+ url
240
+ );
241
+ page = require('../run').page;
242
+ }
243
+ // If it is a contaminator, ensure that future tests use new browsers.
244
+ if (isContaminator) {
245
+ contaminatorsStarted = true;
246
+ }
187
247
  // Initialize an argument array.
188
248
  const ruleArgs = [page, withItems];
189
- // If the rule is defined with JavaScript or JSON but not both:
190
249
  const ruleFileNames = await fs.readdir(`${__dirname}/../testaro`);
191
250
  const isJS = ruleFileNames.includes(`${rule}.js`);
192
251
  const isJSON = ruleFileNames.includes(`${rule}.json`);
252
+ // If the rule is defined with JavaScript or JSON but not both:
193
253
  if ((isJS || isJSON) && ! (isJS && isJSON)) {
194
254
  // If with JavaScript and it has extra arguments:
195
255
  if (isJS && argRules && argRules.includes(rule)) {
@@ -204,8 +264,10 @@ exports.reporter = async (page, report, actIndex) => {
204
264
  result[rule].what = what;
205
265
  const startTime = Date.now();
206
266
  try {
207
- // Apply a 15-second time limit to the test. If it expires:
267
+ // Apply a time limit to the test.
268
+ const timeLimit = 1000 * (slowTestLimits[rule] ?? 5);
208
269
  let timeout;
270
+ // If the time limit expires during the test:
209
271
  const timer = new Promise(resolve => {
210
272
  timeout = setTimeout(() => {
211
273
  // Add data about the test, including its prevention, to the result.
@@ -216,24 +278,26 @@ exports.reporter = async (page, report, actIndex) => {
216
278
  result[rule].standardInstances = [];
217
279
  console.log(`ERROR: Test of testaro rule ${rule} timed out`);
218
280
  resolve({timedOut: true});
219
- }, 15000);
281
+ }, timeLimit);
220
282
  });
283
+ // Perform the test, subject to the time limit.
221
284
  const ruleReport = isJS
222
285
  ? require(`../testaro/${rule}`).reporter(... ruleArgs)
223
286
  : jsonTest(rule, ruleArgs);
224
- const timeoutReport = await Promise.race([timer, ruleReport]);
287
+ // Get the test result or a timeout result.
288
+ const ruleOrTimeoutReport = await Promise.race([timer, ruleReport]);
225
289
  clearTimeout(timeout);
226
- // If the test was completed before the deadline:
227
- if (! timeoutReport.timedOut) {
290
+ // If the test was completed:
291
+ if (! ruleOrTimeoutReport.timedOut) {
228
292
  // Add data from the test to the result.
229
293
  const endTime = Date.now();
230
294
  testTimes.push([rule, Math.round((endTime - startTime) / 1000)]);
231
- Object.keys(timeoutReport).forEach(key => {
232
- result[rule][key] = timeoutReport[key];
295
+ Object.keys(ruleOrTimeoutReport).forEach(key => {
296
+ result[rule][key] = ruleOrTimeoutReport[key];
233
297
  });
234
298
  result[rule].totals = result[rule].totals.map(total => Math.round(total));
235
299
  // If testing is to stop after a failure and the page failed the test:
236
- if (stopOnFail && timeoutReport.totals.some(total => total)) {
300
+ if (stopOnFail && ruleOrTimeoutReport.totals.some(total => total)) {
237
301
  // Stop testing.
238
302
  break;
239
303
  }
@@ -241,7 +305,7 @@ exports.reporter = async (page, report, actIndex) => {
241
305
  }
242
306
  // If an error is thrown by the test:
243
307
  catch(error) {
244
- // Report this.
308
+ // Report the error.
245
309
  data.rulePreventions.push(rule);
246
310
  data.rulePreventionMessages[rule] = error.message;
247
311
  console.log(`ERROR: Test of testaro rule ${rule} prevented (${error.message})`);
@@ -254,6 +318,7 @@ exports.reporter = async (page, report, actIndex) => {
254
318
  console.log(`ERROR: Rule ${rule} not validly defined`);
255
319
  }
256
320
  }
321
+ // Record the test times in descending order.
257
322
  testTimes.sort((a, b) => b[1] - a[1]).forEach(pair => {
258
323
  data.ruleTestTimes[pair[0]] = pair[1];
259
324
  });