@suffolklitlab/alkiln 4.6.2 → 4.7.2
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/CHANGELOG.md +26 -0
- package/lib/docassemble/docassemble_api_interface.js +18 -0
- package/lib/scope.js +699 -116
- package/lib/steps.js +274 -121
- package/lib/utils/files.js +1 -0
- package/lib/utils/session_vars.js +1 -0
- package/package.json +3 -2
- package/tests/features/interactive_steps.feature +1 -1
- package/tests/features/random_input.feature +31 -0
- package/tests/features/reload.feature +155 -0
- package/tests/features/story_table_formats.feature +49 -1
package/lib/scope.js
CHANGED
|
@@ -4,6 +4,8 @@ const path = require('path');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const { AxePuppeteer } = require('@axe-core/puppeteer');
|
|
6
6
|
const safe_filename = require("sanitize-filename");
|
|
7
|
+
// "en" is faster to import: https://github.com/faker-js/faker/issues/1114#issuecomment-1169532948
|
|
8
|
+
const faker = require('@faker-js/faker/locale/en').faker;
|
|
7
9
|
|
|
8
10
|
// Ours
|
|
9
11
|
const session_vars = require('./utils/session_vars');
|
|
@@ -12,6 +14,8 @@ const time = require('./utils/time');
|
|
|
12
14
|
const log = require('./utils/log');
|
|
13
15
|
const { waitForTimeout } = require('./utils/time');
|
|
14
16
|
const files = require('./utils/files' );
|
|
17
|
+
const server = require('./docassemble/docassemble_api_interface');
|
|
18
|
+
const wrapPromiseWithTimeout = require('./cucumber_8_shim');
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
let ordinal_to_integer = {
|
|
@@ -55,6 +59,7 @@ let misc_artifacts_dir = `_alkiln_test-misc_artifacts`;
|
|
|
55
59
|
|
|
56
60
|
module.exports = {
|
|
57
61
|
trigger_not_needed_flag: `ALKiln: no trigger variable needed`,
|
|
62
|
+
|
|
58
63
|
addToReport: async function( scope, { type, value }) {
|
|
59
64
|
/* Add an item to a specific list in a scenario's report.
|
|
60
65
|
* Create report if necessary.
|
|
@@ -189,6 +194,7 @@ module.exports = {
|
|
|
189
194
|
let report = ``;
|
|
190
195
|
for ( let line of data.lines ) {
|
|
191
196
|
if ( line.type === `page_id` ) { report += `screen id: `; }
|
|
197
|
+
else if ( line.type === `outcome` ) { report += `\n`; }
|
|
192
198
|
else if ( line.type === `warning` ) { report += `WARNING: `; }
|
|
193
199
|
else if ( line.type === `error` ) { report += `\nERROR: ` }
|
|
194
200
|
report += `${ line.value }\n`;
|
|
@@ -354,41 +360,54 @@ module.exports = {
|
|
|
354
360
|
// This check might never be needed now as we try to match fields on the page
|
|
355
361
|
// first in user-triggered cases - users used to be able to access this almost
|
|
356
362
|
// directly, but they can't anymore. Our functions should know to only pass good things.
|
|
357
|
-
let msg = `Cannot find
|
|
363
|
+
let msg = `Cannot find the element to tap.`;
|
|
358
364
|
if ( !elem ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
|
|
359
365
|
|
|
360
366
|
expect( elem, msg ).to.exist;
|
|
361
367
|
|
|
368
|
+
// Get the text on the button or link for a potential error message later
|
|
369
|
+
let tapperText = await (await elem.getProperty('textContent')).jsonValue();
|
|
370
|
+
|
|
362
371
|
let start_url = await scope.page.url(); // so we can later check for navigation
|
|
363
372
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
// and other convos around it. We haven't made it public that the device
|
|
371
|
-
// can customized. Until we do that, we'll just use "click" all the time.
|
|
372
|
-
if (!tabToWaitFor) {
|
|
373
|
-
// Click with no navigation will end immediately
|
|
374
|
-
clickWait = Promise.all([
|
|
375
|
-
elem.evaluate( (el) => { return el.click(); }),
|
|
376
|
-
scope.page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
|
|
377
|
-
]);
|
|
378
|
-
} else {
|
|
379
|
-
// Same thing as above, but the promise doesn't wait for navigation.
|
|
380
|
-
clickWait = elem.evaluate( (el) => {return el.click(); });
|
|
373
|
+
// We'll wait for the click itself at the very least
|
|
374
|
+
let click_promises = [ elem.evaluate( (el) => { return el.click(); }) ];
|
|
375
|
+
|
|
376
|
+
// If we're possibly navigating, wait for navigation as well
|
|
377
|
+
if ( !tabToWaitFor ) {
|
|
378
|
+
click_promises.push( scope.page.waitForNavigation({ waitUntil: 'domcontentloaded' }) );
|
|
381
379
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
380
|
+
let clickWait = Promise.all( click_promises );
|
|
381
|
+
|
|
382
|
+
let winner = null;
|
|
383
|
+
|
|
384
|
+
// TODO: implement and use scope.handle_possible_timeout()
|
|
385
|
+
try {
|
|
386
|
+
// Error loads page, so no need to detect to keep things short
|
|
387
|
+
// Also, puppeteer will ensure proper timeout.
|
|
388
|
+
winner = await Promise.race([
|
|
389
|
+
clickWait,
|
|
390
|
+
// This is meant to detect user-visible alerts/errors,
|
|
391
|
+
// not breaking errors in the testing code.
|
|
392
|
+
scope.page.waitForSelector('.alert-danger', { visible: true }),
|
|
393
|
+
scope.page.waitForSelector('.da-has-error', { visible: true }),
|
|
394
|
+
]);
|
|
395
|
+
} catch ( error ) {
|
|
396
|
+
|
|
397
|
+
if ( error.name === `TimeoutError` ) {
|
|
398
|
+
let non_reload_report_msg = `ALKiln tapped "${ tapperText }", but the interview took to long to finish.`;
|
|
399
|
+
await scope.handle_page_timeout_error( scope, { non_reload_report_msg, error });
|
|
400
|
+
} else {
|
|
401
|
+
// Throw any non-timeout error
|
|
402
|
+
let err_msg = `Error occurred when ALKiln tried to tap "${ tapperText }".`
|
|
403
|
+
scope.addToReport( scope, { type: `error`, value: err_msg });
|
|
404
|
+
throw error;
|
|
405
|
+
} // ends if error is timeout error
|
|
406
|
+
|
|
407
|
+
} // ends try/catch
|
|
408
|
+
|
|
409
|
+
// TODO: Not sure how to detect navigation landing at same page, such as
|
|
410
|
+
// with a user input validation error. Does that count as navigating?
|
|
392
411
|
// Maybe url change + trigger var change? Maybe just trigger var?
|
|
393
412
|
let end_url = await scope.page.url();
|
|
394
413
|
let navigated = start_url !== end_url;
|
|
@@ -411,6 +430,156 @@ module.exports = {
|
|
|
411
430
|
return winner;
|
|
412
431
|
}, // Ends scope.tapElement()
|
|
413
432
|
|
|
433
|
+
// Handle server reload errors
|
|
434
|
+
server_statuses: [],
|
|
435
|
+
server_check_interval_ms: 1 * 1000,
|
|
436
|
+
stop_tracking_server_status: false,
|
|
437
|
+
track_server_status: async function( scope ) {
|
|
438
|
+
/** Updates server_statuses list by checking if server is responding until,
|
|
439
|
+
* eventually, it is told to stop. */
|
|
440
|
+
let server_check_start_time = Date.now();
|
|
441
|
+
|
|
442
|
+
while ( !scope.stop_tracking_server_status ) {
|
|
443
|
+
// This code assumes a reasonable response time would be n seconds if the
|
|
444
|
+
// server is up and that a server reload/restart won't take less than n seconds.
|
|
445
|
+
// Also, it's non-blocking, so it doesn't slow down tests
|
|
446
|
+
let is_up = await server.is_server_responding({ timeout: scope.server_check_interval_ms });
|
|
447
|
+
// Add status to the beginning of the list to make truncating easier
|
|
448
|
+
scope.server_statuses.unshift( is_up );
|
|
449
|
+
|
|
450
|
+
// Ensure we wait longer if a full second hasn't yet passed
|
|
451
|
+
let ms_passed = Date.now() - server_check_start_time;
|
|
452
|
+
// Use Math.max to ensure no negative numbers
|
|
453
|
+
let ms_remaining = Math.max( 0, scope.server_check_interval_ms - ms_passed );
|
|
454
|
+
await time.waitForTimeout( ms_remaining );
|
|
455
|
+
// Prepare for next check
|
|
456
|
+
server_check_start_time = Date.now()
|
|
457
|
+
|
|
458
|
+
// To make it easier to check past server statuses, remove defunct server statuses
|
|
459
|
+
let max_length = Math.round( scope.timeout/scope.server_check_interval_ms );
|
|
460
|
+
// Trying to extract more items than the actual length is ok
|
|
461
|
+
scope.server_statuses = scope.server_statuses.slice( 0, max_length );
|
|
462
|
+
} // ends while !scope.stop_tracking_server_status
|
|
463
|
+
|
|
464
|
+
}, // Ends scope.track_server_status()
|
|
465
|
+
|
|
466
|
+
did_server_reload: async function( scope ) {
|
|
467
|
+
return scope.server_statuses.includes( false );
|
|
468
|
+
}, // Ends scope.did_server_reload()
|
|
469
|
+
|
|
470
|
+
// handle_possible_timeout: async function( scope, {
|
|
471
|
+
// promise: ,
|
|
472
|
+
// non_timeout_msg: ,
|
|
473
|
+
// normal_timeout_msg: ,
|
|
474
|
+
// actual_error: ,
|
|
475
|
+
// }) {}, // Ends scope.handle_possible_timeout()
|
|
476
|
+
|
|
477
|
+
handle_page_timeout_error: async function( scope, { non_reload_report_msg, error } ) {
|
|
478
|
+
/* For a page timeout error, handles server reload vs. other timeout errors
|
|
479
|
+
|
|
480
|
+
@param options.non_reload_report_msg { str } Non-reload message to add to
|
|
481
|
+
the report as an error.
|
|
482
|
+
@param options.error { object | str } Error to throw. It can be the actual
|
|
483
|
+
error object or a string that will show up as a message in the error.
|
|
484
|
+
*/
|
|
485
|
+
// If the server was reloading during that time, wait for the server to
|
|
486
|
+
// respond again so the next test has a better chance of passing. Then
|
|
487
|
+
// throw an error. The command-line script will re-try the Scenario after.
|
|
488
|
+
let server_reloaded_during_interval = await scope.did_server_reload( scope );
|
|
489
|
+
if ( server_reloaded_during_interval ) {
|
|
490
|
+
// Wait for the server to reload just to make sure the next test
|
|
491
|
+
// doesn't also fail because of this same server reload.
|
|
492
|
+
await scope.get_server_reload_promise( scope );
|
|
493
|
+
} else {
|
|
494
|
+
// Otherwise just throw the error
|
|
495
|
+
await scope.addToReport( scope, {
|
|
496
|
+
type: `error`,
|
|
497
|
+
value: non_reload_report_msg });
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
}, // Ends scope.handle_page_timeout_error()
|
|
501
|
+
|
|
502
|
+
get_server_reload_promise: async function( scope ) {
|
|
503
|
+
/** If the promise already exists in state, return it because it can
|
|
504
|
+
* be used anywhere with `await`. Otherwise, make the promise, store
|
|
505
|
+
* it in state, and return that.
|
|
506
|
+
*/
|
|
507
|
+
if ( scope.server_reload_promise !== null ) {
|
|
508
|
+
// Return the existing promise of the func that will check on reloading the server
|
|
509
|
+
return scope.server_reload_promise
|
|
510
|
+
|
|
511
|
+
} else {
|
|
512
|
+
// Create and return the promise of the func that will check on reloading the server
|
|
513
|
+
scope.server_reload_promise = scope.wait_for_server_response( scope );
|
|
514
|
+
return scope.server_reload_promise;
|
|
515
|
+
}
|
|
516
|
+
}, // Ends scope.get_server_reload_promise()
|
|
517
|
+
|
|
518
|
+
wait_for_server_response: async function( scope ) {
|
|
519
|
+
/** Wait for the server to respond using a multiple of scope.timeout
|
|
520
|
+
* (or a custom timeout?) so the tests don't fail just because the
|
|
521
|
+
* server isn't responding. Should this take an argument of an amount
|
|
522
|
+
* of time to check or just assume we're using scope.timeout?
|
|
523
|
+
*
|
|
524
|
+
* This should only be called if the server failed to respond at some point.
|
|
525
|
+
*
|
|
526
|
+
* This currently assumes we're either using scope.timeout as basis for
|
|
527
|
+
* the amount of time in which to detect a finished reload or that
|
|
528
|
+
* there's a test input that defines a different timeout.
|
|
529
|
+
*/
|
|
530
|
+
|
|
531
|
+
let ms_till_server_timeout;
|
|
532
|
+
if ( session_vars.get_server_reload_timeout() !== null ) {
|
|
533
|
+
// Should we require the expected value to be seconds instead of ms?
|
|
534
|
+
ms_till_server_timeout = parseInt( session_vars.get_server_reload_timeout() );
|
|
535
|
+
} else {
|
|
536
|
+
// Default server timeout will always be based on scope.timeout even if it changes
|
|
537
|
+
ms_till_server_timeout = 5 * scope.timeout;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
let throw_after_server_reloads = async function () {
|
|
541
|
+
|
|
542
|
+
// Continue waiting until server has responded or until it's taken too long
|
|
543
|
+
let time_accumulator = 0;
|
|
544
|
+
let max = ms_till_server_timeout/scope.server_check_interval_ms
|
|
545
|
+
|
|
546
|
+
while ( scope.server_statuses[0] !== true && time_accumulator < max ) {
|
|
547
|
+
await time.waitForTimeout( scope.server_check_interval_ms );
|
|
548
|
+
time_accumulator += 1;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// If the server hasn't responded yet even now, let the developer know
|
|
552
|
+
if ( time_accumulator >= max ) {
|
|
553
|
+
let timeout_message = `The server took longer than `
|
|
554
|
+
+ `${ Math.round( ms_till_server_timeout/1000 ) } seconds to respond. `
|
|
555
|
+
+ `If the server was just reloading and needed more time, try setting the `
|
|
556
|
+
+ `"SERVER_RELOAD_TIMEOUT_SECONDS" GitHub secret to a longer amount of time.`
|
|
557
|
+
await scope.addToReport( scope, { type: `error`, value: timeout_message });
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Once server has responded, throw an error to end the test. After the first
|
|
561
|
+
// failure, the whole Scenario should retry because of command line flag. Hopefully
|
|
562
|
+
// it'll get through the next time. We considered trying to redo the input on the
|
|
563
|
+
// page instead of re-doing the whole test, but that wouldn't work - we don't
|
|
564
|
+
// know how many Steps to back to do that and we can't redo Steps anyway.
|
|
565
|
+
await scope.throw_server_was_reloading_error( scope )
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
await throw_after_server_reloads();
|
|
569
|
+
|
|
570
|
+
}, // Ends scope.wait_for_server_response()
|
|
571
|
+
|
|
572
|
+
throw_server_was_reloading_error: async function( scope ) {//, { server_timeout_id, timeout_message } ) {
|
|
573
|
+
/* Throw the server reload error with appropriate side effects. */
|
|
574
|
+
let reload_failure_msg = `The test was unable to continue because the `
|
|
575
|
+
+ `server was reloading. ALKiln will try this Scenario a total of 2 times. An `
|
|
576
|
+
+ `error will still be printed for this attempt.`;
|
|
577
|
+
await scope.addToReport( scope, { type: `error`, value: reload_failure_msg });
|
|
578
|
+
// await scope.addToReport( scope, { type: `error`, value: timeout_message });
|
|
579
|
+
// clearTimeout( server_timeout_id );
|
|
580
|
+
throw( reload_failure_msg );
|
|
581
|
+
}, // Ends scope.throw_server_was_reloading_error()
|
|
582
|
+
|
|
414
583
|
detectDownloadComplete: async function detectDownloadComplete(scope, endTime) {
|
|
415
584
|
/* Resolve if a download flag has changed. Timeout after a bit less than
|
|
416
585
|
* the global permitted timeout
|
|
@@ -488,7 +657,7 @@ module.exports = {
|
|
|
488
657
|
|
|
489
658
|
// Do your own waiting if you need to in the step
|
|
490
659
|
return to_manipulate;
|
|
491
|
-
}, // Ends setVar()
|
|
660
|
+
}, // Ends scope.setVar()
|
|
492
661
|
|
|
493
662
|
continue: async function ( scope ) {
|
|
494
663
|
/* Presses whatever button it finds that might lead to the next page. */
|
|
@@ -500,7 +669,25 @@ module.exports = {
|
|
|
500
669
|
await elem.evaluate( el => { return el.className });
|
|
501
670
|
// Waits for navigation or user error
|
|
502
671
|
await scope.tapElement( scope, elem );
|
|
503
|
-
},
|
|
672
|
+
}, // Ends scope.continue()
|
|
673
|
+
|
|
674
|
+
continue_exists: async function ( scope ) {
|
|
675
|
+
/** Return true if a button that will allow continuing appears
|
|
676
|
+
* on this page. Otherwise return false. */
|
|
677
|
+
|
|
678
|
+
// Possible to find this on an event screen with `buttons:`, like an exit button
|
|
679
|
+
// <button type="submit" class="btn btn-da btn-danger" name="X211bHRpcGxlX2Nob2ljZQ" value="0">exit</button>
|
|
680
|
+
// An actual continue button that doesn't set a variable
|
|
681
|
+
// <button class="btn btn-da btn-primary" type="submit">Continue</button>
|
|
682
|
+
// One that sets a variable:
|
|
683
|
+
// <button type="submit" class="btn btn-da btn-primary" name="Zm9v" value="True">Continue</button>
|
|
684
|
+
// `buttons:` can be used in question blocks as choices
|
|
685
|
+
let regular = await scope.page.$( `fieldset.da-field-buttons button[type="submit"]` );
|
|
686
|
+
let signature = await scope.page.$( `fieldset .dasigsave` );
|
|
687
|
+
|
|
688
|
+
return regular !== null || signature !== null;
|
|
689
|
+
|
|
690
|
+
}, // Ends scope.continue_exists()
|
|
504
691
|
|
|
505
692
|
tapTab: async function ( scope, tab_id ) {
|
|
506
693
|
let elem = await scope.page.$(`#${tab_id}`);
|
|
@@ -565,18 +752,36 @@ module.exports = {
|
|
|
565
752
|
/* Try to load the page. Should we also pass a `timeout` arg or just
|
|
566
753
|
* use scope.timeout? */
|
|
567
754
|
|
|
568
|
-
// If there is no browser open, start a new one
|
|
569
|
-
if (!scope.browser) {
|
|
570
|
-
scope.browser = await scope.driver.launch({ headless: !session_vars.get_debug(), devtools: session_vars.get_debug() });
|
|
571
|
-
}
|
|
572
|
-
if ( !scope.page ) { scope.page = await scope.browser.newPage(); }
|
|
573
|
-
|
|
574
755
|
if ( !file_name.includes('.yml') ) { file_name = `${ file_name }.yml` }
|
|
575
756
|
let base_url = await get_base_interview_url();
|
|
576
757
|
let interview_url = `${ base_url }${ file_name }`;
|
|
577
758
|
if ( session_vars.get_debug() ) { console.log( interview_url ); }
|
|
578
759
|
|
|
579
|
-
|
|
760
|
+
if ( !scope.page ) { scope.page = await scope.browser.newPage(); }
|
|
761
|
+
|
|
762
|
+
// TODO: implement and use scope.handle_possible_timeout()
|
|
763
|
+
try {
|
|
764
|
+
|
|
765
|
+
await scope.page.goto(interview_url, { waitUntil: 'domcontentloaded', timeout: scope.timeout })
|
|
766
|
+
|
|
767
|
+
} catch ( error ) {
|
|
768
|
+
|
|
769
|
+
if ( error.name === `TimeoutError` ) {
|
|
770
|
+
|
|
771
|
+
let non_reload_report_msg = `Timeout error occurred when ALKiln tried to go to the interview url "${ interview_url }".`;
|
|
772
|
+
await scope.handle_page_timeout_error( scope, { non_reload_report_msg, error });
|
|
773
|
+
|
|
774
|
+
} else {
|
|
775
|
+
|
|
776
|
+
// Throw any non-timeout error
|
|
777
|
+
scope.addToReport( scope, {
|
|
778
|
+
type: `error`,
|
|
779
|
+
value: `Error occurred when ALKiln tried to go to the interview url "${ interview_url }".`
|
|
780
|
+
});
|
|
781
|
+
throw( error );
|
|
782
|
+
} // ends if error is timeout error
|
|
783
|
+
|
|
784
|
+
} // ends try/catch
|
|
580
785
|
|
|
581
786
|
let load_result = await scope.getLoadData( scope );
|
|
582
787
|
// Errors, or reaching timeout, causes an exception and is shown to the developer at the end
|
|
@@ -597,6 +802,9 @@ module.exports = {
|
|
|
597
802
|
let result = { error: ``, elem: null };
|
|
598
803
|
|
|
599
804
|
let winner = await Promise.race([
|
|
805
|
+
// Signature page
|
|
806
|
+
scope.page.waitForSelector( `#dasigpage`, { timeout: scope.timeout }),
|
|
807
|
+
// Any other valid page
|
|
600
808
|
scope.page.waitForSelector( `#daMainQuestion`, { timeout: scope.timeout }),
|
|
601
809
|
scope.page.waitForSelector( `#da-retry`, { timeout: scope.timeout }),
|
|
602
810
|
]);
|
|
@@ -606,10 +814,14 @@ module.exports = {
|
|
|
606
814
|
let error_id_elem = await scope.page.$( `#da-retry` );
|
|
607
815
|
if ( error_id_elem ) {
|
|
608
816
|
let error_elem = await scope.page.$( `blockquote` );
|
|
609
|
-
|
|
610
|
-
|
|
817
|
+
if (error_elem !== null) {
|
|
818
|
+
let error_handle = await error_elem.getProperty( `textContent` );
|
|
819
|
+
let system_error_text = await error_handle.jsonValue();
|
|
611
820
|
|
|
612
|
-
|
|
821
|
+
result.error = system_error_text;
|
|
822
|
+
} else {
|
|
823
|
+
result.error = "Docassemble ran into an error on the page, but ALKiln does not know what the error is. If a screenshot was taken, check the screenshot";
|
|
824
|
+
}
|
|
613
825
|
}
|
|
614
826
|
|
|
615
827
|
return result;
|
|
@@ -630,9 +842,40 @@ module.exports = {
|
|
|
630
842
|
}
|
|
631
843
|
}, // Ends scope.setLanguage()
|
|
632
844
|
|
|
633
|
-
normalizeTable: async function ( scope, { var_data,
|
|
634
|
-
/* Return data derived from cucumber variable-setting data
|
|
635
|
-
*
|
|
845
|
+
normalizeTable: async function ( scope, { var_data=null, raw_var_data=null }) {
|
|
846
|
+
/* Return data derived from cucumber variable-setting data, from either an object format
|
|
847
|
+
* (var_data) or an array format (raw_var_data). `var_data` only is used when calling
|
|
848
|
+
* normalizeTable internally.
|
|
849
|
+
*/
|
|
850
|
+
|
|
851
|
+
if (!var_data && ! raw_var_data) {
|
|
852
|
+
let no_data_msg = `ALKiln Internal error: normalizeTable called w/o var_data or raw_var_data:` +
|
|
853
|
+
` open an issue at https://github.com/SuffolkLITLab/ALKiln/issues/new if you see this!`;
|
|
854
|
+
await scope.addToReport( scope, { type: `error`, value: no_data_msg });
|
|
855
|
+
throw Error(no_data_msg);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Always use the raw var data if present; it has the full first row
|
|
859
|
+
if (raw_var_data) {
|
|
860
|
+
var_data = raw_var_data.hashes();
|
|
861
|
+
if (!('var' in var_data[0]) || !('value' in var_data[0])) {
|
|
862
|
+
let raw_array = raw_var_data.raw();
|
|
863
|
+
var_data = [];
|
|
864
|
+
for (let row_raw of raw_array) {
|
|
865
|
+
if (row_raw.length < 2) {
|
|
866
|
+
let bad_table_msg = `Your Story Table definition needs to be changed. The best practice`
|
|
867
|
+
+ ` is to make your table 3 columns wide with a first row in this format: "| var | value | trigger |".`
|
|
868
|
+
+ ` The first row of your table is actually "${ raw_array[0] }"`;
|
|
869
|
+
await scope.addToReport( scope, { type: `error`, value: bad_table_msg})
|
|
870
|
+
throw Error(bad_table_msg);
|
|
871
|
+
} else if (row_raw.length == 2) {
|
|
872
|
+
var_data.push({var: row_raw[0], value: row_raw[1]});
|
|
873
|
+
} else {
|
|
874
|
+
var_data.push({var: row_raw[0], value: row_raw[1], trigger: row_raw[2]});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
636
879
|
|
|
637
880
|
// Support tables with no 'trigger' column
|
|
638
881
|
let supported_table = [];
|
|
@@ -967,6 +1210,9 @@ module.exports = {
|
|
|
967
1210
|
// names.var_names.push( layer_2_decoded );
|
|
968
1211
|
}
|
|
969
1212
|
|
|
1213
|
+
// Detecting valid variable names/characters
|
|
1214
|
+
// https://stackoverflow.com/a/23377268
|
|
1215
|
+
|
|
970
1216
|
return var_names;
|
|
971
1217
|
}, // Ends scope.getPossibleVarNames()
|
|
972
1218
|
|
|
@@ -1233,13 +1479,7 @@ module.exports = {
|
|
|
1233
1479
|
await scope.page.waitForSelector('#dasigcanvas');
|
|
1234
1480
|
}
|
|
1235
1481
|
|
|
1236
|
-
let handle = await scope.
|
|
1237
|
-
let elem = document.querySelector( selector );
|
|
1238
|
-
// `elem` has never been null
|
|
1239
|
-
if ( elem.nextSibling && elem.nextSibling.tag === `label` ) {
|
|
1240
|
-
return elem.nextSibling;
|
|
1241
|
-
} else { return elem; }
|
|
1242
|
-
}, field.selector, field );
|
|
1482
|
+
let handle = await scope.get_handle_from_field( scope, { field: field });
|
|
1243
1483
|
|
|
1244
1484
|
let disabled = await handle.evaluate(( elem )=> {
|
|
1245
1485
|
// da's header covers the top of the section containing the fields which means
|
|
@@ -1260,15 +1500,39 @@ module.exports = {
|
|
|
1260
1500
|
await scope.page.waitForTimeout( 400 );
|
|
1261
1501
|
}
|
|
1262
1502
|
|
|
1263
|
-
let { tag, type } = field;
|
|
1264
1503
|
// We use the last match. We wrote a warning for the user if there were
|
|
1265
1504
|
// multiple matches in the story that the last value will be used. In future,
|
|
1266
1505
|
// Steps will be able to set this data gradually and we don't want the developer
|
|
1267
1506
|
// to get confused thinking that an earlier Step was used instead of a later one.
|
|
1268
1507
|
let row = matching_input_rows[ matching_input_rows.length - 1 ];
|
|
1269
|
-
let
|
|
1508
|
+
let answer = row.value;
|
|
1509
|
+
|
|
1510
|
+
await scope.funnel_the_answer( scope, { handle, answer, field });
|
|
1511
|
+
|
|
1512
|
+
return row;
|
|
1513
|
+
}, // Ends scope.setVariable()
|
|
1514
|
+
|
|
1515
|
+
get_handle_from_field: async function ( scope, { field }) {
|
|
1516
|
+
/** Get the element's puppeteer handle using the field's selector. */
|
|
1517
|
+
|
|
1518
|
+
let handle = await scope.page.evaluateHandle(( selector ) => {
|
|
1519
|
+
let elem = document.querySelector( selector );
|
|
1520
|
+
// If the element has a sibling that's a label, we need to
|
|
1521
|
+
// interact with that label instead.
|
|
1522
|
+
// `elem` has never been null from what we've seen
|
|
1523
|
+
if ( elem.nextSibling && elem.nextSibling.tag === `label` ) {
|
|
1524
|
+
return elem.nextSibling;
|
|
1525
|
+
} else { return elem; }
|
|
1526
|
+
// Pass in the selector as an argument
|
|
1527
|
+
}, field.selector );
|
|
1528
|
+
|
|
1529
|
+
return handle;
|
|
1530
|
+
}, // Ends scope.get_handle_from_field()
|
|
1531
|
+
|
|
1532
|
+
get_custom_datatype: async function( scope, { handle }) {
|
|
1533
|
+
/** If this is part of a custom datatype, returns an element,
|
|
1534
|
+
* otherwise returns null. */
|
|
1270
1535
|
|
|
1271
|
-
// Detect a custom type and get the element of that custom type if it exists
|
|
1272
1536
|
let custom_datatype = await handle.evaluate(( elem )=> {
|
|
1273
1537
|
// If the element is in a form group**, then extract the form group's
|
|
1274
1538
|
// datatype value.
|
|
@@ -1287,28 +1551,52 @@ module.exports = {
|
|
|
1287
1551
|
return custom_datatype;
|
|
1288
1552
|
});
|
|
1289
1553
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1554
|
+
return custom_datatype;
|
|
1555
|
+
}, // Ends scope.is_custom_datatype()
|
|
1556
|
+
|
|
1557
|
+
funnel_the_answer: async function( scope, { handle, field, answer }) {
|
|
1558
|
+
/** Use the tag or custom datatype to funnel the answer to the right
|
|
1559
|
+
* field. Pages without a handle should pass in `null` for the
|
|
1560
|
+
* handle.
|
|
1561
|
+
*/
|
|
1562
|
+
|
|
1563
|
+
let custom_datatype = null;
|
|
1564
|
+
// Pages with elements like `canvas` for signatures won't have a handle
|
|
1565
|
+
// to pass in. They should pass in `null` for the handle and shouldn't
|
|
1566
|
+
// need a handle in order to function.
|
|
1567
|
+
if ( handle !== null ) {
|
|
1568
|
+
// Get the element of that custom type if it exists
|
|
1569
|
+
custom_datatype = await scope.get_custom_datatype( scope, { handle });
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// If there is a custom datatype function available, use it to set the field
|
|
1573
|
+
if ( scope.setCustomDatatype[ custom_datatype ] !== undefined ) {
|
|
1295
1574
|
// Set da package custom fields. TODO: find `custom_handle` in the setter?
|
|
1296
1575
|
let custom_handle = await handle.evaluateHandle(( elem )=> { return elem.closest( `.da-form-group` ) });
|
|
1297
|
-
await scope.setCustomDatatype[ custom_datatype ]( scope, { handle: custom_handle,
|
|
1576
|
+
await scope.setCustomDatatype[ custom_datatype ]( scope, { handle: custom_handle, answer });
|
|
1577
|
+
} else {
|
|
1578
|
+
// If no custom datatype, set a regular field
|
|
1579
|
+
await scope.enter_answer[ field.tag ]( scope, { handle, answer });
|
|
1298
1580
|
}
|
|
1299
1581
|
|
|
1300
|
-
|
|
1301
|
-
}, // Ends scope.setVariable()
|
|
1582
|
+
}, // Ends scope.funnel_the_answer()
|
|
1302
1583
|
|
|
1303
1584
|
// Handle setting values for da custom datatypes. E.g. `da-field-container-datatype-BirthDate`
|
|
1304
1585
|
// TODO: Make this more easily extensible.
|
|
1305
|
-
// TODO: Just put this in `scope.
|
|
1586
|
+
// TODO: Just put this in `scope.enter_answer`? Find the custom handle in the setter itself.
|
|
1306
1587
|
setCustomDatatype: {
|
|
1307
|
-
|
|
1588
|
+
al_date: async function ( scope, { handle, answer, is_past }) {
|
|
1308
1589
|
/* Given a date of the format 'mm/dd/yyyy' and the handle of the
|
|
1309
|
-
* custom field container, sets
|
|
1590
|
+
* custom field container, sets an AssemblyLine three-part date's
|
|
1310
1591
|
* field values. */
|
|
1311
|
-
|
|
1592
|
+
|
|
1593
|
+
// If it doesn't have the right format
|
|
1594
|
+
if ( answer === null || !answer.includes(`/`) ) {
|
|
1595
|
+
// Do nothing
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
let date_parts = answer.split(`/`);
|
|
1312
1600
|
let fields = await handle.$$(`select, input:not([type="hidden"])`);
|
|
1313
1601
|
for ( let field_i = 0; field_i < fields.length; field_i++ ) {
|
|
1314
1602
|
let field = fields[ field_i ];
|
|
@@ -1321,55 +1609,76 @@ module.exports = {
|
|
|
1321
1609
|
// Set the month
|
|
1322
1610
|
await field.select( date_parts[ 0 ] );
|
|
1323
1611
|
} else {
|
|
1324
|
-
// Set the day
|
|
1612
|
+
// Set the day or year
|
|
1325
1613
|
await scope.setText( scope, {
|
|
1326
1614
|
handle: field,
|
|
1327
|
-
|
|
1615
|
+
answer: date_parts[ field_i ]
|
|
1328
1616
|
});
|
|
1329
1617
|
}
|
|
1330
1618
|
|
|
1331
1619
|
} // For each field
|
|
1332
1620
|
|
|
1333
1621
|
await scope.afterStep( scope );
|
|
1334
|
-
}, // Ends
|
|
1622
|
+
}, // Ends scope.setCustomDatatype.al_date()
|
|
1623
|
+
|
|
1624
|
+
BirthDate: async function ( scope, { handle, answer }) {
|
|
1625
|
+
/** Given a date of the format 'mm/dd/yyyy' and the handle of the
|
|
1626
|
+
* custom field container, sets the ALToolbox custom birthdate
|
|
1627
|
+
* field values. */
|
|
1628
|
+
|
|
1629
|
+
await scope.setCustomDatatype.al_date( scope, { handle, answer })
|
|
1630
|
+
}, // Ends scope.setCustomDatatype.BirthDate()
|
|
1335
1631
|
|
|
1632
|
+
ThreePartsDate: async function ( scope, { handle, answer }) {
|
|
1633
|
+
/** Given a date of the format 'mm/dd/yyyy' and the handle of the
|
|
1634
|
+
* custom field container, sets the ALToolbox custom ThreePartsDate
|
|
1635
|
+
* field values. */
|
|
1636
|
+
await scope.setCustomDatatype.al_date( scope, { handle, answer })
|
|
1637
|
+
}, // Ends scope.setCustomDatatype.ThreePartsDate()
|
|
1336
1638
|
|
|
1337
1639
|
}, // ends setCustomDatatype {}
|
|
1338
1640
|
|
|
1339
|
-
|
|
1340
|
-
button: async function ( scope, { handle,
|
|
1341
|
-
textarea: async function ( scope, { handle,
|
|
1342
|
-
await scope.setText( scope, { handle,
|
|
1641
|
+
enter_answer: {
|
|
1642
|
+
button: async function ( scope, { handle, answer }) { await scope.tapElement( scope, handle ); },
|
|
1643
|
+
textarea: async function ( scope, { handle, answer }) {
|
|
1644
|
+
await scope.setText( scope, { handle, answer });
|
|
1343
1645
|
await scope.afterStep(scope); // No showifs for this one?
|
|
1344
1646
|
},
|
|
1345
|
-
select: async function ( scope, { handle,
|
|
1647
|
+
select: async function ( scope, { handle, answer }) {
|
|
1648
|
+
// Note: I don't remember what's really going on in here.
|
|
1649
|
+
// I will note that values of `select` elements are strings,
|
|
1650
|
+
// not variables, so we don't need to check for variable names.
|
|
1651
|
+
|
|
1346
1652
|
// A dropdown's option value can be one of two things
|
|
1347
1653
|
// Try to find the element using the first value
|
|
1348
1654
|
|
|
1349
|
-
// TODO: Can we somehow pass in a `
|
|
1655
|
+
// TODO: Can we somehow pass in a `answer` that is encoded correctly since
|
|
1350
1656
|
// we're getting the fields from the DOM to begin with? We'll probably
|
|
1351
1657
|
// need another prop because we need the `option` human value to
|
|
1352
1658
|
// match the story table
|
|
1353
1659
|
|
|
1354
1660
|
// TODO: Change this to search in the actual handle instead of just $
|
|
1355
|
-
|
|
1661
|
+
// There could be multiple fields on the page with the same option
|
|
1662
|
+
// values.
|
|
1663
|
+
// let option = await handle.$(`option[value="${ answer }"]`);
|
|
1664
|
+
let option = await scope.page.$(`option[value="${ answer }"]`);
|
|
1356
1665
|
if ( option ) {
|
|
1357
|
-
await handle.select(
|
|
1666
|
+
await handle.select( answer ); // And use that value to set it
|
|
1358
1667
|
|
|
1359
1668
|
// If that literal value isn't on the page, it should be a base64 encoded value
|
|
1360
|
-
// TODO: Can these be double encoded?
|
|
1669
|
+
// TODO: Can these be double encoded? Can they be encoded like objects?
|
|
1361
1670
|
} else {
|
|
1362
|
-
let base64_name = await scope.toBase64( scope, { utf8_str:
|
|
1671
|
+
let base64_name = await scope.toBase64( scope, { utf8_str: answer });
|
|
1363
1672
|
await handle.select( base64_name );
|
|
1364
1673
|
}
|
|
1365
1674
|
|
|
1366
1675
|
await scope.afterStep(scope, { waitForShowIf: true });
|
|
1367
1676
|
},
|
|
1368
|
-
canvas: async function ( scope, { handle,
|
|
1677
|
+
canvas: async function ( scope, { handle, answer }) {
|
|
1369
1678
|
await scope.sign( scope );
|
|
1370
1679
|
await scope.afterStep( scope );
|
|
1371
1680
|
},
|
|
1372
|
-
input: async function ( scope, { handle,
|
|
1681
|
+
input: async function ( scope, { handle, answer }) {
|
|
1373
1682
|
/* Set value of some `input` element to the given value. */
|
|
1374
1683
|
|
|
1375
1684
|
let type = await handle.evaluate( elem => { return elem.getAttribute('type'); });
|
|
@@ -1378,51 +1687,63 @@ module.exports = {
|
|
|
1378
1687
|
let [label] = await handle.$x(`following-sibling::label`);
|
|
1379
1688
|
|
|
1380
1689
|
if ( session_vars.get_debug() ) {
|
|
1381
|
-
if (
|
|
1690
|
+
if ( answer.toLowerCase() === 'false' ) { await label.evaluate( elem => { elem.style.background = 'tomato'; }); }
|
|
1382
1691
|
else { await label.evaluate( elem => { elem.style.background = 'teal'; }); }
|
|
1383
1692
|
let name = await label.evaluate( elem => { return elem.getAttribute('for'); });
|
|
1384
1693
|
}
|
|
1385
1694
|
|
|
1386
1695
|
if ( type === `radio` ) { await label.evaluate( elem => { return elem.click(); }); }
|
|
1387
|
-
else { await scope.setCheckbox( scope, { label,
|
|
1696
|
+
else { await scope.setCheckbox( scope, { label, answer }); }
|
|
1388
1697
|
|
|
1389
1698
|
await scope.afterStep(scope, { waitForShowIf: true });
|
|
1390
1699
|
|
|
1391
1700
|
} else if ( type === `file` ) {
|
|
1392
|
-
await scope.uploadFiles( scope, { handle,
|
|
1701
|
+
await scope.uploadFiles( scope, { handle, answer });
|
|
1393
1702
|
await scope.afterStep(scope, { waitForShowIf: true });
|
|
1394
1703
|
|
|
1395
1704
|
} else if (type == `date`) {
|
|
1396
|
-
let
|
|
1705
|
+
let answer_date = answer;
|
|
1397
1706
|
// If the date is specified in the format "today" or "today + 2" or "today-1"
|
|
1398
1707
|
// it will need some pre-processing.
|
|
1399
|
-
if (
|
|
1708
|
+
if (answer.indexOf(`today`) !== -1) {
|
|
1400
1709
|
// Find out how many days to add/subtract
|
|
1401
|
-
let date_delta =
|
|
1710
|
+
let date_delta = answer.replace(/\s/g, '');
|
|
1402
1711
|
date_delta = date_delta.match(/today([+-]\d+)?/)[1];
|
|
1403
1712
|
date_delta = date_delta == undefined ? 0 : date_delta;
|
|
1404
1713
|
date_delta = parseInt(date_delta);
|
|
1405
1714
|
// Get today's date
|
|
1406
|
-
|
|
1715
|
+
answer_date = new Date();
|
|
1407
1716
|
// Add/subtract required number of days
|
|
1408
|
-
|
|
1717
|
+
answer_date.setDate(answer_date.getDate() + date_delta);
|
|
1409
1718
|
// Convert to the docassemble date field format: mm/dd/yyyy
|
|
1410
1719
|
// Reference: https://gomakethings.com/setting-a-date-input-to-todays-date-with-vanilla-js/
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1720
|
+
answer_date = ((answer_date.getMonth() + 1).toString().padStart(2, 0) + '/' +
|
|
1721
|
+
answer_date.getDate().toString().padStart(2, 0) + '/' +
|
|
1722
|
+
answer_date.getFullYear().toString());
|
|
1414
1723
|
}
|
|
1415
|
-
await scope.setText( scope, { handle,
|
|
1724
|
+
await scope.setText( scope, { handle, answer: answer_date });
|
|
1416
1725
|
await scope.afterStep(scope, { waitForShowIf: true });
|
|
1417
1726
|
} else {
|
|
1418
|
-
await scope.setText( scope, { handle,
|
|
1727
|
+
await scope.setText( scope, { handle, answer });
|
|
1419
1728
|
await scope.afterStep(scope, { waitForShowIf: true });
|
|
1420
1729
|
}
|
|
1421
|
-
}, // Ends scope.
|
|
1422
|
-
|
|
1730
|
+
}, // Ends scope.enter_answer.input()
|
|
1731
|
+
hidden: async function ( scope, { handle, answer }) {
|
|
1732
|
+
// Do nothing. The only way the code should get here is if
|
|
1733
|
+
// this wasn't a custom datatype we can handle.
|
|
1734
|
+
// If we were to write a message, I don't know what we can
|
|
1735
|
+
// write - this may be a different custom datatype or this
|
|
1736
|
+
// could be a special da field that we shouldn't worry the
|
|
1737
|
+
// dev about at all.
|
|
1738
|
+
return;
|
|
1739
|
+
},
|
|
1423
1740
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1741
|
+
}, // ends scope.enter_answer
|
|
1742
|
+
|
|
1743
|
+
uploadFiles: async function ( scope, { handle, answer }) {
|
|
1744
|
+
/* Try to upload one or more files to a file-type input field.
|
|
1745
|
+
* Does not submit, so no danger of timeout with server reload.
|
|
1746
|
+
*/
|
|
1426
1747
|
|
|
1427
1748
|
// Tests could be in one of two places. Figure out which directory they're in
|
|
1428
1749
|
// Makes an absolute path from the directory in which `npm run` was used plus our internal folders
|
|
@@ -1454,7 +1775,7 @@ module.exports = {
|
|
|
1454
1775
|
}
|
|
1455
1776
|
|
|
1456
1777
|
// Allow a typo where someone forgot to include a space after a comma
|
|
1457
|
-
let file_names =
|
|
1778
|
+
let file_names = answer.split(`,`);
|
|
1458
1779
|
|
|
1459
1780
|
// If a file exist, add it to a list of files to be uploaded. If it doesn't, add a warning.
|
|
1460
1781
|
let paths = [];
|
|
@@ -1476,8 +1797,8 @@ module.exports = {
|
|
|
1476
1797
|
}
|
|
1477
1798
|
|
|
1478
1799
|
if ( paths.length === 0 ) {
|
|
1479
|
-
await scope.addToReport( scope, { type: `error`, value: `Could not find "${
|
|
1480
|
-
throw ReferenceError(`Could not find "${
|
|
1800
|
+
await scope.addToReport( scope, { type: `error`, value: `Could not find "${answer}".` });
|
|
1801
|
+
throw ReferenceError(`Could not find "${answer}".`)
|
|
1481
1802
|
}
|
|
1482
1803
|
// Prepare to wait for upload to complete
|
|
1483
1804
|
let name = await handle.evaluate( elem => { return elem.getAttribute(`name`); });
|
|
@@ -1502,20 +1823,20 @@ module.exports = {
|
|
|
1502
1823
|
|
|
1503
1824
|
}, // Ends scope.uploadFiles()
|
|
1504
1825
|
|
|
1505
|
-
setCheckbox: async function ( scope, { label,
|
|
1826
|
+
setCheckbox: async function ( scope, { label, answer }) {
|
|
1506
1827
|
// Depending on the current value/status of a checkbox and the desired
|
|
1507
1828
|
// value to set, either taps the checkbox or leaves it alone.
|
|
1508
1829
|
let status = await label.evaluate( elem => { return elem.getAttribute('aria-checked'); });
|
|
1509
|
-
if (
|
|
1830
|
+
if ( answer.toLowerCase() !== status ) {
|
|
1510
1831
|
await label.evaluate( elem => { return elem.click(); });
|
|
1511
1832
|
}
|
|
1512
1833
|
}, // Ends scope.setCheckbox()
|
|
1513
1834
|
|
|
1514
|
-
setText: async function ( scope, { handle,
|
|
1835
|
+
setText: async function ( scope, { handle, answer }) {
|
|
1515
1836
|
// Set text in some kind of field (input text, input date, textarea, etc.)
|
|
1516
1837
|
await handle.evaluate( el => { el.value = '' });
|
|
1517
1838
|
await handle.focus();
|
|
1518
|
-
await handle.type(
|
|
1839
|
+
await handle.type( answer );
|
|
1519
1840
|
setTimeout(async () => {
|
|
1520
1841
|
}, 500)
|
|
1521
1842
|
},
|
|
@@ -1562,7 +1883,7 @@ module.exports = {
|
|
|
1562
1883
|
}, // Ends scope.examinePageID()
|
|
1563
1884
|
|
|
1564
1885
|
// TODO: Rename var_data to input_data?
|
|
1565
|
-
// Note: Totally different from `
|
|
1886
|
+
// Note: Totally different from `enter_answer` (which is singular)
|
|
1566
1887
|
// TODO: If a step triggers this and is not able to set a value, should it error?
|
|
1567
1888
|
// TODO: Rename to setFieldsOfOnePage? setPageFields?
|
|
1568
1889
|
setFields: async function ( scope, {
|
|
@@ -1691,7 +2012,8 @@ module.exports = {
|
|
|
1691
2012
|
}
|
|
1692
2013
|
}
|
|
1693
2014
|
if ( num_unused_rows > 0 ) {
|
|
1694
|
-
let
|
|
2015
|
+
let { id } = await scope.examinePageID( scope, 'none to match' );
|
|
2016
|
+
let msg = `Missing variable or variables on page "${id}"`;
|
|
1695
2017
|
await scope.addToReport(scope, { type: `error`, value: msg });
|
|
1696
2018
|
expect( num_unused_rows, msg ).to.equal( 0 );
|
|
1697
2019
|
}
|
|
@@ -1837,6 +2159,20 @@ module.exports = {
|
|
|
1837
2159
|
}
|
|
1838
2160
|
}, // Ends scope.getThereIsAnotherValue()
|
|
1839
2161
|
|
|
2162
|
+
// throw_if_no_interview_content()?
|
|
2163
|
+
has_interview_content: async function ( scope ) {
|
|
2164
|
+
/** Return false if ther's no element indicating a da interview question,
|
|
2165
|
+
* like a signature page, other fields, or interview text. Assumes that page has already
|
|
2166
|
+
* finished navigating. */
|
|
2167
|
+
let elem_da_question = await scope.page.$( `#daMainQuestion` );
|
|
2168
|
+
let elem_da_signature = await scope.page.$( `#dasigpage` );
|
|
2169
|
+
if ( elem_da_question === null && elem_da_signature === null ) {
|
|
2170
|
+
return false;
|
|
2171
|
+
} else {
|
|
2172
|
+
return true;
|
|
2173
|
+
}
|
|
2174
|
+
}, // Ends scope.has_interview_content()
|
|
2175
|
+
|
|
1840
2176
|
waitUntilContinued: async function ( scope, { url = '', id = '' }) {
|
|
1841
2177
|
/** Given a url, attempt to continue until the page url changes or an error
|
|
1842
2178
|
is found. Return the error object no matter what. */
|
|
@@ -1960,18 +2296,20 @@ module.exports = {
|
|
|
1960
2296
|
let actual_url = await prop_obj.jsonValue();
|
|
1961
2297
|
|
|
1962
2298
|
let linkPage = await scope.browser.newPage();
|
|
2299
|
+
// This should not check for a server reload timeout error - it could be going anywhere.
|
|
1963
2300
|
let response = await linkPage.goto( actual_url, { waitUntil: 'domcontentloaded' });
|
|
1964
2301
|
|
|
1965
2302
|
let msg = `The "${ linkText }" link is broken.`;
|
|
1966
2303
|
if ( !response.ok() ) { await scope.addToReport( scope, { type: `error`, value: msg }); }
|
|
1967
2304
|
expect( response.ok(), msg ).to.be.true;
|
|
1968
2305
|
|
|
2306
|
+
// This may cause problems if the linked page is the interview page
|
|
1969
2307
|
linkPage.close()
|
|
1970
2308
|
|
|
1971
2309
|
await scope.afterStep(scope);
|
|
1972
2310
|
}, // Ends link_works()
|
|
1973
2311
|
|
|
1974
|
-
set_regular_var: async ( scope, var_name,
|
|
2312
|
+
set_regular_var: async ( scope, var_name, answer ) => {
|
|
1975
2313
|
/** Set a non-story table variable (with or without a choice associated with it) to a value. Set:
|
|
1976
2314
|
* - Buttons associated with variables
|
|
1977
2315
|
* - Dropdowns
|
|
@@ -1982,23 +2320,23 @@ module.exports = {
|
|
|
1982
2320
|
*/
|
|
1983
2321
|
// Let kiln know not to test against trigger variable values, even for proxy vars
|
|
1984
2322
|
let var_data = await scope.normalizeTable( scope, {
|
|
1985
|
-
var_data: [{ var: var_name, value:
|
|
2323
|
+
var_data: [{ var: var_name, value: answer, trigger: scope.trigger_not_needed_flag }],
|
|
1986
2324
|
});
|
|
1987
2325
|
// Don't continue if the variable doesn't cause navigation itself. Also,
|
|
1988
2326
|
// if this variable cannot be set on this page, trigger an error
|
|
1989
2327
|
await scope.setFields( scope, { var_data, ensure_navigation: false, ensure_all_vars_used: true });
|
|
1990
2328
|
}, // Ends set_regular_var()
|
|
1991
2329
|
|
|
1992
|
-
set_secret_var: async ( scope, var_name,
|
|
2330
|
+
set_secret_var: async ( scope, var_name, answer_env_name ) => {
|
|
1993
2331
|
/** Sets a non-story table variable to a "secret" value (i.e. an env var) */
|
|
1994
2332
|
// Prevent pictures of a screen with a secret.
|
|
1995
2333
|
scope.disable_error_screenshot = true;
|
|
1996
2334
|
let var_data = await scope.normalizeTable( scope, {
|
|
1997
2335
|
var_data: [{
|
|
1998
2336
|
var: var_name,
|
|
1999
|
-
value:
|
|
2337
|
+
value: answer_env_name,
|
|
2000
2338
|
trigger: scope.trigger_not_needed_flag,
|
|
2001
|
-
flags: {secret_name:
|
|
2339
|
+
flags: {secret_name: answer_env_name}
|
|
2002
2340
|
}],
|
|
2003
2341
|
});
|
|
2004
2342
|
// Don't continue if the variable doesn't cause navigation itself. Also,
|
|
@@ -2014,7 +2352,7 @@ module.exports = {
|
|
|
2014
2352
|
if ( !link ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
|
|
2015
2353
|
expect( link, msg ).to.exist;
|
|
2016
2354
|
|
|
2017
|
-
await
|
|
2355
|
+
await scope.tapElement( scope, link );
|
|
2018
2356
|
|
|
2019
2357
|
await scope.afterStep(scope, {waitForShowIf: true});
|
|
2020
2358
|
}, // Ends tap_term()
|
|
@@ -2042,6 +2380,7 @@ module.exports = {
|
|
|
2042
2380
|
expect( elem, msg ).to.exist;
|
|
2043
2381
|
|
|
2044
2382
|
scope.toDownload = filename;
|
|
2383
|
+
// Should this be using `scope.tapElement()`?
|
|
2045
2384
|
await elem.evaluate( elem => { return elem.click(); });
|
|
2046
2385
|
await scope.detectDownloadComplete( scope );
|
|
2047
2386
|
}, // Ends download()
|
|
@@ -2134,8 +2473,26 @@ module.exports = {
|
|
|
2134
2473
|
|
|
2135
2474
|
// Go to the sign in page
|
|
2136
2475
|
let login_url = `${ session_vars.get_da_server_url() }/user/sign-in`;
|
|
2137
|
-
|
|
2138
|
-
|
|
2476
|
+
|
|
2477
|
+
// TODO: implement and use scope.handle_possible_timeout()
|
|
2478
|
+
try {
|
|
2479
|
+
// puppeteer will ensure proper timeout.
|
|
2480
|
+
await scope.page.goto( login_url, { waitUntil: `domcontentloaded`, timeout: scope.timeout });
|
|
2481
|
+
await scope.page.waitForSelector( `.dabody` );
|
|
2482
|
+
|
|
2483
|
+
} catch ( error ) {
|
|
2484
|
+
|
|
2485
|
+
let err_msg = `Error occurred when ALKiln tried to go to "${ login_url }".`
|
|
2486
|
+
if ( error.name === `TimeoutError` ) {
|
|
2487
|
+
let non_reload_report_msg = `It took too long to load "${ login_url }"`;
|
|
2488
|
+
await scope.handle_page_timeout_error( scope, { non_reload_report_msg, error });
|
|
2489
|
+
} else {
|
|
2490
|
+
// Throw any non-timeout error
|
|
2491
|
+
scope.addToReport( scope, { type: `error`, value: err_msg });
|
|
2492
|
+
throw error;
|
|
2493
|
+
} // ends if error is timeout error
|
|
2494
|
+
|
|
2495
|
+
} // ends try/catch
|
|
2139
2496
|
|
|
2140
2497
|
let email = process.env[ email_secret_name ];
|
|
2141
2498
|
let password = process.env[ password_secret_name ];
|
|
@@ -2151,13 +2508,15 @@ module.exports = {
|
|
|
2151
2508
|
if ( password === undefined ) {
|
|
2152
2509
|
await scope.addToReport( scope, { type: `error`, value: password_msg })
|
|
2153
2510
|
}
|
|
2511
|
+
|
|
2154
2512
|
expect( email, email_msg ).to.not.equal( undefined );
|
|
2155
2513
|
expect( password, password_msg ).to.not.equal( undefined );
|
|
2156
2514
|
|
|
2157
2515
|
// Try to log in
|
|
2158
2516
|
await scope.page.type( `#email`, email );
|
|
2159
2517
|
await scope.page.type( `#password`, password );
|
|
2160
|
-
await scope.page.$
|
|
2518
|
+
let elem = await scope.page.$( `button[name="submit"]` );
|
|
2519
|
+
await scope.tapElement( scope, elem );
|
|
2161
2520
|
|
|
2162
2521
|
// Wait to see the success message.
|
|
2163
2522
|
// This is a bit of an assumption and needs success and failure testing
|
|
@@ -2170,10 +2529,234 @@ module.exports = {
|
|
|
2170
2529
|
});
|
|
2171
2530
|
|
|
2172
2531
|
await scope.afterStep(scope, {waitForShowIf: false});
|
|
2173
|
-
}, // Ends sign_in
|
|
2532
|
+
}, // Ends scope.steps.sign_in()
|
|
2533
|
+
|
|
2534
|
+
set_random_page_vars: async ( scope ) => {
|
|
2535
|
+
/** Answer inputs randomly. */
|
|
2536
|
+
|
|
2537
|
+
let html = await scope.page.content();
|
|
2538
|
+
// These are the fields on the current page. Their handles should all exist.
|
|
2539
|
+
let fields = await scope.getAllFields( scope, { html: html });
|
|
2540
|
+
let buttons = [];
|
|
2541
|
+
|
|
2542
|
+
// For every field on the page
|
|
2543
|
+
for ( let field of fields ) {
|
|
2544
|
+
// fields[n].tag is HTML tag of node, as a string
|
|
2545
|
+
|
|
2546
|
+
// If the field is a button, save it in a list of buttons that we'll deal with later
|
|
2547
|
+
if ( field.tag === `button` ) {
|
|
2548
|
+
buttons.push( field );
|
|
2549
|
+
} else {
|
|
2550
|
+
// Try to give a random answer for that element tag
|
|
2551
|
+
await scope.set_random_input_for[ field.tag ]( scope, { field: field });
|
|
2552
|
+
process.stdout.write(`\x1b[36m${ '*' }\x1b[0m`); // assumes var was set if no error occurred
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
} // End for all page fields
|
|
2556
|
+
|
|
2557
|
+
return buttons;
|
|
2558
|
+
|
|
2559
|
+
}, // Ends scope.steps.set_random_page_vars()
|
|
2174
2560
|
|
|
2175
2561
|
}, // ends scope.steps
|
|
2176
2562
|
|
|
2563
|
+
set_random_input_for: {
|
|
2564
|
+
a: async function (scope, { fields }) {
|
|
2565
|
+
// We don't do anything with links for random input tests
|
|
2566
|
+
},
|
|
2567
|
+
buttons: async function (scope, { fields }) {
|
|
2568
|
+
// Get a random button
|
|
2569
|
+
let field = faker.helpers.arrayElement( fields );
|
|
2570
|
+
let handle = await scope.get_handle_from_field( scope, { field: field });
|
|
2571
|
+
// Send it to be tapped
|
|
2572
|
+
await scope.funnel_the_answer( scope, { handle, field, answer: `doesn't matter` });
|
|
2573
|
+
},
|
|
2574
|
+
textarea: async function (scope, { field }) {
|
|
2575
|
+
// Get random paragraph of text
|
|
2576
|
+
let answer = faker.lorem.paragraph();
|
|
2577
|
+
// Get the element's puppeteer handler
|
|
2578
|
+
let handle = await scope.get_handle_from_field( scope, { field: field });
|
|
2579
|
+
// Type the random text into the textarea
|
|
2580
|
+
await scope.funnel_the_answer( scope, { handle, field, answer: answer });
|
|
2581
|
+
},
|
|
2582
|
+
select: async function (scope, { field }) {
|
|
2583
|
+
// Get the values of all the options of the field
|
|
2584
|
+
let handle = await scope.get_handle_from_field( scope, { field: field });
|
|
2585
|
+
// Get the values of all the choices
|
|
2586
|
+
let answer_choices = await handle.evaluate(function ( elem ) {
|
|
2587
|
+
let values = Array.from(elem.querySelectorAll(`option`)).map(element=>element.value);
|
|
2588
|
+
return values;
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
// Remove the "no selection" option from the list ('')
|
|
2592
|
+
// TODO: blue sky - for optional fields allow no selection
|
|
2593
|
+
if ( answer_choices[0] === `` ) {
|
|
2594
|
+
answer_choices.shift();
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// Select randomly between them
|
|
2598
|
+
let answer = faker.helpers.arrayElement( answer_choices );
|
|
2599
|
+
|
|
2600
|
+
// Set that value
|
|
2601
|
+
await scope.funnel_the_answer( scope, { handle, field, answer: answer });
|
|
2602
|
+
|
|
2603
|
+
},
|
|
2604
|
+
canvas: async function (scope, { field }) {
|
|
2605
|
+
// WARNING: Do not test this in headless mode.
|
|
2606
|
+
// If we do this in headless mode, the test will fail. It signs, but
|
|
2607
|
+
// can't continue. Not sure why.
|
|
2608
|
+
await scope.funnel_the_answer( scope, { field, handle: null, answer: `doesn't matter` });
|
|
2609
|
+
},
|
|
2610
|
+
input: async function (scope, { field }) {
|
|
2611
|
+
/** Handles various `type`s of input element fields, except:
|
|
2612
|
+
*
|
|
2613
|
+
* Does not handle "file" type
|
|
2614
|
+
* Does not handle custom data values yet, like BirthDate, because they have
|
|
2615
|
+
* `type="hidden"` which also matches other special da fields on the
|
|
2616
|
+
* page that we SHOULDN'T interact with.
|
|
2617
|
+
* Does not handle range, which is not a `type`, but it is an input element
|
|
2618
|
+
*/
|
|
2619
|
+
|
|
2620
|
+
// Use the type instead of the tag to get get the answer for
|
|
2621
|
+
// an input field
|
|
2622
|
+
let type = field.type;
|
|
2623
|
+
|
|
2624
|
+
// Custom datatype question: Do all custom datatypes hide their
|
|
2625
|
+
// original field? Probably not...
|
|
2626
|
+
// TODO: Move custom datatype checking to the caller of this
|
|
2627
|
+
// function somehow - in future, not all custom datatypes will
|
|
2628
|
+
// be inputs _and_ this won't handle, for example, a `select` field
|
|
2629
|
+
// that is part of a custom datatype with a hidden field - this
|
|
2630
|
+
// doesn't take care of that situation.
|
|
2631
|
+
|
|
2632
|
+
let handle = await scope.get_handle_from_field( scope, { field: field });
|
|
2633
|
+
|
|
2634
|
+
// If this is a custom datatype
|
|
2635
|
+
let custom_datatype = await scope.get_custom_datatype( scope, { handle });
|
|
2636
|
+
let is_custom_datatype = scope.get_random_input_for[ custom_datatype ] !== undefined;
|
|
2637
|
+
if ( is_custom_datatype ) {
|
|
2638
|
+
|
|
2639
|
+
// And the custom datatype has a hidden field (which will be the original field)
|
|
2640
|
+
let hidden_field = await scope.get_custom_datatype_hidden_field( scope, { handle });
|
|
2641
|
+
if ( hidden_field !== null ) {
|
|
2642
|
+
// only set the value once - just for the original field
|
|
2643
|
+
if ( type === `hidden` ) {
|
|
2644
|
+
|
|
2645
|
+
// set the value
|
|
2646
|
+
let answer = await scope.get_random_input_for[ custom_datatype ]( scope );
|
|
2647
|
+
await scope.funnel_the_answer( scope, { handle, field, answer });
|
|
2648
|
+
|
|
2649
|
+
}
|
|
2650
|
+
// Otherwise ignore the non-original field
|
|
2651
|
+
|
|
2652
|
+
// Untested: If no hidden field exists, just try to set the custom
|
|
2653
|
+
// datatype field
|
|
2654
|
+
} else {
|
|
2655
|
+
// set the value
|
|
2656
|
+
let answer = await scope.get_random_input_for[ custom_datatype ]( scope );
|
|
2657
|
+
await scope.funnel_the_answer( scope, { handle, field, answer });
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// If this is not a custom datatype
|
|
2661
|
+
} else {
|
|
2662
|
+
|
|
2663
|
+
// `file` type inputs come next to an input with a type of ''.
|
|
2664
|
+
// Maybe others do too.
|
|
2665
|
+
if ( type !== `` && type !== `hidden` ) {
|
|
2666
|
+
// Field types that we can't yet handle, but plan to in future
|
|
2667
|
+
if ( type === `file` ) {
|
|
2668
|
+
await scope.addToReport( scope, {
|
|
2669
|
+
type: `warning`,
|
|
2670
|
+
value: `Sorry, ALKiln's random input tests cannot yet handle "${ type }" fields.`
|
|
2671
|
+
});
|
|
2672
|
+
} else {
|
|
2673
|
+
let answer = await scope.get_random_input_for[ type ]( scope );
|
|
2674
|
+
await scope.funnel_the_answer( scope, { handle, field, answer });
|
|
2675
|
+
} // ends if field type is one we cover
|
|
2676
|
+
} // ends if field type exists
|
|
2677
|
+
|
|
2678
|
+
} // ends if is custom datatype
|
|
2679
|
+
|
|
2680
|
+
}, // ends scope.set_random_input_for.input
|
|
2681
|
+
}, // ends scope.set_random_input_for
|
|
2682
|
+
|
|
2683
|
+
get_custom_datatype_hidden_field: async function (scope, { handle }) {
|
|
2684
|
+
/** If there's a hidden field in a custom datatype, returns that
|
|
2685
|
+
* DOM node, otherwise returns null. */
|
|
2686
|
+
|
|
2687
|
+
let hidden_field = await handle.evaluate(( elem )=> {
|
|
2688
|
+
// If the element is in a form group**, then extract the form group's
|
|
2689
|
+
// datatype value.
|
|
2690
|
+
// ** For now the only custom datatype we have is in a form group. In
|
|
2691
|
+
// future we should look into what happens with a `fieldset` for
|
|
2692
|
+
// `choices:`, etc.
|
|
2693
|
+
let custom_datatype = null, matches = null;
|
|
2694
|
+
let group = elem.closest( `.da-form-group` );
|
|
2695
|
+
// Returns an element or null
|
|
2696
|
+
let hidden_child = group.querySelector( `[type="hidden"]` );
|
|
2697
|
+
|
|
2698
|
+
return hidden_child;
|
|
2699
|
+
});
|
|
2700
|
+
|
|
2701
|
+
return hidden_field;
|
|
2702
|
+
}, // Ends scope.get_custom_datatype_hidden_field()
|
|
2703
|
+
|
|
2704
|
+
get_random_input_for: {
|
|
2705
|
+
checkbox: async function ( scope ) {
|
|
2706
|
+
return `${ Math.random() > .5 }`;
|
|
2707
|
+
},
|
|
2708
|
+
radio: async function ( scope ) {
|
|
2709
|
+
return `${ Math.random() > .5 }`;
|
|
2710
|
+
},
|
|
2711
|
+
text: async function ( scope ) {
|
|
2712
|
+
return faker.random.word();
|
|
2713
|
+
},
|
|
2714
|
+
number: async function ( scope ) {
|
|
2715
|
+
let answer = faker.helpers.arrayElement( [`1`, `2`, `3`], 1 );
|
|
2716
|
+
return answer;
|
|
2717
|
+
},
|
|
2718
|
+
date: async function ( scope ) {
|
|
2719
|
+
let past = faker.date.past(25);
|
|
2720
|
+
let future = faker.date.future(1);
|
|
2721
|
+
|
|
2722
|
+
let date = null;
|
|
2723
|
+
if ( Math.random() > .5 ) {
|
|
2724
|
+
date = past;
|
|
2725
|
+
} else {
|
|
2726
|
+
date = future;
|
|
2727
|
+
}
|
|
2728
|
+
// Ensure 2-digit number for month and day
|
|
2729
|
+
let month = ("0" + (date.getMonth() + 1)).slice(-2);
|
|
2730
|
+
let day = ("0" + (date.getDay() + 1)).slice(-2);
|
|
2731
|
+
// Format the string in the way ALKiln can read it
|
|
2732
|
+
return `${ month }/${ day }/${ date.getFullYear() }`;
|
|
2733
|
+
},
|
|
2734
|
+
currency: async function ( scope ) {
|
|
2735
|
+
return `${ Math.random() * 100 }`;
|
|
2736
|
+
},
|
|
2737
|
+
email: async function ( scope ) {
|
|
2738
|
+
return faker.internet.email( faker.random.word(), faker.random.word(), `example.com` );
|
|
2739
|
+
},
|
|
2740
|
+
password: async function ( scope ) {
|
|
2741
|
+
return faker.internet.password();
|
|
2742
|
+
},
|
|
2743
|
+
hidden: async function ( scope ) {
|
|
2744
|
+
return null;
|
|
2745
|
+
},
|
|
2746
|
+
BirthDate: async function ( scope ) {
|
|
2747
|
+
let date = faker.date.birthdate({ max: 70, min: 0, mode: `age` });
|
|
2748
|
+
// Ensure 2-digit number for month and day
|
|
2749
|
+
let month = ("0" + (date.getMonth() + 1)).slice(-2);
|
|
2750
|
+
let day = ("0" + (date.getDay() + 1)).slice(-2);
|
|
2751
|
+
// Format the string in the way ALKiln can read it
|
|
2752
|
+
let date_str = `${ month }/${ day }/${ date.getFullYear() }`;
|
|
2753
|
+
return date_str;
|
|
2754
|
+
},
|
|
2755
|
+
ThreePartsDate: async function ( scope ) {
|
|
2756
|
+
return await scope.get_random_input_for.date( scope );
|
|
2757
|
+
},
|
|
2758
|
+
}, // ends scope.get_random_input_for()
|
|
2759
|
+
|
|
2177
2760
|
|
|
2178
2761
|
//#####################################
|
|
2179
2762
|
//#####################################
|