@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/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 that element.`;
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
- let clickWait;
365
- // Can't use elem[ scope.activate ](). It works locally, but errors on GitHub
366
- // with "Node is not visible or not an HTMLElement". We're not using a custom Chromium version
367
- // Unfortunately, this won't catch an invisible element. Hopefully we'd catch it before now...?
368
- // "Tap" on mobile is more complex to implement ourselves.
369
- // See https://stackoverflow.com/a/56547605/14144258
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
- // Error loads page, so no need to detect to keep things short
383
- let winner = await Promise.race([
384
- clickWait,
385
- // This is meant to detect user-visible alerts/errors,
386
- // not breaking errors in the testing code.
387
- scope.page.waitForSelector('.alert-danger', { visible: true }),
388
- scope.page.waitForSelector('.da-has-error', { visible: true }),
389
- ])
390
-
391
- // TODO: Not sure how to detect navigation landing at same page.
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
- await scope.page.goto(interview_url, { waitUntil: 'domcontentloaded', timeout: scope.timeout })
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
- let error_handle = await error_elem.getProperty( `textContent` );
610
- let system_error_text = await error_handle.jsonValue();
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
- result.error = system_error_text;
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, from_story_table=true }) {
634
- /* Return data derived from cucumber variable-setting data.
635
- * Table must have headers. Arrays are not supported. */
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.page.evaluateHandle(( selector, field ) => {
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 set_to = row.value;
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
- // Set field
1291
- if ( scope.setCustomDatatype[ custom_datatype ] === undefined ) {
1292
- // If no custom datatype, set a regular field
1293
- await scope.setField[ tag ]( scope, { handle, set_to });
1294
- } else {
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, set_to });
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
- return row;
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.setField`? Find the custom handle in the setter itself.
1586
+ // TODO: Just put this in `scope.enter_answer`? Find the custom handle in the setter itself.
1306
1587
  setCustomDatatype: {
1307
- "BirthDate": async function ( scope, { handle, set_to }) {
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 the ALToolbox custom birthdate
1590
+ * custom field container, sets an AssemblyLine three-part date's
1310
1591
  * field values. */
1311
- let date_parts = set_to.split(`/`);
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 and year
1612
+ // Set the day or year
1325
1613
  await scope.setText( scope, {
1326
1614
  handle: field,
1327
- set_to: date_parts[ field_i ]
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 BirthDate()
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
- setField: {
1340
- button: async function ( scope, { handle, set_to }) { await scope.tapElement( scope, handle ); },
1341
- textarea: async function ( scope, { handle, set_to }) {
1342
- await scope.setText( scope, { handle, set_to });
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, set_to }) {
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 `set_to` that is encoded correctly since
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
- let option = await scope.page.$(`option[value="${ set_to }"]`);
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( set_to ); // And use that value to set it
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: set_to });
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, set_to }) {
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, set_to }) {
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 ( set_to.toLowerCase() === 'false' ) { await label.evaluate( elem => { elem.style.background = 'tomato'; }); }
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, set_to }); }
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, set_to });
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 set_to_date = set_to;
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 (set_to.indexOf(`today`) !== -1) {
1708
+ if (answer.indexOf(`today`) !== -1) {
1400
1709
  // Find out how many days to add/subtract
1401
- let date_delta = set_to.replace(/\s/g, '');
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
- set_to_date = new Date();
1715
+ answer_date = new Date();
1407
1716
  // Add/subtract required number of days
1408
- set_to_date.setDate(set_to_date.getDate() + date_delta);
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
- set_to_date = ((set_to_date.getMonth() + 1).toString().padStart(2, 0) + '/' +
1412
- set_to_date.getDate().toString().padStart(2, 0) + '/' +
1413
- set_to_date.getFullYear().toString());
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, set_to: set_to_date });
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, set_to });
1727
+ await scope.setText( scope, { handle, answer });
1419
1728
  await scope.afterStep(scope, { waitForShowIf: true });
1420
1729
  }
1421
- }, // Ends scope.setField.input()
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
- uploadFiles: async function ( scope, { handle, set_to }) {
1425
- /* Try to upload one or more files to a file-type input field. */
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 = set_to.split(`,`);
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 "${set_to}".` });
1480
- throw ReferenceError(`Could not find "${set_to}".`)
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, set_to }) {
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 ( set_to.toLowerCase() !== status ) {
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, set_to }) {
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( set_to );
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 `setField` (which is singular)
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 msg = `Missing variable or variables on page.`;
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, set_to ) => {
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: set_to, trigger: scope.trigger_not_needed_flag }],
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, set_to_env_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: set_to_env_name,
2337
+ value: answer_env_name,
2000
2338
  trigger: scope.trigger_not_needed_flag,
2001
- flags: {secret_name: set_to_env_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 link.evaluate( elem => { return elem.click(); });
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
- await scope.page.goto( login_url, { waitUntil: `domcontentloaded`, timeout: scope.timeout })
2138
- await scope.page.waitForSelector( `.dabody` );
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.$eval( `button[name="submit"]`, (elem) => { return elem.click(); });
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
  //#####################################