testaro 3.0.0 → 4.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/README.md +69 -29
- package/commands.js +15 -1
- package/index.js +257 -136
- package/package.json +2 -1
- package/samples/scripts/simple.json +2 -3
- package/samples/scripts/tenon.json +28 -0
- package/tests/ibm.js +45 -19
- package/tests/tenon.js +123 -0
- package/validation/executors/tenon.js +18 -0
package/README.md
CHANGED
|
@@ -10,16 +10,6 @@ The purpose of Testaro is to provide programmatic access to over 600 accessibili
|
|
|
10
10
|
|
|
11
11
|
Running Testaro requires telling it which operations (including tests) to perform and which URLs to perform them on, and giving Testaro an object to put its output into.
|
|
12
12
|
|
|
13
|
-
## Origin
|
|
14
|
-
|
|
15
|
-
Work on the custom tests in this package began in 2017, and work on the multi-package federation that Testaro implements began in early 2018. These two aspects were combined into the [Autotest](https://github.com/jrpool/autotest) package in early 2021 and into this more limited-purpose package, Testaro, in January 2022.
|
|
16
|
-
|
|
17
|
-
Testaro omits some functionalities of Autotest, such as:
|
|
18
|
-
- tests producing results intended to be human-inspected
|
|
19
|
-
- previous versions of scoring algorithms
|
|
20
|
-
- file operations for score aggregation, report revision, and HTML reports
|
|
21
|
-
- a web user interface
|
|
22
|
-
|
|
23
13
|
## System requirements
|
|
24
14
|
|
|
25
15
|
Version 14 or later of [Node.js](https://nodejs.org/en/).
|
|
@@ -35,6 +25,7 @@ Testaro includes some of its own accessibility tests. In addition, it performs t
|
|
|
35
25
|
- [alfa](https://alfa.siteimprove.com/) (Siteimprove alfa)
|
|
36
26
|
- [Automated Accessibility Testing Tool](https://www.npmjs.com/package/aatt) (Paypal AATT, running HTML CodeSniffer)
|
|
37
27
|
- [axe-playwright](https://www.npmjs.com/package/axe-playwright) (Deque Axe-core)
|
|
28
|
+
- [Tenon](https://tenon.io/documentation/what-tenon-tests.php)
|
|
38
29
|
- [WAVE API](https://wave.webaim.org/api/) (WebAIM WAVE)
|
|
39
30
|
|
|
40
31
|
As of this version, the counts of tests in the packages referenced above were:
|
|
@@ -42,14 +33,11 @@ As of this version, the counts of tests in the packages referenced above were:
|
|
|
42
33
|
- Alfa: 103
|
|
43
34
|
- Axe-core: 138
|
|
44
35
|
- Equal Access: 163
|
|
36
|
+
- Tenon: 180
|
|
45
37
|
- WAVE: 110
|
|
46
38
|
- subtotal: 612
|
|
47
39
|
- Testaro tests: 16
|
|
48
|
-
- grand total:
|
|
49
|
-
|
|
50
|
-
## Related packages
|
|
51
|
-
|
|
52
|
-
[Testilo](https://github.com/jrpool/testilo) is an application that facilitates the use of Testaro.
|
|
40
|
+
- grand total: 808
|
|
53
41
|
|
|
54
42
|
## Code organization
|
|
55
43
|
|
|
@@ -266,6 +254,24 @@ An example of a **Testaro-defined** test is:
|
|
|
266
254
|
|
|
267
255
|
In this case, Testaro runs the `motion` test with the specified parameters.
|
|
268
256
|
|
|
257
|
+
###### Tenon
|
|
258
|
+
|
|
259
|
+
The `tenon` test requires two commands:
|
|
260
|
+
- A command of type `tenonRequest`.
|
|
261
|
+
- A command of type `test` with `tenon` as the value of `which`.
|
|
262
|
+
|
|
263
|
+
The reason for this is that the Tenon API operates asynchronously. You ask it to perform a test, and it puts your request into a queue. To learn whether Tenon has completed your test, you make a status request. You can continue making status requests until Tenon replies that your test has been completed. Then you submit a request for the test result, and Tenon replies with the result. (As of May 2022, status requests were observed to misreport still-running tests as completed. The `tenon` test works around that.)
|
|
264
|
+
|
|
265
|
+
Tenon says that tests are typically completed in 3 to 6 seconds but that the latency can be longer, depending on demand.
|
|
266
|
+
|
|
267
|
+
Therefore, you can include a `tenonRequest` command early in your script, and a `tenon` test late in your script. Tenon will move your request through its queue while Testaro is processing your script. When Testaro reaches your `tenon` test command, Tenon will most likely have completed your test. If not, the `tenon` test will wait and then make a second request before giving up.
|
|
268
|
+
|
|
269
|
+
Thus, a `tenon` test actually does not perform any test; it merely collects the result. The page that was active when the `tenonRequest` command was performed is the one that Tenon tests.
|
|
270
|
+
|
|
271
|
+
In case you want to perform more than one `tenon` test, you can do so. Just give each pair of commands a distinct `id` property, so each `tenon` test command will request the correct result.
|
|
272
|
+
|
|
273
|
+
Tenon recommends giving it a public URL rather than giving it the content of a page, if possible. So, it is best to give the `withNewContent` property of the `tenonRequest` command the value `true`, unless the page is not public.
|
|
274
|
+
|
|
269
275
|
##### Scoring
|
|
270
276
|
|
|
271
277
|
An example of a **scoring** command is:
|
|
@@ -424,8 +430,14 @@ Another way to run Testaro is to use Testilo, which can handle batches and saves
|
|
|
424
430
|
|
|
425
431
|
If a `wave` test is included in the script, an environment variable named `TESTARO_WAVE_KEY` must exist, with your WAVE API key as its value.
|
|
426
432
|
|
|
433
|
+
If a `tenon` test is included in the script, environment variables named `TESTARO_TENON_USER` and `TESTARO_TENON_PASSWORD` must exist, with your Tenon username and password, respectively, as their values.
|
|
434
|
+
|
|
435
|
+
The `text` command can interpolate the value of an environment variable into text that it enters on a page, as documented in the `commands.js` file.
|
|
436
|
+
|
|
427
437
|
Before executing a Testaro script, you can optionally also set the environment variables `TESTARO_DEBUG` (to `'true'` or anything else) and/or `TESTARO_WAITS` (to a non-negative integer). The effects of these variables are described in the `index.js` file.
|
|
428
438
|
|
|
439
|
+
You may store these environment variables in an untracked `.env` file if you wish, and Testaro will recognize them.
|
|
440
|
+
|
|
429
441
|
## Validation
|
|
430
442
|
|
|
431
443
|
_Executors_ for Testaro validation are located in the `validation` directory.
|
|
@@ -450,18 +462,6 @@ You can define additional Testaro commands and functionality. Contributions are
|
|
|
450
462
|
|
|
451
463
|
The rationales motivating the Testaro-defined tests and scoring procs can be found in comments within the files of those tests and procs, in the `tests` and `procs/score` directories. Unavoidably, each test is opinionated. Testaro itself, however, can accommodate other tests representing different opinions. Testaro is intended to be neutral with respect to questions such as the criteria for accessibility, the severities of accessibility issues, whether accessibility is binary or graded, and the distinction between usability and accessibility.
|
|
452
464
|
|
|
453
|
-
### Future work
|
|
454
|
-
|
|
455
|
-
Further development is contemplated, is taking place, or is welcomed, on:
|
|
456
|
-
- addition of Tenon to the set of packages
|
|
457
|
-
- links with href="#"
|
|
458
|
-
- links and buttons styled non-distinguishably
|
|
459
|
-
- first focused element not first focusable element in DOM
|
|
460
|
-
- never-visible skip links
|
|
461
|
-
- buttons with no text content
|
|
462
|
-
- modal dialogs
|
|
463
|
-
- autocomplete attributes
|
|
464
|
-
|
|
465
465
|
## Testing challenges
|
|
466
466
|
|
|
467
467
|
### Activation
|
|
@@ -478,9 +478,11 @@ Test packages sometimes do redundant testing, in that two or more packages test
|
|
|
478
478
|
|
|
479
479
|
The files in the `temp` directory are presumed ephemeral and are not tracked by `git`. When tests require temporary files to be written, Testaro writes them there.
|
|
480
480
|
|
|
481
|
-
##
|
|
481
|
+
## Related packages
|
|
482
482
|
|
|
483
|
-
|
|
483
|
+
[Testilo](https://www.npmjs.com/package/testilo) is an application that facilitates the use of Testaro.
|
|
484
|
+
|
|
485
|
+
Testaro is derived from [Autotest](https://github.com/jrpool/autotest).
|
|
484
486
|
|
|
485
487
|
Testaro omits some functionalities of Autotest, such as:
|
|
486
488
|
- tests producing results intended to be human-inspected
|
|
@@ -488,6 +490,44 @@ Testaro omits some functionalities of Autotest, such as:
|
|
|
488
490
|
- file operations for score aggregation, report revision, and HTML reports
|
|
489
491
|
- a web user interface
|
|
490
492
|
|
|
493
|
+
## Origin
|
|
494
|
+
|
|
495
|
+
Work on the custom tests in this package began in 2017, and work on the multi-package federation that Testaro implements began in early 2018. These two aspects were combined into the [Autotest](https://github.com/jrpool/autotest) package in early 2021 and into this more single-purpose package, Testaro, in January 2022.
|
|
496
|
+
|
|
491
497
|
## Etymology
|
|
492
498
|
|
|
493
499
|
“Testaro” means “collection of tests” in Esperanto.
|
|
500
|
+
|
|
501
|
+
## Future work
|
|
502
|
+
|
|
503
|
+
### Improvements
|
|
504
|
+
|
|
505
|
+
Further development is contemplated, is taking place, or is welcomed, on:
|
|
506
|
+
- addition of Tenon to the set of packages
|
|
507
|
+
- links with href="#"
|
|
508
|
+
- links and buttons styled non-distinguishably
|
|
509
|
+
- first focused element not first focusable element in DOM
|
|
510
|
+
- never-visible skip links
|
|
511
|
+
- buttons with no text content
|
|
512
|
+
- modal dialogs
|
|
513
|
+
- autocomplete attributes
|
|
514
|
+
- inclusion of other test packages, such as:
|
|
515
|
+
- FAE (https://github.com/opena11y/evaluation-library)
|
|
516
|
+
- Tenon
|
|
517
|
+
|
|
518
|
+
## Corrections
|
|
519
|
+
|
|
520
|
+
Issues found or reported with the current version that need diagnosis and correction include:
|
|
521
|
+
|
|
522
|
+
### hover
|
|
523
|
+
|
|
524
|
+
There seem to be a couple of problems with the hover test:
|
|
525
|
+
- The score for unhoverability is documented as 2 times the count of unhoverables, but is reported as 1 time that count.
|
|
526
|
+
- The list of unhoverables in the report is empty.
|
|
527
|
+
Observed after inquiry by Tobias Christian Jensen of Siteimprove on 2022-05-09.
|
|
528
|
+
|
|
529
|
+
### axe
|
|
530
|
+
|
|
531
|
+
Configuration to include best practices and experimental tests.
|
|
532
|
+
|
|
533
|
+
Investigation of tags, including wcag2a, wcag2aa, wcag21a, wcag21aa, best-practice, wcag***, ACT, cat.*.
|
package/commands.js
CHANGED
|
@@ -118,6 +118,14 @@ exports.commands = {
|
|
|
118
118
|
what: [false, 'string', 'hasLength', 'comment']
|
|
119
119
|
}
|
|
120
120
|
],
|
|
121
|
+
tenonRequest: [
|
|
122
|
+
'Request a Tenon test',
|
|
123
|
+
{
|
|
124
|
+
id: [true, 'string', 'hasLength', 'ID for this test instance'],
|
|
125
|
+
withNewContent: [true, 'boolean', '', 'true: use a URL; false: use page content'],
|
|
126
|
+
what: [false, 'string', 'hasLength', 'comment']
|
|
127
|
+
}
|
|
128
|
+
],
|
|
121
129
|
text: [
|
|
122
130
|
'Enter text into a text input, optionally with 1 placeholder for an all-caps literal environment variable',
|
|
123
131
|
{
|
|
@@ -185,7 +193,7 @@ exports.commands = {
|
|
|
185
193
|
{
|
|
186
194
|
withItems: [true, 'boolean'],
|
|
187
195
|
withNewContent: [
|
|
188
|
-
false, 'boolean', '', 'true: use a URL; false: use page content; omitted:
|
|
196
|
+
false, 'boolean', '', 'true: use a URL; false: use page content; omitted: both'
|
|
189
197
|
]
|
|
190
198
|
}
|
|
191
199
|
],
|
|
@@ -233,6 +241,12 @@ exports.commands = {
|
|
|
233
241
|
withItems: [true, 'boolean']
|
|
234
242
|
}
|
|
235
243
|
],
|
|
244
|
+
tenon: [
|
|
245
|
+
'Perform a Tenon test',
|
|
246
|
+
{
|
|
247
|
+
id: [true, 'string', 'hasLength', 'ID of the requested test instance']
|
|
248
|
+
}
|
|
249
|
+
],
|
|
236
250
|
wave: [
|
|
237
251
|
'Perform a WebAIM WAVE test',
|
|
238
252
|
{
|
package/index.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
testaro main script.
|
|
4
4
|
*/
|
|
5
5
|
// ########## IMPORTS
|
|
6
|
+
// Module to keep secrets.
|
|
7
|
+
require('dotenv').config();
|
|
6
8
|
// Requirements for commands.
|
|
7
9
|
const {commands} = require('./commands');
|
|
8
10
|
// ########## CONSTANTS
|
|
@@ -40,6 +42,7 @@ const tests = {
|
|
|
40
42
|
role: 'roles',
|
|
41
43
|
styleDiff: 'style inconsistencies',
|
|
42
44
|
tabNav: 'keyboard navigation between tab elements',
|
|
45
|
+
tenon: 'Tenon',
|
|
43
46
|
wave: 'WAVE',
|
|
44
47
|
zIndex: 'z indexes'
|
|
45
48
|
};
|
|
@@ -55,6 +58,11 @@ const browserTypeNames = {
|
|
|
55
58
|
};
|
|
56
59
|
// Items that may be waited for.
|
|
57
60
|
const waitables = ['url', 'title', 'body'];
|
|
61
|
+
// Tenon data.
|
|
62
|
+
const tenonData = {
|
|
63
|
+
accessToken: '',
|
|
64
|
+
requestIDs: {}
|
|
65
|
+
};
|
|
58
66
|
// ########## VARIABLES
|
|
59
67
|
// Facts about the current session.
|
|
60
68
|
let logCount = 0;
|
|
@@ -359,7 +367,7 @@ const textOf = async (page, element) => {
|
|
|
359
367
|
const matchElement = async (page, selector, matchText, index = 0) => {
|
|
360
368
|
// If the page still exists:
|
|
361
369
|
if (page) {
|
|
362
|
-
// Wait
|
|
370
|
+
// Wait 2 seconds until the body contains any text to be matched.
|
|
363
371
|
const slimText = debloat(matchText);
|
|
364
372
|
const bodyText = await page.textContent('body');
|
|
365
373
|
const slimBody = debloat(bodyText);
|
|
@@ -706,157 +714,125 @@ const doActs = async (report, actIndex, page) => {
|
|
|
706
714
|
await require('./procs/test/allVis').allVis(page);
|
|
707
715
|
act.result = 'All elements visible.';
|
|
708
716
|
}
|
|
709
|
-
// Otherwise, if
|
|
710
|
-
else if (act.type === '
|
|
711
|
-
const {
|
|
712
|
-
const
|
|
713
|
-
//
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const currentJSHandle = await page.evaluateHandle(actCount => {
|
|
728
|
-
// Initialize it as the focused element.
|
|
729
|
-
let currentElement = document.activeElement;
|
|
730
|
-
// If it exists in the page:
|
|
731
|
-
if (currentElement && currentElement.tagName !== 'BODY') {
|
|
732
|
-
// Change it, if necessary, to its active descendant.
|
|
733
|
-
if (currentElement.hasAttribute('aria-activedescendant')) {
|
|
734
|
-
currentElement = document.getElementById(
|
|
735
|
-
currentElement.getAttribute('aria-activedescendant')
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
// Or change it, if necessary, to its selected option.
|
|
739
|
-
else if (currentElement.tagName === 'SELECT') {
|
|
740
|
-
const currentIndex = Math.max(0, currentElement.selectedIndex);
|
|
741
|
-
const options = currentElement.querySelectorAll('option');
|
|
742
|
-
currentElement = options[currentIndex];
|
|
743
|
-
}
|
|
744
|
-
// Or change it, if necessary, to its active shadow-DOM element.
|
|
745
|
-
else if (currentElement.shadowRoot) {
|
|
746
|
-
currentElement = currentElement.shadowRoot.activeElement;
|
|
747
|
-
}
|
|
748
|
-
// If there is a current element:
|
|
749
|
-
if (currentElement) {
|
|
750
|
-
// If it was already reached within this command performance:
|
|
751
|
-
if (currentElement.dataset.pressesReached === actCount.toString(10)) {
|
|
752
|
-
// Report the error.
|
|
753
|
-
console.log(`ERROR: ${currentElement.tagName} element reached again`);
|
|
754
|
-
status = 'ERROR';
|
|
755
|
-
return 'ERROR: locallyExhausted';
|
|
756
|
-
}
|
|
757
|
-
// Otherwise, i.e. if it is newly reached within this act:
|
|
758
|
-
else {
|
|
759
|
-
// Mark and return it.
|
|
760
|
-
currentElement.dataset.pressesReached = actCount;
|
|
761
|
-
return currentElement;
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
// Otherwise, i.e. if there is no current element:
|
|
765
|
-
else {
|
|
766
|
-
// Report the error.
|
|
767
|
-
status = 'ERROR';
|
|
768
|
-
return 'noActiveElement';
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
// Otherwise, i.e. if there is no focus in the page:
|
|
772
|
-
else {
|
|
773
|
-
// Report the error.
|
|
774
|
-
status = 'ERROR';
|
|
775
|
-
return 'ERROR: globallyExhausted';
|
|
776
|
-
}
|
|
777
|
-
}, actCount);
|
|
778
|
-
// If the current element exists:
|
|
779
|
-
const currentElement = currentJSHandle.asElement();
|
|
780
|
-
if (currentElement) {
|
|
781
|
-
// Update the data.
|
|
782
|
-
const tagNameJSHandle = await currentElement.getProperty('tagName');
|
|
783
|
-
const tagName = await tagNameJSHandle.jsonValue();
|
|
784
|
-
const text = await textOf(page, currentElement);
|
|
785
|
-
// If the text of the current element was found:
|
|
786
|
-
if (text !== null) {
|
|
787
|
-
const textLength = text.length;
|
|
788
|
-
// If it is non-empty and there are texts to match:
|
|
789
|
-
if (matchTexts.length && textLength) {
|
|
790
|
-
// Identify the matching text.
|
|
791
|
-
matchedText = matchTexts.find(matchText => text.includes(matchText));
|
|
792
|
-
}
|
|
793
|
-
// Update the item data if required.
|
|
794
|
-
if (withItems) {
|
|
795
|
-
const itemData = {
|
|
796
|
-
tagName,
|
|
797
|
-
text,
|
|
798
|
-
textLength
|
|
799
|
-
};
|
|
800
|
-
if (matchedText) {
|
|
801
|
-
itemData.matchedText = matchedText;
|
|
717
|
+
// Otherwise, if the act is a tenon request:
|
|
718
|
+
else if (act.type === 'tenonRequest') {
|
|
719
|
+
const {id, withNewContent} = act;
|
|
720
|
+
const https = require('https');
|
|
721
|
+
// If a Tenon access token has not yet been obtained:
|
|
722
|
+
if (! tenonData.accessToken) {
|
|
723
|
+
// Authenticate with the Tenon API.
|
|
724
|
+
const authData = await new Promise(resolve => {
|
|
725
|
+
const request = https.request(
|
|
726
|
+
{
|
|
727
|
+
host: 'tenon.io',
|
|
728
|
+
path: '/api/v2/auth',
|
|
729
|
+
port: 443,
|
|
730
|
+
protocol: 'https:',
|
|
731
|
+
method: 'POST',
|
|
732
|
+
headers: {
|
|
733
|
+
'Content-Type': 'application/json',
|
|
734
|
+
'Cache-Control': 'no-cache'
|
|
802
735
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
const inputText = act.text;
|
|
814
|
-
if (inputText) {
|
|
815
|
-
await page.keyboard.type(inputText);
|
|
816
|
-
presses += inputText.length;
|
|
736
|
+
},
|
|
737
|
+
response => {
|
|
738
|
+
let responseData = '';
|
|
739
|
+
response.on('data', chunk => {
|
|
740
|
+
responseData += chunk;
|
|
741
|
+
});
|
|
742
|
+
response.on('end', () => {
|
|
743
|
+
try {
|
|
744
|
+
const responseJSON = JSON.parse(responseData);
|
|
745
|
+
return resolve(responseJSON);
|
|
817
746
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
747
|
+
catch(error) {
|
|
748
|
+
return resolve({
|
|
749
|
+
error: 'Tenon did not return JSON authentication data.',
|
|
750
|
+
responseData
|
|
751
|
+
});
|
|
822
752
|
}
|
|
823
|
-
}
|
|
753
|
+
});
|
|
824
754
|
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
755
|
+
);
|
|
756
|
+
const tenonUser = process.env.TESTARO_TENON_USER;
|
|
757
|
+
const tenonPassword = process.env.TESTARO_TENON_PASSWORD;
|
|
758
|
+
const postData = JSON.stringify({
|
|
759
|
+
username: tenonUser,
|
|
760
|
+
password: tenonPassword
|
|
761
|
+
});
|
|
762
|
+
request.write(postData);
|
|
763
|
+
request.end();
|
|
764
|
+
});
|
|
765
|
+
// If the authentication succeeded:
|
|
766
|
+
if (authData.access_token) {
|
|
767
|
+
// Record the access token.
|
|
768
|
+
tenonData.accessToken = authData.access_token;
|
|
829
769
|
}
|
|
830
|
-
// Otherwise, i.e. if
|
|
770
|
+
// Otherwise, i.e. if the authentication failed:
|
|
831
771
|
else {
|
|
832
|
-
|
|
833
|
-
status = await currentJSHandle.jsonValue();
|
|
772
|
+
console.log('ERROR: tenon authentication failed');
|
|
834
773
|
}
|
|
835
774
|
}
|
|
836
|
-
//
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
775
|
+
// If a Tenon access token exists:
|
|
776
|
+
if (tenonData.accessToken) {
|
|
777
|
+
// Request a Tenon test of the page and get a response ID.
|
|
778
|
+
const option = {};
|
|
779
|
+
// If Tenon is to be given the URL and not the content of the page:
|
|
780
|
+
if (withNewContent) {
|
|
781
|
+
// Specify this.
|
|
782
|
+
option.url = page.url();
|
|
842
783
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
784
|
+
// Otherwise, i.e. if Tenon is to be given the page content:
|
|
785
|
+
else {
|
|
786
|
+
// Specify this.
|
|
787
|
+
option.src = await page.content();
|
|
788
|
+
}
|
|
789
|
+
// Request a Tenon test and get a response ID.
|
|
790
|
+
const responseID = await new Promise(resolve => {
|
|
791
|
+
const request = https.request(
|
|
792
|
+
{
|
|
793
|
+
host: 'tenon.io',
|
|
794
|
+
path: '/api/v2/',
|
|
795
|
+
port: 443,
|
|
796
|
+
protocol: 'https:',
|
|
797
|
+
method: 'POST',
|
|
798
|
+
headers: {
|
|
799
|
+
'Content-Type': 'application/json',
|
|
800
|
+
'Cache-Control': 'no-cache',
|
|
801
|
+
Authorization: `Bearer ${tenonData.accessToken}`
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
response => {
|
|
805
|
+
let resultJSON = '';
|
|
806
|
+
response.on('data', chunk => {
|
|
807
|
+
resultJSON += chunk;
|
|
808
|
+
});
|
|
809
|
+
// When the data arrive, return them as an object.
|
|
810
|
+
response.on('end', () => {
|
|
811
|
+
try {
|
|
812
|
+
const result = JSON.parse(resultJSON);
|
|
813
|
+
resolve(result.data.responseID || '');
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
console.log('ERROR: Tenon did not return JSON.');
|
|
817
|
+
resolve('');
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
);
|
|
822
|
+
const postData = JSON.stringify(option);
|
|
823
|
+
request.write(postData);
|
|
824
|
+
request.end();
|
|
825
|
+
});
|
|
826
|
+
// Record the response ID.
|
|
827
|
+
tenonData.requestIDs[id] = responseID || '';
|
|
849
828
|
}
|
|
850
|
-
// Add the totals to the report.
|
|
851
|
-
report.presses += presses;
|
|
852
|
-
report.amountRead += amountRead;
|
|
853
829
|
}
|
|
854
830
|
// Otherwise, if the act is a test:
|
|
855
831
|
else if (act.type === 'test') {
|
|
856
832
|
// Add a description of the test to the act.
|
|
857
833
|
act.what = tests[act.which];
|
|
858
834
|
// Initialize the arguments.
|
|
859
|
-
const args = [page];
|
|
835
|
+
const args = [act.which === 'tenon' ? tenonData : page];
|
|
860
836
|
// Identify the additional validator of the test.
|
|
861
837
|
const testValidator = commands.tests[act.which];
|
|
862
838
|
// If it exists:
|
|
@@ -1025,6 +1001,151 @@ const doActs = async (report, actIndex, page) => {
|
|
|
1025
1001
|
const qualifier = act.again ? `${1 + act.again} times` : 'once';
|
|
1026
1002
|
act.result = `pressed ${qualifier}`;
|
|
1027
1003
|
}
|
|
1004
|
+
// Otherwise, if it is a repetitive keyboard navigation:
|
|
1005
|
+
else if (act.type === 'presses') {
|
|
1006
|
+
const {navKey, what, which, withItems} = act;
|
|
1007
|
+
const matchTexts = which ? which.map(text => debloat(text)) : [];
|
|
1008
|
+
// Initialize the loop variables.
|
|
1009
|
+
let status = 'more';
|
|
1010
|
+
let presses = 0;
|
|
1011
|
+
let amountRead = 0;
|
|
1012
|
+
let items = [];
|
|
1013
|
+
let matchedText;
|
|
1014
|
+
// As long as a matching element has not been reached:
|
|
1015
|
+
while (status === 'more') {
|
|
1016
|
+
// Press the Escape key to dismiss any modal dialog.
|
|
1017
|
+
await page.keyboard.press('Escape');
|
|
1018
|
+
// Press the specified navigation key.
|
|
1019
|
+
await page.keyboard.press(navKey);
|
|
1020
|
+
presses++;
|
|
1021
|
+
// Identify the newly current element or a failure.
|
|
1022
|
+
const currentJSHandle = await page.evaluateHandle(actCount => {
|
|
1023
|
+
// Initialize it as the focused element.
|
|
1024
|
+
let currentElement = document.activeElement;
|
|
1025
|
+
// If it exists in the page:
|
|
1026
|
+
if (currentElement && currentElement.tagName !== 'BODY') {
|
|
1027
|
+
// Change it, if necessary, to its active descendant.
|
|
1028
|
+
if (currentElement.hasAttribute('aria-activedescendant')) {
|
|
1029
|
+
currentElement = document.getElementById(
|
|
1030
|
+
currentElement.getAttribute('aria-activedescendant')
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
// Or change it, if necessary, to its selected option.
|
|
1034
|
+
else if (currentElement.tagName === 'SELECT') {
|
|
1035
|
+
const currentIndex = Math.max(0, currentElement.selectedIndex);
|
|
1036
|
+
const options = currentElement.querySelectorAll('option');
|
|
1037
|
+
currentElement = options[currentIndex];
|
|
1038
|
+
}
|
|
1039
|
+
// Or change it, if necessary, to its active shadow-DOM element.
|
|
1040
|
+
else if (currentElement.shadowRoot) {
|
|
1041
|
+
currentElement = currentElement.shadowRoot.activeElement;
|
|
1042
|
+
}
|
|
1043
|
+
// If there is a current element:
|
|
1044
|
+
if (currentElement) {
|
|
1045
|
+
// If it was already reached within this command performance:
|
|
1046
|
+
if (currentElement.dataset.pressesReached === actCount.toString(10)) {
|
|
1047
|
+
// Report the error.
|
|
1048
|
+
console.log(`ERROR: ${currentElement.tagName} element reached again`);
|
|
1049
|
+
status = 'ERROR';
|
|
1050
|
+
return 'ERROR: locallyExhausted';
|
|
1051
|
+
}
|
|
1052
|
+
// Otherwise, i.e. if it is newly reached within this act:
|
|
1053
|
+
else {
|
|
1054
|
+
// Mark and return it.
|
|
1055
|
+
currentElement.dataset.pressesReached = actCount;
|
|
1056
|
+
return currentElement;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
// Otherwise, i.e. if there is no current element:
|
|
1060
|
+
else {
|
|
1061
|
+
// Report the error.
|
|
1062
|
+
status = 'ERROR';
|
|
1063
|
+
return 'noActiveElement';
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
// Otherwise, i.e. if there is no focus in the page:
|
|
1067
|
+
else {
|
|
1068
|
+
// Report the error.
|
|
1069
|
+
status = 'ERROR';
|
|
1070
|
+
return 'ERROR: globallyExhausted';
|
|
1071
|
+
}
|
|
1072
|
+
}, actCount);
|
|
1073
|
+
// If the current element exists:
|
|
1074
|
+
const currentElement = currentJSHandle.asElement();
|
|
1075
|
+
if (currentElement) {
|
|
1076
|
+
// Update the data.
|
|
1077
|
+
const tagNameJSHandle = await currentElement.getProperty('tagName');
|
|
1078
|
+
const tagName = await tagNameJSHandle.jsonValue();
|
|
1079
|
+
const text = await textOf(page, currentElement);
|
|
1080
|
+
// If the text of the current element was found:
|
|
1081
|
+
if (text !== null) {
|
|
1082
|
+
const textLength = text.length;
|
|
1083
|
+
// If it is non-empty and there are texts to match:
|
|
1084
|
+
if (matchTexts.length && textLength) {
|
|
1085
|
+
// Identify the matching text.
|
|
1086
|
+
matchedText = matchTexts.find(matchText => text.includes(matchText));
|
|
1087
|
+
}
|
|
1088
|
+
// Update the item data if required.
|
|
1089
|
+
if (withItems) {
|
|
1090
|
+
const itemData = {
|
|
1091
|
+
tagName,
|
|
1092
|
+
text,
|
|
1093
|
+
textLength
|
|
1094
|
+
};
|
|
1095
|
+
if (matchedText) {
|
|
1096
|
+
itemData.matchedText = matchedText;
|
|
1097
|
+
}
|
|
1098
|
+
items.push(itemData);
|
|
1099
|
+
}
|
|
1100
|
+
amountRead += textLength;
|
|
1101
|
+
// If there is no text-match failure:
|
|
1102
|
+
if (matchedText || ! matchTexts.length) {
|
|
1103
|
+
// If the element has any specified tag name:
|
|
1104
|
+
if (! what || tagName === what) {
|
|
1105
|
+
// Change the status.
|
|
1106
|
+
status = 'done';
|
|
1107
|
+
// Perform the action.
|
|
1108
|
+
const inputText = act.text;
|
|
1109
|
+
if (inputText) {
|
|
1110
|
+
await page.keyboard.type(inputText);
|
|
1111
|
+
presses += inputText.length;
|
|
1112
|
+
}
|
|
1113
|
+
if (act.action) {
|
|
1114
|
+
presses++;
|
|
1115
|
+
await page.keyboard.press(act.action);
|
|
1116
|
+
await page.waitForLoadState();
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
status = 'ERROR';
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
// Otherwise, i.e. if there was a failure:
|
|
1126
|
+
else {
|
|
1127
|
+
// Update the status.
|
|
1128
|
+
status = await currentJSHandle.jsonValue();
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// Add the result to the act.
|
|
1132
|
+
act.result = {
|
|
1133
|
+
status,
|
|
1134
|
+
totals: {
|
|
1135
|
+
presses,
|
|
1136
|
+
amountRead
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
if (status === 'done' && matchedText) {
|
|
1140
|
+
act.result.matchedText = matchedText;
|
|
1141
|
+
}
|
|
1142
|
+
if (withItems) {
|
|
1143
|
+
act.result.items = items;
|
|
1144
|
+
}
|
|
1145
|
+
// Add the totals to the report.
|
|
1146
|
+
report.presses += presses;
|
|
1147
|
+
report.amountRead += amountRead;
|
|
1148
|
+
}
|
|
1028
1149
|
// Otherwise, i.e. if the act type is unknown:
|
|
1029
1150
|
else {
|
|
1030
1151
|
// Add the error result to the act.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testaro",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "Automation of accessibility testing",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"aatt": "*",
|
|
31
31
|
"accessibility-checker": "*",
|
|
32
32
|
"axe-playwright": "*",
|
|
33
|
+
"dotenv": "*",
|
|
33
34
|
"pixelmatch": "*",
|
|
34
35
|
"playwright": "*"
|
|
35
36
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"what": "Test example.com with
|
|
2
|
+
"what": "Test example.com with bulk",
|
|
3
3
|
"strict": true,
|
|
4
4
|
"commands": [
|
|
5
5
|
{
|
|
@@ -14,8 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"type": "test",
|
|
17
|
-
"which": "
|
|
18
|
-
"what": "Siteimprove alfa package"
|
|
17
|
+
"which": "bulk"
|
|
19
18
|
}
|
|
20
19
|
]
|
|
21
20
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"what": "Test Wikipedia with tenon",
|
|
3
|
+
"strict": true,
|
|
4
|
+
"commands": [
|
|
5
|
+
{
|
|
6
|
+
"type": "launch",
|
|
7
|
+
"which": "chromium",
|
|
8
|
+
"what": "Chromium browser"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"type": "url",
|
|
12
|
+
"which": "https://en.wikipedia.org/wiki/Main_Page",
|
|
13
|
+
"what": "Wikipedia English home page"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"type": "tenonRequest",
|
|
17
|
+
"withNewContent": true,
|
|
18
|
+
"id": "a",
|
|
19
|
+
"what": "tenon request"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"type": "test",
|
|
23
|
+
"which": "tenon",
|
|
24
|
+
"id": "a",
|
|
25
|
+
"what": "result of prior tenon request"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
package/tests/ibm.js
CHANGED
|
@@ -50,28 +50,54 @@ const report = (result, withItems) => {
|
|
|
50
50
|
}
|
|
51
51
|
return data;
|
|
52
52
|
};
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// Performs an IBM test.
|
|
54
|
+
const doTest = async (content, withItems, timeLimit) => {
|
|
55
|
+
// Start a timeout clock.
|
|
56
|
+
let timeoutID;
|
|
57
|
+
const wait = new Promise(resolve => {
|
|
58
|
+
timeoutID = setTimeout(() => {
|
|
59
|
+
resolve('');
|
|
60
|
+
}, 1000 * timeLimit);
|
|
61
|
+
});
|
|
62
|
+
// Conduct and report the test.
|
|
63
|
+
const result = run(content);
|
|
64
|
+
// Wait for the report until the time limit expires.
|
|
65
|
+
const resultIfFast = await Promise.race([result, wait]);
|
|
66
|
+
// Delete the report files.
|
|
67
|
+
try {
|
|
68
|
+
const reportNames = await fs.readdir('results');
|
|
69
|
+
for (const reportName of reportNames) {
|
|
70
|
+
await fs.rm(`results/${reportName}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch(error) {
|
|
74
|
+
console.log('ibm test created no result files.');
|
|
75
|
+
}
|
|
76
|
+
// Return the result.
|
|
77
|
+
if (resultIfFast) {
|
|
78
|
+
clearTimeout(timeoutID);
|
|
79
|
+
const typeResult = report(result, withItems);
|
|
80
|
+
return typeResult;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log('ERROR: getting ibm test report took too long');
|
|
84
|
+
return 'ERROR: getting ibm test report took too long';
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
// Returns results of one or two IBM tests.
|
|
55
88
|
exports.reporter = async (page, withItems, withNewContent) => {
|
|
56
|
-
// If
|
|
89
|
+
// If a test with existing content is to be performed:
|
|
90
|
+
const result = {};
|
|
57
91
|
if (! withNewContent) {
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
all.content = report(result, withItems);
|
|
92
|
+
const timeLimit = 15;
|
|
93
|
+
const typeContent = await page.content();
|
|
94
|
+
result.content = await doTest(typeContent, withItems, timeLimit);
|
|
62
95
|
}
|
|
63
|
-
// If
|
|
96
|
+
// If a test with new content is to be performed:
|
|
64
97
|
if ([true, undefined].includes(withNewContent)) {
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
all.url = report(result, withItems);
|
|
98
|
+
const timeLimit = 20;
|
|
99
|
+
const typeContent = page.url();
|
|
100
|
+
result.url = await doTest(typeContent, withItems, timeLimit);
|
|
69
101
|
}
|
|
70
|
-
|
|
71
|
-
const reportNames = await fs.readdir('results');
|
|
72
|
-
for (const reportName of reportNames) {
|
|
73
|
-
await fs.rm(`results/${reportName}`);
|
|
74
|
-
}
|
|
75
|
-
// Return the result.
|
|
76
|
-
return {result: all};
|
|
102
|
+
return {result};
|
|
77
103
|
};
|
package/tests/tenon.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/*
|
|
2
|
+
tenon
|
|
3
|
+
This test processes a previously requested test by the Tenon API.
|
|
4
|
+
*/
|
|
5
|
+
const https = require('https');
|
|
6
|
+
// Wait until a time limit in seconds expires.
|
|
7
|
+
const wait = timeLimit => new Promise(resolve => setTimeout(resolve, 1000 * timeLimit));
|
|
8
|
+
exports.reporter = async (tenonData, id) => {
|
|
9
|
+
if (tenonData && tenonData.accessToken && tenonData.requestIDs && tenonData.requestIDs[id]) {
|
|
10
|
+
// Shared request options.
|
|
11
|
+
const requestOptions = {
|
|
12
|
+
host: 'tenon.io',
|
|
13
|
+
path: `/api/v2/${tenonData.requestIDs[id]}`,
|
|
14
|
+
port: 443,
|
|
15
|
+
protocol: 'https:',
|
|
16
|
+
headers: {
|
|
17
|
+
Authorization: `Bearer ${tenonData.accessToken}`,
|
|
18
|
+
'Cache-Control': 'no-cache'
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
// Gets the test status.
|
|
22
|
+
const getStatus = async () => {
|
|
23
|
+
const testStatus = await new Promise((resolve, reject) => {
|
|
24
|
+
requestOptions.method = 'HEAD';
|
|
25
|
+
const statusRequest = https.request(requestOptions, statusResponse => {
|
|
26
|
+
const {statusCode} = statusResponse;
|
|
27
|
+
resolve(statusCode);
|
|
28
|
+
});
|
|
29
|
+
statusRequest.on('error', error => {
|
|
30
|
+
console.log(`ERROR getting Tenon test status (${error.message})`);
|
|
31
|
+
reject(`ERROR getting Tenon test status (${error.message})`);
|
|
32
|
+
});
|
|
33
|
+
statusRequest.end();
|
|
34
|
+
});
|
|
35
|
+
return testStatus;
|
|
36
|
+
};
|
|
37
|
+
// Gets the test result.
|
|
38
|
+
const getResult = async () => {
|
|
39
|
+
const testResult = await new Promise(resolve => {
|
|
40
|
+
requestOptions.method = 'GET';
|
|
41
|
+
const resultRequest = https.request(requestOptions, resultResponse => {
|
|
42
|
+
let resultJSON = '';
|
|
43
|
+
resultResponse.on('data', chunk => {
|
|
44
|
+
resultJSON += chunk;
|
|
45
|
+
});
|
|
46
|
+
resultResponse.on('end', () => {
|
|
47
|
+
try {
|
|
48
|
+
const result = JSON.parse(resultJSON);
|
|
49
|
+
resolve(result);
|
|
50
|
+
}
|
|
51
|
+
catch(error) {
|
|
52
|
+
console.log(`ERROR getting Tenon test result (${resultJSON.slice(0, 80)} …)`);
|
|
53
|
+
resolve({
|
|
54
|
+
error: 'ERROR getting Tenon test result',
|
|
55
|
+
resultStart: resultJSON.slice(0, 80)
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
resultRequest.end();
|
|
61
|
+
});
|
|
62
|
+
return testResult;
|
|
63
|
+
};
|
|
64
|
+
// Get the test status (not reliable: may say 200 instead of 202).
|
|
65
|
+
let testStatus = await getStatus();
|
|
66
|
+
// If the test is still in the Tenon queue:
|
|
67
|
+
if (testStatus === 202) {
|
|
68
|
+
// Wait 5 seconds.
|
|
69
|
+
await wait(5);
|
|
70
|
+
// Get the test status again.
|
|
71
|
+
testStatus = await getStatus();
|
|
72
|
+
}
|
|
73
|
+
// If the test has allegedly been completed:
|
|
74
|
+
if (testStatus === 200) {
|
|
75
|
+
// Get the test result.
|
|
76
|
+
let testResult = await getResult();
|
|
77
|
+
// If the test is still in the Tenon queue:
|
|
78
|
+
let {status} = testResult;
|
|
79
|
+
if (status === 202) {
|
|
80
|
+
// Wait 5 seconds.
|
|
81
|
+
await wait(5);
|
|
82
|
+
// Get the test result again.
|
|
83
|
+
testResult = await getResult();
|
|
84
|
+
// If the test is still in the Tenon queue:
|
|
85
|
+
status = testResult.status;
|
|
86
|
+
if (status === 202) {
|
|
87
|
+
// Wait 15 more seconds.
|
|
88
|
+
await wait(15);
|
|
89
|
+
// Get the test result again.
|
|
90
|
+
testResult = await getResult();
|
|
91
|
+
status = testResult.status;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// If the test has really been completed:
|
|
95
|
+
if (status === 200) {
|
|
96
|
+
// Return its result.
|
|
97
|
+
return {result: testResult};
|
|
98
|
+
}
|
|
99
|
+
// Otherwise, i.e. if the test is still running or failed:
|
|
100
|
+
else {
|
|
101
|
+
return {result: {
|
|
102
|
+
error: 'ERROR: Tenon result not retrieved',
|
|
103
|
+
status
|
|
104
|
+
}};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Otherwise, if the test is still running after a wait for its status:
|
|
108
|
+
else {
|
|
109
|
+
// Report the test status.
|
|
110
|
+
return {result: {
|
|
111
|
+
error: 'ERROR: test status not completed',
|
|
112
|
+
testStatus
|
|
113
|
+
}};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
return {
|
|
118
|
+
result: {
|
|
119
|
+
error: 'ERROR: tenon authorization and test data incomplete'
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// tenon.js
|
|
2
|
+
// Test executor for tenon sample script.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const {handleRequest} = require('../../index');
|
|
6
|
+
const scriptJSON = fs.readFileSync('samples/scripts/tenon.json', 'utf8');
|
|
7
|
+
const script = JSON.parse(scriptJSON);
|
|
8
|
+
const report = {
|
|
9
|
+
id: '',
|
|
10
|
+
script,
|
|
11
|
+
log: [],
|
|
12
|
+
acts: []
|
|
13
|
+
};
|
|
14
|
+
(async () => {
|
|
15
|
+
await handleRequest(report);
|
|
16
|
+
console.log(`Report log:\n${JSON.stringify(report.log, null, 2)}\n`);
|
|
17
|
+
console.log(`Report acts:\n${JSON.stringify(report.acts, null, 2)}`);
|
|
18
|
+
})();
|