-
Notifications
You must be signed in to change notification settings - Fork 4
/
steps.js
1246 lines (1035 loc) · 49.9 KB
/
steps.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
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const {
When,
Then,
And,
Given,
After,
Before,
BeforeAll,
AfterAll,
setDefaultTimeout //,
// wrapPromiseWithTimeout // cucumber v8
} = require('@cucumber/cucumber');
const wrapPromiseWithTimeout = require('./cucumber_8_shim');
const fs = require('fs');
const path = require('path');
const { expect } = require('chai');
const { v4: uuidv4 } = require('uuid');
// Ours
const scope = require('./scope');
const log = require('./utils/log');
const session_vars = require('./utils/session_vars');
const files = require('./utils/files' );
/* Of Note:
- We're using `*=` for selectors because sometimes da text has funny characters in it that are hard to anticipate
- Can't use elem[ scope.activate ](). It works locally, but errors on GitHub
with "Node is not visible or not an HTMLElement". We're not using a custom Chromium version
Unfortunately, this won't catch an invisible element. Hopefully we'd catch it before click
attempts are made. "Tap" on mobile is more complex to implement ourselves.
See https://stackoverflow.com/a/56547605/14144258
and other convos around it. We haven't made it public that the device
can customized. Until we do that, we'll just use "click" all the time.
*/
require("dotenv").config(); // Where did this go?
/* TODO:
1. 'choice' to be any kind of choice - radio, checkbox,
dropdown, etc. Have to research the different DOM for each
1. 'choice' to have a more specific way to access each item. For
example, for a list collect or other things that have multiple
things with the same text on the page.
1. Figure out how to test allowing flexibility for coder. For example:
there is placeholder text for the title of the form and if it's not
defined, placeholder text should appear (though that behavior may
bear discussion).
1. Add links to resources on:
1. taps that trigger navigation need Promise.all: https://stackoverflow.com/a/60789449/14144258
> I ran into a scenario, where there was the classic POST-303-GET
and an input[type=submit] was involved. It seems that in this case,
the tap of the button won't resolve until after the associated form's
submission and redirection, so the solution was to remove the waitForNavigation,
because it was executed after the redirection and thus was timing out.
I think Promise.all is what's taking care of these situations.
1. Listening for `targetchanged` or changed URL
1. Listening for responses
Should post example of detecting new page/no new page on submit when there
is a DOM change that you can detect. I suspect no request is
being sent, but I could be wrong. Haven't yet figured out how
to detect that.
regex thoughts: https://stackoverflow.com/questions/171480/regex-grabbing-values-between-quotation-marks
*/
// Hoping that most load and download times will be under 30 seconds.
// TODO: Check what average load and download times are in our current interviews.
let default_timeout = 30 * 1000
setDefaultTimeout( default_timeout );
let click_with = {
mobile: 'tap',
pc: 'click',
};
BeforeAll(async () => {
scope.showif_timeout = 600;
scope.report = new Map();
// Start tracking whether server is up and running
scope.track_server_status( scope );
// Store path names for files and docs created for the tests
scope.paths = {};
// TODO: figure out how to test file and folder creation stuff,
// especially when setup is excluded from the flow.
// Make the folder to store all the files for this run of the tests
scope.paths.artifacts = files.make_artifacts_folder();
// Save its name in a file. `session_vars` can get it back later.
// The value we are saving is used by at least run_cucumber.js.
session_vars.save_artifacts_path_name( scope.paths.artifacts );
scope.paths.report = `${ scope.paths.artifacts }/report.txt`;
scope.paths.debug_log = `${ scope.paths.artifacts }/${ log.debug_log_file }`;
fs.writeFileSync( scope.paths.debug_log, "" );
});
Before(async (scenario) => {
// Create browser, which can't be created in BeforeAll() where .driver doesn't exist for some reason
if (!scope.browser) {
if (session_vars.get_origin() === 'playground') {
// Will only run in the Playground outside of a sandbox. TODO: There's a
// better way to do this, though it's more complicated. See comments in
// https://github.com/SuffolkLITLab/ALKiln/issues/661
scope.browser = await scope.driver.launch({ args: ['--no-sandbox'] });
} else {
scope.browser = await scope.driver.launch({ headless: !session_vars.get_debug(), devtools: session_vars.get_debug() });
}
}
// Clean up all previously existing pages
for (const page of await scope.browser.pages()) {
await page.close();
}
// Make a new page
scope.page = await scope.browser.newPage()
scope.scenario_id = uuidv4();
scope.expected_status = null;
scope.expected_in_report = null;
scope.expected_not_in_report = null;
scope.server_reload_promise = null;
await scope.addReportHeading(scope, {scenario});
// Make folder for this Scenario in the all-tests artifacts folder
scope.base_filename = await scope.getSafeScenarioBaseFilename(scope, {scenario});
// Add a date for uniqueness in case dev has accidentally copied a Scenario description
let date = files.readable_date();
scope.paths.scenario = `${ scope.paths.artifacts }/${ scope.base_filename }-${ date }`;
fs.mkdirSync( scope.paths.scenario );
// Store path name for this Scenario's individualized report
scope.paths.scenario_report = `${ scope.paths.scenario }/report.txt`;
// Downloads
scope.downloadComplete = false;
scope.toDownload = '';
// Device interaction
scope.device = 'pc';
scope.activate = click_with[ scope.device ];
scope.disable_error_screenshot = false;
scope.page_id = null;
scope.check_all_for_a11y = false;
scope.passed_all_for_a11y = true;
// Reset default timeout
scope.timeout = default_timeout;
});
// Add a check for an error page before each step? After each step?
//#####################################
//#####################################
// Establishing
//#####################################
//#####################################
Given(/I start the interview at "([^"]+)"(?: in lang "([^"]+)")?/, {timeout: -1}, async (file_name, language) => { // √
// Load the right interview page
await scope.load( scope, file_name );
// The page timeout is set in here too in case someone set a custom timeout
// before a page was loaded.
// The function itself won't really timeout here, so we don't need to wrap it in
// a custom timeout
await scope.page.setDefaultTimeout( scope.timeout ); // overrides outer default timeout
// Allow developer to save download files
let custom_download_behavior_timeout = new Promise(async ( resolve, reject ) => {
let page_timeout = setTimeout(function() {reject( `Took too long to set download behavior.` ); }, scope.timeout);
// self-invoke an anonymous async function so we don't have to
// abstract the details of resolving and rejecting.
(async function() {
// I've seen stuff take an extra moment or two. Shame to have it everywhere
try {
// Interview files should get downloaded to downloads folder
await scope.page._client.send('Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath: path.resolve( scope.paths.scenario ), // Save in root of this scenario
});
resolve();
} catch ( err ) { reject( err ); }
// prevent temporary hang at end of tests
finally { clearTimeout( page_timeout ); }
})();
}); // ends custom_download_behavior_timeout
await custom_download_behavior_timeout;
// ---- EVENT LISTENERS ----
// TODO: Wait for download to complete doesn't work. See notes in
// scope.js scope.detectDownloadComplete()
// ~Wait for possible download. From https://stackoverflow.com/a/51423688~
scope.page.on('response', async response => {
// Note: We can expect that only one file will be getting downloaded
// at any one time since each step completes synchronously
// If response has a file in it that matches the file we are downloading
if ( response._headers['content-disposition'] && (response._headers['content-disposition']).includes(`attachment;filename=${scope.toDownload}`) ) {
// We're choosing to avoid removing the event listener in case removal
// would prevent downloading other files. The listener should get
// destroyed when the page closes anyway.
// Watch event on download folder or file
await fs.watchFile( scope.paths.scenario, function (curr, prev) {
// If current size eq to size from response then close
if (parseInt(curr.size) === parseInt(response._headers['content-length'])) {
// Lets the related function in `scope` know that the download
// is done so that the next step can start.
scope.downloadComplete = true;
this.close();
return true;
}
}); // ends fs.watchFile
return false;
} // ends if this is the file the developer expected
}); // ends page.on 'response'
// TODO: Move language into a different step
scope.language = language;
let custom_lang_timeout = new Promise(async ( resolve, reject ) => {
let page_timeout = setTimeout(function() { reject( `It took to long to try to set the language "${ language }".` ); }, scope.timeout);
// self-invoke an anonymous async function so we don't have to
// abstract the details of resolving and rejecting.
(async function() {
try {
await scope.setLanguage( scope, { language });
resolve();
} catch ( err ) { reject( err ); }
// prevent temporary hang at end of tests
finally { clearTimeout( page_timeout ); }
})();
}); // ends custom_lang_timeout
await custom_lang_timeout;
let custom_afterStep_timeout = new Promise(async ( resolve, reject ) => {
let page_timeout = setTimeout(function() { reject(`\`afterStep()\` took too long.`); }, scope.timeout);
// self-invoke an anonymous async function so we don't have to
// abstract the details of resolving and rejecting.
(async function() {
// I've seen stuff take an extra moment or two. Shame to have it everywhere
try { await scope.afterStep(scope, {waitForTimeout: 200}); resolve(); }
catch ( err ) { reject( err ); }
// prevent temporary hang at end of tests
finally { clearTimeout( page_timeout ); }
})();
}); // ends custom_afterStep_timeout
await custom_afterStep_timeout;
});
Given(/I (?:sign|log) ?(?:in)?(?:on)?(?:to the server)? with(?: the email)? "([^"]+)" and(?: the password)? "([^"]+)"(?: SECRETs)?/i, {timeout: -1}, async ( email, password ) => {
/** Uses the names of environment variables (most often GitHub SECRETs) to
* log into an account on the user's server. Must be SECRETs to stay secure.
*
* Variations:
* I sign into the server with the email "USER1_EMAIL" and the password "USER1_PASSWORD" SECRETs
* I sign on with the email "USER1_EMAIL" and the password "USER1_PASSWORD" SECRETs
* I log in with "USER1_EMAIL" and "USER1_PASSWORD"
*/
// Exiting the custom timeout happens in scope.tapElement() if needed
// Couldn't test the timeout for tapping the button because there's not
// enough of a pause between initial navigation and pressing button.
await scope.steps.sign_in( scope, {
email_secret_name: email,
password_secret_name: password
});
});
// I am using a mobile/pc
When(/I am using an? (.*)/, async ( device ) => {
// Let developer pick mobile device if they want to
// TODO: Add tablet?
if ( device ) {
if ( device.includes( 'mobile' ) || device.includes( 'phone' ) ) {
await scope.page.setUserAgent("Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36");
scope.device = 'mobile';
}
} else {
await scope.page.setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3312.0 Safari/537.36");
scope.device = 'pc';
}
scope.activate = click_with[ scope.device ];
});
Given(/the max(?:imum)? sec(?:ond)?s for each step (?:in this scenario )?is (\d+)/i, async ( seconds ) => {
/* At any time, set a custom timeout for the scenario. */
// TODO: Add 1 second to this to allow other steps to not fail, like "I wait n seconds"
scope.timeout = parseInt( seconds ) * 1000;
// Cucumber timeouts are made custom for each step that needs it
// If there is no page yet, we've ensured custom timeout will still be set
// appropriately in the interview loading step
if ( scope.page ) { await scope.page.setDefaultTimeout( scope.timeout ); }
});
//#####################################
//#####################################
// Story
//#####################################
//#####################################
When(/^(?:the user gets)?(?:I get)? to ?(?:the)?(?: ?question)?(?: id)? "([^"]+)" with this data:?/, {timeout: -1}, async (target_id, raw_var_data) => {
/* NO CUCUMBER TIMEOUT. Must handle timeout ourselves:
* https://github.com/cucumber/cucumber-js/blob/master/docs/support_files/timeouts.md#disable-timeouts
* Iterate in here to keep timeout reasonable */
// Note: Why do we not just stop when we've used all the vars in the table or
// reach the last var? Vars are not required to be given in sequence and pages that
// have non-button fields don't usually have a var assigned to the 'continue' button.
// Alternative: require vars even for 'continue' pages. Then in loop require at least one var
// to be found on a page before hitting 'continue'. That requires detecting navigation because
// pages with fields will not have a var for the continue button, so there will
// still have to be some automatic continuing.
// It also seems harsh, but more reliable.
// Ask developers what they'd like.
let supported_table = await scope.normalizeTable( scope, { raw_var_data });
let { question_has_id, id, id_matches } = await scope.examinePageID( scope, target_id );
// Until we reach the target page, hit an error, or time out (take too long)
while ( !id_matches ) {
let custom_timeout = new Promise(function( resolve, reject ) {
// Set up the timeout that avoids infinite loops
let on_page_timeout = async function() {
let non_reload_report_msg = `The target id was "${ target_id }", but it took `
+ `too long to try to reach it (over ${ scope.timeout/1000/60 } min). The `
+ `test got stuck on "${ id }".`
let error = non_reload_report_msg;
await scope.handle_page_timeout_error( scope, { non_reload_report_msg, error });
}; // Ends on_page_timeout()
let page_timeout_id = setTimeout(on_page_timeout, scope.timeout);
// self-invoke an anonymous async function so we don't have to
// abstract the details of resolving and rejecting.
// If we need to call recursively, signature function might look like:
// func( scope, { resolve, reject, supported_table, page_timeout_id })
// Maybe list of timeout and/or interval ids in state that can all be cleared at once?
(async function() {
try {
let { question_has_id, id, id_matches } = await scope.examinePageID( scope );
// Add special rows. Basically, do weirder things.
// TODO: Remove from_story_table - no longer needed if only called from in here
let ensured_var_data = await scope.ensureSpecialRows( scope, {
var_data: supported_table,
from_story_table: true,
});
// Some errors happen in there to be closer to the relevant code. We'd have to try/catch anyway.
let error = await scope.setFields(scope, { var_data: ensured_var_data, id, ensure_navigation: true });
if ( error.was_found ) { await scope.throwPageError( scope, { id: id }); }
resolve();
} catch ( err ) {
let err_msg = `Error occurred when tried to answer fields on question "${ id }".`
scope.addToReport( scope, { type: `error`, value: err_msg });
// Throw error instead?
reject( err );
} finally {
clearTimeout( page_timeout_id ); // prevent temporary hang at end of tests
}
})();
}); // ends custom_timeout for setFields
await custom_timeout;
// Prep for next loop
({ question_has_id, id, id_matches } = await scope.examinePageID( scope, target_id ));
} // ends while !id_matches
// Can anything unsatisfactory get through here?
// Add report strings from supported table, not an altered one
for ( let row of supported_table ) {
row.report_str = `${ await scope.convertToOriginalStoryTableRow(scope, { row_data: row }) }`;
}
// Sort into alphabetical order, as is recommended for writing story tables
let sorted_rows = supported_table.sort( function ( row1, row2 ) {
if( row1.report_str < row2.report_str ) { return -1; }
if( row1.report_str > row2.report_str ) { return 1; }
return 0;
});
// Add full used table to report so it can be copied if needed.
// TODO: Should we/How do we get this in the final scenario report instead of here?
await scope.addToReport( scope, { type: `note`, value: `\n${ ' '.repeat(2) }Rows that got set:` });
await scope.addToReport( scope, { type: `step`, value: `${ ' '.repeat(4) }And I get to the question id "${ target_id }" with this data:` });
await scope.addToReport(scope, { type: `row`, value: `${ ' '.repeat(6) }| var | value | trigger |`, });
let at_least_one_row_was_NOT_used = false;
for ( let row of sorted_rows ) {
if ( row.times_used > 0 ) {
await scope.addToReport(scope, { type: `row`, value: row.report_str, });
} else {
at_least_one_row_was_NOT_used = true;
}
}
// Add unused rows if needed
if ( at_least_one_row_was_NOT_used ) {
await scope.addToReport( scope, { type: `note`, value: `${ ' '.repeat(2) }Unused rows:` });
for ( let row of sorted_rows ) {
if ( row.times_used === 0 ) {
await scope.addToReport(scope, { type: `row`, value: row.report_str, });
}
}
} else {
await scope.addToReport(scope, { type: `note`, value: `${ ' '.repeat(2) }All rows were used` });
}
await scope.addToReport(scope, { type: `note`, value: `` });
});
//#####################################
//#####################################
// Passive/Observational
//#####################################
//#####################################
When(/I wait ?(?:for)? (\d*\.?\d+) seconds?/, { timeout: -1 }, async ( seconds ) => {
/** Wait for a specified amount of time. Can help with some race conditions.
* We're working on reducing race conditions, but it'll never be perfect.
*
* Variations:
* - I wait 1 second
* - I wait .5 seconds
*
* Questions:
* - Should this really reset the 'navigated' flag? Should the below
* return succeed or fail? e.g:
* I tap to continue; I wait 10 seconds; I arrive at the next page;
* Maybe the 'navigated' flag should only be reset just before trying to
* navigate (after pressing a button)
*/
return wrapPromiseWithTimeout(
scope.afterStep(scope, {waitForTimeout: (parseFloat( seconds ) * 1000)}),
scope.timeout
);
});
Then(/I take a screenshot ?(?:named "([^"]+)")?/, { timeout: -1 }, async ( name ) => {
/* Download and save a screenshot. `name` does not have to be
* unique as the filename will be made unique. */
return wrapPromiseWithTimeout(
scope.steps.screenshot( scope, name ),
scope.timeout
);
});
/* Here to make writing tests more comfortable. */
When("I do nothing", async () => { await scope.afterStep(scope); }); // √
Then(/the (?:question|page|screen) id should be "([^"]+)"/i, { timeout: -1 }, async ( question_id ) => {
/* Looks for a sanitized version of the question id as it's written
* in the .yml. docassemble's way */
let { question_has_id, id, id_matches } = await scope.examinePageID( scope, question_id );
// No question id exists
let no_id_msg = `Did not find any question id.`;
if ( !question_has_id ) {
await scope.addToReport( scope, { type: `error`, value: no_id_msg });
}
expect( question_has_id , no_id_msg ).to.be.true;
// Wrong question id
let no_match_msg = `The question id was supposed to be "${ question_id }"`
+ `, but it's actually "${ id }".`;
if ( !id_matches ) {
await scope.addToReport( scope, { type: `error`, value: no_match_msg });
}
expect( id_matches, no_match_msg ).to.be.true;
await scope.afterStep(scope);
});
// Allow emphasis with capital letters
Then(/I ?(?:should)? see the phrase "([^"]+)"/i, async ( phrase ) => { // √
/* In Chrome, this `innerText` gets only visible text */
const bodyText = await scope.page.$eval('body', elem => elem.innerText);
let msg = `The text "${ phrase }" SHOULD be on this page, but it's NOT.`;
if ( !bodyText.includes( phrase )) { await scope.addToReport(scope, { type: `error`, value: msg }); }
expect( bodyText, msg ).to.contain( phrase );
await scope.afterStep(scope);
});
// Allow emphasis with capital letters
Then(/I should not see the phrase "([^"]+)"/i, async ( phrase ) => { // √
/* In Chrome, this `innerText` gets only visible text:
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText
* "ignores hidden elements" */
const bodyText = await scope.page.$eval('body', elem => elem.innerText);
let msg = `The text "${ phrase }" should NOT be on this page, but it IS here.`;
if ( bodyText.includes( phrase )) { await scope.addToReport(scope, { type: `error`, value: msg }); }
expect( bodyText, msg ).not.to.contain( phrase );
await scope.afterStep(scope);
});
Then('an element should have the id {string}', async ( id ) => { // √
const element = await scope.page.$('#' + id);
let msg = `No element on this page has the ID "${ id }".`;
if ( !element ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
expect( element, msg ).to.exist;
await scope.afterStep(scope);
});
// TODO:
// Just realized it's much more likely that the group name will be
// repeated than that the choice name will be repeated. Or maybe
// they're both equally likely to be repeated, but writing about the
// ordinal of the group would probably be more comfortable for a human.
// Need to think about this.
// let ordinal_regex = '?(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)?';
// // Haven't figured out ordinal_regex for group label yet
// // Allow "checkbox" and "radio"? Why would they be called the same thing?
// // TODO: Remove final pipe in regex
// let the_checkbox_is_as_expected = new RegExp(`the ${ ordinal_regex } ?(?:"([^"]+)")? (choice|checkbox|radio)(?: button)? ?(?:in "([^"]+)")? is (checked|unchecked|selected|unselected)`);
// Then(the_checkbox_is_as_expected, async (ordinal, label_text, choice_type, group_label_text, expected_status) => { // √
/* Non-dropdown non-combobox choices
* Examples of use:
* 1. the choice is unchecked
* 1. the radio is unchecked
* 1. the checkbox is unchecked
* 1. the only checkbox is unchecked
* 1. the third checkbox is checked
* 1. the "My court" checkbox is unchecked
* 1. the checkbox in "Which service" is checked
* 1. the third "My court" checkbox is checked
* 1. the third checkbox in "Which service" is checked
* 1. the "My court" checkbox in "Which service" is checked
* 1. the third "My court" checkbox in "Which service" is checked
* combos: none; a; b; c; a b; a c; b c; a b c;
*/
// let roles = [ choice_type ];
// if ( choice_type === 'choice' ) { roles = ['checkbox', 'radio']; }
// let elem = await scope.getSpecifiedChoice(scope, {ordinal, label_text, roles, group_label_text});
// // See if it's checked or not
// let is_checked = await elem.evaluate((elem) => {
// return elem.getAttribute('aria-checked') === 'true';
// });
// let what_it_should_be = expected_status === 'checked' || expected_status === 'selected';
// expect( is_checked ).to.equal( what_it_should_be );
// await scope.afterStep(scope);
// });
// This phrase is confusing. It sounds like it's going to at least try to continue.
Then(/I (don't|can't|don’t|can’t|cannot|do not) continue/, async (unused) => { // √
/* Tests for detection of url change from button or link tap.
* Can other things trigger navigation? Re: other inputs things
* like 'Enter' acting like a click is a test for docassemble */
let msg = `The page should have stopped the user from continuing, but the user was able to continue.`;
if ( scope.navigated ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
expect( scope.navigated, msg ).to.be.false;
await scope.afterStep(scope);
});
Then('I will be told an answer is invalid', async () => { // √
let { was_found, error_handlers } = await scope.checkForError( scope );
let no_error_msg = `No error message was found on the page`;
if ( !was_found ) { await scope.addToReport(scope, { type: `error`, value: no_error_msg }); }
expect( was_found, no_error_msg ).to.be.true;
let was_user_error = error_handlers.complex_user_error !== undefined || error_handlers.simple_user_error !== undefined;
let not_user_error_msg = `The error was a system error, not an error message to the user. Check the error screenshot.`;
if ( !was_user_error ) { await scope.addToReport(scope, { type: `error`, value: not_user_error_msg }); }
expect( was_user_error, not_user_error_msg ).to.be.true;
await scope.afterStep(scope);
});
Then('I arrive at the next page', async () => { // √
/* Tests for detection of url change from button or link tap.
* Can other things trigger navigation? Re: other inputs things
* like 'Enter' acting like a click is a test for docassemble */
let msg = `User did not arrive at the next page.`
if ( !scope.navigated ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
expect( scope.navigated, msg ).to.be.true;
await scope.afterStep(scope);
});
// Link observations have the 'Escape' button link in mind
Then('I should see the link {string}', async ( linkText ) => { // √
/* Loosely match link visible text. Should probably deprecate this. */
let [link] = await scope.page.$x(`//a[contains(text(), "${linkText}")]`);
let msg = `Cannot find a link with the text "${ linkText }".`;
if ( !link ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
expect( link, msg ).to.exist;
await scope.afterStep(scope);
});
Then('I should see the link to {string}', async ( url ) => { // √
/* Strictly match href of link. */
let link = await scope.page.$(`*[href="${ url }"]`);
let msg = `Cannot find a link to "${ url }".`;
if ( !link ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
expect(link, msg ).to.exist;
await scope.afterStep(scope);
});
Then(/the "([^"]+)" link leads to "([^"]+)"/, async (linkText, expected_url) => { // √
/* Find link by its visible text and where it leads. */
let [link] = await scope.page.$x(`//a[contains(text(), "${linkText}")]`);
// TODO: Possibly add a check here for the link text, though note this
// Step is not currently supported.
let prop_obj = await link.getProperty('href');
let actual_url = await prop_obj.jsonValue();
let msg = `Cannot find a link with the text "${ linkText }" leading to ${ expected_url }.`;
if ( actual_url !== expected_url ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
expect( actual_url ).to.equal( expected_url );
await scope.afterStep(scope);
});
Then(/the "([^"]+)" link opens in (a new window|the same window)/, async (linkText, which_window) => { // √
let [link] = await scope.page.$x(`//a[contains(text(), "${linkText}")]`);
let prop_obj = await link.getProperty('target');
let target = await prop_obj.jsonValue();
let should_open_a_new_window = which_window === 'a new window';
let opens_a_new_window = target === '_blank';
let hasCorrectWindowTarget =
( should_open_a_new_window && opens_a_new_window )
|| ( !should_open_a_new_window && !opens_a_new_window );
let msg = `The link "${ linkText }" does NOT open in ${ which_window }.`;
if ( !hasCorrectWindowTarget ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
expect( hasCorrectWindowTarget, msg ).to.be.true;
await scope.afterStep(scope);
});
Then(/(?:the )?text(?: in)?(?: the)?(?: JSON)?(?: var)?(?:iable)?(?: )?"([^"]+)" should be/i, async (var_name, expected_value) => {
/* Check that the text string of the page's JSON var value matches
* the given text string. Does not currently accept nested values
* like "foo.bar". The word "text" is required.
*
* The format for this step is something like this:
* ```
* Then the text in the JSON variable "var_name" should be
* """
* A single or
* multiline string
* """
* ```
*
* Sentence combinations that will work:
* `the text in the JSON variable "variable_with_text_value" should be`
* `the text in the JSON var "variable_with_text_value" should be`
* `the text in "variable_with_text_value" should be`
* `text var "variable_with_text_value" should be`
*/
// Get JSON variable value
let { json_path, json } = await scope.savePageJSONData( scope );
let actual_value = json.variables[ var_name ];
// Add warning to report if value is not as expected
if ( actual_value !== expected_value ) {
await scope.addToReport( scope, {
type: `error`,
value: `The variable "${ var_name }" was not equal to the expected `
+ `value on the "${ scope.page_id }" screen. Check `
+ `${ json_path } to see the page's JSON variables.`
});
}
expect( actual_value ).to.equal( expected_value );
});
Then(/I get the(?: page's)?(?: JSON)? var(?:iable)?s and val(?:ue)?s/i, { timeout: -1 }, async () => {
/** Uses https://docassemble.org/docs/functions.html#js_get_interview_variables
* to get the variable values on the page of the interview and add
* them to the report.
*
* Variations:
* I get the page's JSON variables and values
* I get the json vars and vals
* I get the page's vars and vals
* I get the vars and vals
*/
return await wrapPromiseWithTimeout(
scope.steps.add_json_vars_to_report( scope ),
scope.timeout
);
});
//#####################################
//#####################################
// Actions
//#####################################
//#####################################
//#####################################
// Possible navigation
//#####################################
// Consider people wanting to use this for an in-interview page
// Also consider 'the link url "..." should open...'?
Then(/the "([^"]+)" link opens a working page/, { timeout: -1 }, async ( linkText ) => {
return wrapPromiseWithTimeout(
scope.steps.link_works( scope, linkText ),
scope.timeout
);
});
When('I tap to continue', { timeout: -1 }, async () => {
// Any selectors for this seem somewhat precarious
// No data will cause an automatic continue when `ensure_navigation` is true
// Exiting the custom timeout happens in scope.tapElement() where we can
// check if the server is reloading
await scope.setFields( scope, { var_data: [], ensure_navigation: true });
});
When(/I tap the "([^"]+)" tab/i, { timeout: -1 }, async ( tabId ) => {
// Exiting the custom timeout happens in scope.tapElement() if needed
await scope.tapTab( scope, tabId );
});
When(/I tap the "([^"]+)" element$/i, { timeout: -1 }, async ( elemSelector ) => {
// Exiting the custom timeout happens in scope.tapElement() if needed
await scope.tapElementBySelector( scope, elemSelector, 2000 );
});
When(/I tap the "([^"]+)" element and wait ([0-9]+) second(?:s)?/i, { timeout: -1 }, async ( elemSelector, waitTime ) => {
// Exiting the custom timeout happens in scope.tapElement() if needed
await scope.tapElementBySelector( scope, elemSelector, waitTime * 1000 )
});
When(/I check the page for (?:accessibility|a11y) issues/i, {timeout: -1}, async () => {
let { passed, axe_filepath } = await wrapPromiseWithTimeout(
scope.checkForA11y(scope),
scope.timeout
)
let msg = `Found potential accessibility issues on the ${ scope.page_id } screen. Details in ${ axe_filepath }.`;
expect( passed, msg ).to.be.true;
});
When(/I check all pages for (?:accessibility|a11y) issues/i, {timeout: -1}, async () => {
scope.check_all_for_a11y = true;
scope.passed_all_for_a11y = true;
});
//#####################################
// UI element interaction
//#####################################
// -------------------------------------
// --- RANDOM INPUT ---
When(/I answer randomly for at most ([0-9]+) (?:page(?:s)?|screen(?:s)?)/, { timeout: -1 }, async ( max_pages_str ) => {
/** Give random answers until the user can't continue any more.
*
* Variations:
* I answer randomly for at most 1 page
* I answer randomly for at most 10 pages
* I answer randomly for at most 101 screens
*/
// Give each screenshots folder a time-stamped name in case the dev gets clever
// and use the same Scenario to run multiple randomized tests.
let timestamp = files.readable_date();
let random_input_path = `${ scope.paths.scenario }/random_answers_screenshots-${ timestamp }`
scope.paths.random_screenshots = random_input_path;
fs.mkdirSync( scope.paths.random_screenshots );
await scope.addToReport( scope, {
type: 'row',
value: `Testing interview with random input (in ${ random_input_path })`
});
// Force the dev to prevent infinite loops
let max_pages = parseInt( max_pages_str );
let curr_page_num = 0;
// While a continue-type button is on the current page, fill out
// the fields and then try to continue
let buttons_exist = true;
while ( buttons_exist && curr_page_num < max_pages ) {
curr_page_num = curr_page_num + 1;
let { id } = await scope.examinePageID( scope, `no target id` );
let no_content_err = `The interview seemed to run into an error page`
+ ` after the page with the question id "${ id }".`
+ ` See the screenshot in "${ random_input_path }".`;
let has_thrown = false;
// Ensure no infinite loop, but each page has a full timeout to run
let custom_timeout = new Promise(async function( resolve, reject ) {
// Set up timeout to stop infinite loops
let page_timeout_id = setTimeout(async function() {
// Make sure to only throw once
if ( has_thrown ) { return; }
else { has_thrown = true; }
let has_content = await scope.has_interview_content( scope );
if ( !has_content ) {
await scope.addToReport( scope, {
type: `error`,
value: no_content_err
});
reject( no_content_err );
} else {
let timeout_message = `The page with an id of "${ id }" took too long`
+ ` to fill with random answers (over ${ scope.timeout/1000/60 } min).`
await scope.addToReport( scope, { type: `error`, value: timeout_message });
reject( timeout_message );
}
}, scope.timeout );
// self-invoke an anonymous async function so we don't have to
// abstract the details of resolving and rejecting.
(async function() {
try {
// Set all the fields on the page
let buttons = await scope.steps.set_random_page_vars( scope );
// Take a screenshot of the page before continuing or ending
let path = `${ scope.paths.random_screenshots }/random-${ Date.now() }.jpg`;
await scope.take_a_screenshot( scope, { path: path });
// Prep for the next loop to possibly stop. Signature page buttons
// don't count as buttons in the way our field detects them
buttons_exist = buttons.length > 0;
// If there were any buttons, pick a random one to try to press
if ( buttons_exist ) {
await scope.set_random_input_for.buttons( scope, { fields: buttons });
} else {
// If can theoretically continue, try to. Also prep to
// stop or continue for the next loop
buttons_exist = await scope.continue_exists( scope );
if ( buttons_exist ) {
await scope.continue( scope );
} // ends if continue exists
} // ends if buttons_exist
let has_content = await scope.has_interview_content( scope );
if ( !has_content ) {
// Throw an error if there's no interview content
// Make sure to only throw once
if ( !has_thrown ) {
has_thrown = true;
await scope.addToReport( scope, {
type: `error`,
value: no_content_err
});
reject( no_content_err );
}
} // ends if !has_content
if ( buttons_exist ) {
process.stdout.write(`\x1b[36m${ 'v' }\x1b[0m`); // pressed button
}
resolve();
} catch ( error ) {
// Make sure to only throw once
if ( has_thrown ) { return; }
else { has_thrown = true; }
// Throw a specific error if there's no interview content
let has_content = await scope.has_interview_content( scope );
if ( !has_content ) {
await scope.addToReport( scope, {
type: `error`,
value: no_content_err
});
reject( no_content_err );
// Otherwise throw a generic error
} else {
let err_msg = `The page with the question id "${ id }" errored when trying to fill`
+ ` it with random answers: ${ error.name }`
await scope.addToReport( scope, { type: `error`, value: err_msg });
reject( error );
} // ends if !has_content
} finally {
// prevent temporary hang at end of tests
clearTimeout( page_timeout_id );
}
})();
}); // ends custom_timeout for set_random_page_vars
await custom_timeout;
} // ends while continue_exists
}); // Ends random input
// -------------------------------------
// --- VARIABLES-BASED STEPS ---
When(/I set the var(?:iable)? "([^"]+)" to "([^"]+)"/, { timeout: -1 }, async ( var_name, answer ) => {
/* Set a non-story table variable (with or without a choice associated with it) to a value. Set:
* - Buttons associated with variables
* - Dropdowns
* - Checkboxes (multiple choice checkboxes must be set to "true" or "false")
* - Radio buttons
* - Text inputs
* - Textareas
*
* Variations:
* 1. I set the var "benefits['SSI']" to "false"
* 1. I set the variable "rent_interval" to "weekly"
*/
// Exiting the custom timeout happens in scope.tapElement() if needed
await scope.steps.set_regular_var( scope, var_name, answer )
});
When(/I set the var(?:iable)? "([^"]+)" to ?(?:the)? ?(?:GitHub)? secret "([^"]+)"/i, { timeout: -1 }, async ( var_name, set_to_env_name ) => {
/* Set a non-story table variable to a secrete value. Same as the above, but reads the value from `process.env` */
// This could theoretically tap an element, so
// exiting the custom timeout happens in scope.tapElement() if needed
await scope.steps.set_secret_var( scope, var_name, set_to_env_name )
});
Then(/I upload "([^"]+)" to "([^"]+)"/, { timeout: -1 }, async ( filenames, var_name ) => {
/* Uploads file (docx, jpg, etc) in same folder to input var. Can actually use
* 'I set the var "" to ""', but this is more explicit if it's desired. */
return wrapPromiseWithTimeout(
scope.steps.set_regular_var( scope, var_name, filenames ),
scope.timeout
);
});
// -------------------------------------
// --- OTHER UI STEPS ---
When('I tap the defined text link {string}', { timeout: -1 }, async ( phrase ) => {
/** Tap a link that doesn't navigate. Depends on the language of the text. */
// exiting the custom timeout happens in scope.tapElement() if needed
await scope.steps.tap_term( scope, phrase );
});
Then(/^I sign$/, { timeout: -1 }, async () => {
return wrapPromiseWithTimeout(
scope.steps.sign( scope ),
scope.timeout
);
});
Then('I sign with the name {string}', { timeout: -1 }, async ( name ) => {
/* Signs with the string argument */
return wrapPromiseWithTimeout(
scope.steps.sign( scope, name ),
scope.timeout
);
});
When(/I download "([^"]+)"$/, { timeout: -1 }, async ( filename ) => {
/* Taps the link that leads to the given filename to trigger downloading.
* and waits till the file has been downloaded before allowing the tests to continue.
* WARNING: Cannot download the same file twice in a single scenario.