@suffolklitlab/alkiln 5.17.0 → 5.17.1-detect-upload-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/scope.js CHANGED
@@ -211,6 +211,7 @@ module.exports = {
211
211
  *
212
212
  * TODO: discuss splitting into `afterField` and `afterStep` or something. Or just change name
213
213
  * TODO: Update to latest cucumberjs, which has `AfterStep`
214
+ * Discuss: Move system error detection to afterStep()?
214
215
  */
215
216
 
216
217
  if ( scope.page ) {
@@ -232,8 +233,8 @@ module.exports = {
232
233
  let error_text_prop = await error_elem.getProperty( 'textContent' );
233
234
  let system_error_text = await error_text_prop.jsonValue();
234
235
 
235
- reports.addToReport(scope, { type: `error`, code: `ALK0101`, value: system_error_text });
236
- throw system_error_text;
236
+ let rpt_msg = reports.addToReport(scope, { type: `error`, code: `ALK0101`, value: system_error_text });
237
+ throw Error( rpt_msg );
237
238
  };
238
239
 
239
240
  // Wait for elements that might get revealed
@@ -254,10 +255,44 @@ module.exports = {
254
255
  if ( options.waitForTimeout ) { await time.waitForTimeout( options.waitForTimeout ); }
255
256
  }, // Ends afterStep
256
257
 
257
- tapElementAndNavigate: async function(scope, { elem, waitForTimeout=null }) {
258
- /** Tap a given element, detect navigation, and do appropriate related things. */
258
+ tapElementAndNavigate: async function(scope, {
259
+ elem,
260
+ post_nav_handlers=null,
261
+ waitForTimeout=null
262
+ }) {
263
+ /**
264
+ * Tap a given element and expect successful navigation. Can tap action
265
+ * button, link, continue button, etc. Unfortunately, we need more ways to
266
+ * detect navigation. Ideas for detecting navigation:
267
+ * https://github.com/SuffolkLITLab/ALKiln/issues/229.
268
+ *
269
+ * @param {Object} scope - Tests state object
270
+ * @param {Object} kwargs - Named arguments
271
+ * @param {ElementHandle} kwargs.elem - Valid puppeteer element handle
272
+ * @param {int} [kwargs.waitForTimeout] - Max seconds before timing out
273
+ * @param {[function]} [kwargs.post_nav_handlers] - Functions to handle page
274
+ * errors like system errors or invalid answers' markers
275
+ *
276
+ * @returns {Object} kwrvs - Named return values
277
+ * @returns {bool} kwrvs.was_found - True if the check found any errors
278
+ * @returns {bool} kwrvs.ok - False if the check found any errors. Discuss:
279
+ * Should we only use `ok` for internal errors?
280
+ * @param {user|system} kwrvs.type - "user" with invalid answers, "system"
281
+ * with da or AL server errors
282
+ * @param {[ElementHandle]} kwrvs.error_handles - List of puppeteer element
283
+ * handles the check found. There can be multiple invalid answer elems.
284
+ * */
285
+ await scope.log_and_update_if_new_page_id( scope );
286
+
287
+ // TODO: We sometimes log multiple error messages for an error because we're
288
+ // logging at multiple levels. Can we refactor to log them just once?
259
289
 
260
- let start_url = await scope.page.url(); // so we can later check for navigation
290
+ let { id: initial_id } = await scope.examinePageID( scope, 'none to match' );
291
+ let error_data = await scope.checkForErrors( scope );
292
+ // Saves a lot of time in cases where we must otherwise wait for timeout
293
+ await scope.throw_on_system_error( scope, { error_data, id: initial_id });
294
+
295
+ let start_url = await scope.page.url(); // for navigation detection later
261
296
 
262
297
  await scope.tapElementAndStayOnPage(scope, {
263
298
  elem,
@@ -267,30 +302,17 @@ module.exports = {
267
302
  ]
268
303
  });
269
304
 
270
- // For other ideas for detecting navigation, see
271
- // https://github.com/SuffolkLITLab/ALKiln/issues/229
272
- let end_url = await scope.page.url();
273
- let navigated = start_url !== end_url;
274
-
275
- // Might be able to handle this in `scope.afterStep`
276
- if ( navigated ) {
277
-
278
- // TODO: Can we remove this?
279
- // Save the id that comes next
280
- let { question_has_id, id, id_matches } = await scope.examinePageID( scope, 'none to match' );
305
+ let navigated = false;
306
+ ({ error_data, navigated } = await scope.wait_until_continued( scope, {
307
+ url: start_url, id: initial_id, post_nav_handlers
308
+ }));
281
309
 
282
- // If navigation, wait for 200ms to let things finish loading and moving
283
- await scope.afterStep(scope, {
284
- waitForTimeout: ( waitForTimeout || 200 ),
285
- navigated: true
286
- });
287
- } else {
288
- // Discuss: Fail here or later since the test didn't manage to navigate?
289
- await scope.afterStep(scope, {
290
- waitForTimeout: ( waitForTimeout || 0 ),
291
- navigated: false
292
- });
293
- }
310
+ // If navigation, wait for 200ms to let things finish loading and moving
311
+ if ( navigated ) { waitForTimeout ??= 200; }
312
+ await scope.afterStep( scope, {
313
+ waitForTimeout: waitForTimeout, navigated,
314
+ });
315
+ return error_data;
294
316
  }, // Ends scope.tapElementAndNavigate()
295
317
 
296
318
  nav_race: function(scope, opts={}) {
@@ -344,15 +366,27 @@ module.exports = {
344
366
  // for that text too. Also, puppeteer will ensure proper timeout.
345
367
  await Promise.race([
346
368
  click_promises,
347
- // This is meant to detect user-visible alerts/errors,
369
+ // This is meant to detect user-visible da or AL alerts/errors,
348
370
  // not breaking errors in the testing code.
349
371
  // No need for aborters since puppeteer has its own timeout
350
- scope.page.waitForSelector('.alert-danger', { visible: true }),
372
+
373
+ // If this accidentally navigates, these will detect that the page has
374
+ // navigated.
375
+ // Discuss: Is intention to stay on this page? So throw on navigation?
376
+
377
+ // Discuss: `validation code` message can be on a page even after user fixed
378
+ // values. Always dismiss `validation code` before trying to continue? Or
379
+ // just detect navigation through requests and responses, which we need to
380
+ // switch to anyway.
381
+ // TODO: Do we need to check these elements' bounding boxes first since
382
+ // puppeteer doesn't recognize if parents are hidden?
383
+ scope.page.waitForSelector(`#daflash .alert-danger`, { visible: true }),
351
384
  scope.page.waitForSelector('.da-has-error', { visible: true }),
352
385
  ]);
353
386
  } catch ( error ) {
354
387
  if ( error.name === `TimeoutError` ) {
355
- let non_reload_report_msg = `After ALKiln tapped "${ tapperText }" it took too long to load the next page.`;
388
+ // TODO: Discuss - this is a navigation timeout even though the tap shouldn't navigate
389
+ let non_reload_report_msg = `After ALKiln tapped "${ tapperText }" it took too long to detect a change on the page.`;
356
390
  await scope.handle_page_timeout_error( scope, { non_reload_report_data: {
357
391
  code: `ALK0103`,
358
392
  message: non_reload_report_msg,
@@ -360,8 +394,8 @@ module.exports = {
360
394
  } else {
361
395
  // Throw any non-timeout error
362
396
  let err_msg = `An error occurred when ALKiln tried to tap "${ tapperText }".`
363
- reports.addToReport( scope, { type: `error`, code: `ALK0104`, value: err_msg });
364
- throw error;
397
+ let rpt_msg = reports.addToReport( scope, { type: `error`, code: `ALK0104`, value: [ err_msg, error ].join(`\n`) });
398
+ throw Error( rpt_msg );
365
399
  } // ends if error is timeout error
366
400
 
367
401
  } // ends try/catch
@@ -556,22 +590,73 @@ module.exports = {
556
590
  return false;
557
591
  },
558
592
 
559
- continue_button_selector: `fieldset.da-field-buttons button[type="submit"].btn-primary`,
560
- continue_button_selector_backup: `.da-fieldset.da-field-buttons button[type="submit"].btn-primary`,
561
- signature_selector: `fieldset .dasigsave`,
562
- signature_selector_backup: `.da-fieldset .dasigsave`,
563
- continue: async function ( scope ) {
564
- /* Presses whatever button it finds that might lead to the next page. */
565
- // Any selectors I find seem somewhat precarious.
566
- let elem = await Promise.race([
567
- scope.page.waitForSelector(scope.continue_button_selector), // other pages (this is the most consistent way)
568
- scope.page.waitForSelector(scope.continue_button_selector_backup),
569
- scope.page.waitForSelector(scope.signature_selector), // signature page
570
- scope.page.waitForSelector(scope.signature_selector_backup),
571
- ]);
572
- await elem.evaluate( el => { return el.className });
593
+ // ⚠️ Any selectors I find seem somewhat precarious.
594
+ continue_button_selector_da_v_1_9_n_and_earlier: `fieldset.da-field-buttons button[type="submit"].btn-primary`,
595
+ continue_button_selector_da_v_1_10_n_and_later: `button[type="submit"]#da-continue-button`,
596
+ continue_button_selector_backup_da_v_1_9_n_and_earlier: `.da-fieldset.da-field-buttons button[type="submit"].btn-primary`,
597
+ // Discuss: Use only the broadest selector or keep these first 2 as well?
598
+ signature_cont_btn_selector_da_v_1_9_n_and_earlier: `fieldset .dasigsave`,
599
+ signature_cont_btn_selector_backup_da_v_1_9_n_and_earlier: `.da-fieldset .dasigsave`,
600
+ // ⚠️ docassemble >= 1.10.n, 2 signature page continue buttons. One visible on
601
+ // wide screen, one visible on narrow screen. We need to get the visible
602
+ // one, but puppeteer v22.15.0 doesn't recognize an element with a hidden
603
+ // ancestor as invisible, so we need to get both `.dasigsave` and test for
604
+ // visibility with the bounding box for now.
605
+ signature_cont_btn_selector_da_v_1_10_n_and_later: `.dasigsave`,
606
+ continue: async function ( scope, { post_nav_handlers=null }={}) {
607
+ /**
608
+ * Presses whatever button it finds that might lead to the next page.
609
+ *
610
+ * @param {Object} scope - Tests state object
611
+ * @param {Object} obj - Named arguments
612
+ * @param {[function]} [obj.post_nav_handlers] - Functions to handle page
613
+ * errors like system errors or invalid answers' markers
614
+ *
615
+ * @returns {Object} obj - Named return values
616
+ * @returns {bool} obj.was_found - True if the check found any errors
617
+ * @returns {bool} obj.ok - False if the check found any errors. Discuss:
618
+ * Should we only use `ok` for internal errors?
619
+ * @param {user|system} obj.type - "user" with invalid answers, "system"
620
+ * with da or AL server errors
621
+ * @param {[ElementHandle]} obj.error_handles - List of puppeteer element
622
+ * handles the check found. There can be multiple invalid answer elems.
623
+ * */
624
+ await scope.log_and_update_if_new_page_id( scope );
625
+
626
+ let elem_lists = [
627
+ // non-signature pages (these are the most consistent ways)
628
+ await scope.page.$$(scope.continue_button_selector_da_v_1_9_n_and_earlier, { visible: true }),
629
+ await scope.page.$$(scope.continue_button_selector_da_v_1_10_n_and_later, { visible: true }),
630
+ await scope.page.$$(scope.continue_button_selector_backup_da_v_1_9_n_and_earlier, { visible: true }),
631
+ // signature pages
632
+ await scope.page.$$(scope.signature_cont_btn_selector_da_v_1_9_n_and_earlier, { visible: true }),
633
+ await scope.page.$$(scope.signature_cont_btn_selector_backup_da_v_1_9_n_and_earlier, { visible: true }),
634
+ // Two possible buttons if `visible: true` doesn't work
635
+ await scope.page.$$(scope.signature_cont_btn_selector_da_v_1_10_n_and_later, { visible: true }),
636
+ ];
637
+
638
+ // ⚠️ Avoid whichever `.dasigsave` button is hidden on signature screens. We
639
+ // must do this ourselves because puppeteer v22.15.0 doesn't seem to
640
+ // recognize when an ancestor of an element is `display: none`.
641
+ let continue_button = null;
642
+ for ( let possible_cont_buttons of elem_lists ) {
643
+ let found = false;
644
+ for ( let possible_cont_button of possible_cont_buttons ) {
645
+ let bounding_box = await possible_cont_button.boundingBox();
646
+ if ( bounding_box?.height !== 0 && bounding_box?.width !== 0) {
647
+ continue_button = possible_cont_button;
648
+ found = true;
649
+ break;
650
+ }
651
+ }
652
+ if ( found ) { break; }
653
+ }
654
+
573
655
  // Waits for navigation or user error
574
- await scope.tapElementAndNavigate( scope, { elem });
656
+ let error_data = await scope.tapElementAndNavigate( scope, {
657
+ elem: continue_button, post_nav_handlers
658
+ });
659
+ return error_data;
575
660
  }, // Ends scope.continue()
576
661
 
577
662
  continue_exists: async function ( scope ) {
@@ -585,14 +670,18 @@ module.exports = {
585
670
  // <button type="submit" class="btn btn-da btn-primary" name="Zm9v" value="True">Continue</button>
586
671
  // It will not find event screen `buttons:`, like an exit button or a restart button
587
672
  // <button type="submit" class="btn btn-da btn-danger" name="X211bHRpcGxlX2Nob2ljZQ" value="0">exit</button>
588
- // `buttons:` can be used in question blocks as choices
589
- let regular = await scope.page.$(scope.continue_button_selector);
590
- let regular_backup = await scope.page.$(scope.continue_button_selector_backup);
591
- let signature = await scope.page.$(scope.signature_selector);
592
- let signature_backup = await scope.page.$(scope.signature_selector_backup);
673
+ // ⚠️ `buttons:` can be used in question blocks as choices
674
+
675
+ // Multiple matches not needed. We're just checking for presence.
676
+ let regular = await scope.page.$(scope.continue_button_selector_da_v_1_9_n_and_earlier, { visible: true });
677
+ let regular_da_v_1_10_x = await scope.page.$(scope.continue_button_selector_da_v_1_10_n_and_later, { visible: true });
678
+ let regular_backup = await scope.page.$(scope.continue_button_selector_backup_da_v_1_9_n_and_earlier, { visible: true });
593
679
 
594
- return regular !== null || regular_backup !== null || signature !== null || signature_backup !== null;
680
+ let signature = await scope.page.$(scope.signature_cont_btn_selector_da_v_1_9_n_and_earlier, { visible: true });
681
+ let signature_backup = await scope.page.$(scope.signature_cont_btn_selector_backup_da_v_1_9_n_and_earlier, { visible: true });
682
+ let signature_da_v_1_10_x = await scope.page.$(scope.signature_cont_btn_selector_da_v_1_10_n_and_later, { visible: true });
595
683
 
684
+ return regular !== null || regular_da_v_1_10_x !== null || regular_backup !== null || signature !== null || signature_backup !== null || signature_da_v_1_10_x !== null;
596
685
  }, // Ends scope.continue_exists()
597
686
 
598
687
  tapTab: async function ( scope, tab_id ) {
@@ -606,8 +695,9 @@ module.exports = {
606
695
  await scope.waitForTab( scope, { tab_id });
607
696
  },
608
697
 
609
- checkForA11y: async function (scope ) {
698
+ checkForA11y: async function ( scope ) {
610
699
  /** Return if there were any violations and the file containing aXe output with more details of the checks */
700
+ // Discuss: What problem was the page id solving? When was there a mismatch?
611
701
  let { id } = await scope.examinePageID( scope, 'none to match' );
612
702
  if ( scope.page_id !== id ) {
613
703
  reports.addToReport(scope, { type: `page_id info`, code: `ALK0108`, value: `${ id }`, });
@@ -642,13 +732,30 @@ module.exports = {
642
732
  },
643
733
 
644
734
  tapSelectorToNavigate: async function ( scope, selector, waitForTimeout ) {
735
+ /**
736
+ * Taps an element matching the selector string, expecting to navigate.
737
+ *
738
+ * @param {Object} scope - Tests state object
739
+ * @param {str} selector - Selector of the element to tap
740
+ * @param {int} waitForTimeout - Max seconds before timing out
741
+ *
742
+ * @returns {Object} obj - Named return values
743
+ * @returns {bool} obj.was_found - True if the check found any errors
744
+ * @returns {bool} obj.ok - False if the check found any errors. Discuss:
745
+ * Should we only use `ok` for internal errors?
746
+ * @param {user|system} obj.type - "user" with invalid answers, "system"
747
+ * with da or AL server errors
748
+ * @param {[ElementHandle]} obj.error_handles - List of puppeteer element
749
+ * handles the check found. There can be multiple invalid answer elems.
750
+ * */
645
751
  let elem = await scope.page.$(`${ selector }`);
646
- let error_msg = `Couldn't find "${ selector }" on the page, is there a typo?`;
752
+ let error_msg = `Couldn't find "${ selector }" on the page. Is there a typo?`;
647
753
  if ( !elem ) {
648
754
  reports.addToReport(scope, { type: `error`, code: `ALK0111`, value: error_msg });
649
755
  }
650
756
  expect( elem, error_msg ).to.exist;
651
- await scope.tapElementAndNavigate( scope, { elem, waitForTimeout });
757
+ let error_data = await scope.tapElementAndNavigate( scope, { elem, waitForTimeout });
758
+ return error_data;
652
759
  },
653
760
 
654
761
  tapSelectorAndStay: async function ( scope, { selector }) {
@@ -750,6 +857,14 @@ module.exports = {
750
857
  set_interview_session_data: function ( scope, { vars = null }) {
751
858
  /** Sets this interview's session data for this scenario if it doesn't
752
859
  * already exist.
860
+ *
861
+ * Discuss: Keep a record of insertion order of .interviews uids so we can
862
+ * tell which interview is most recent and the order in which interviews
863
+ * were visited.
864
+ *
865
+ * TODO: If we don't already, when deleting interviews, use
866
+ * scenario.interviews to get the relevant uids instead of just the most
867
+ * recent uid? Multiple tests may be running.
753
868
  *
754
869
  * @returns final_vars { object | null }
755
870
  * @returns final_vars.i { str } File path of interview
@@ -931,8 +1046,12 @@ module.exports = {
931
1046
  * as some kind of sophisticated data? With their selectors, for example.
932
1047
  * We will choose selectors that will continue to identify the node, eve
933
1048
  * after a hidden field is revealed, etc.
934
- * TODO: Not sure where to bring in cheerio
935
- * TODO: Pass in `$` instead? */
1049
+ *
1050
+ * TODO: Not sure where to bring in cheerio. Pass in `$` instead?
1051
+ *
1052
+ * TODO: Consider getting more data here so that other functionality can use
1053
+ * the data to reduce use of `page.evaluate()`.
1054
+ * */
936
1055
  let fields = [];
937
1056
 
938
1057
  const $ = cheerio.load( html ); // Could this be on `scope`?
@@ -960,55 +1079,87 @@ module.exports = {
960
1079
  // Some `show if` fields have `names` like '_field_n' instead of their var name. Ex: _field_1
961
1080
  let field_like_names = await scope.getFieldNamesDict( scope, { $: $ });
962
1081
 
963
- // All the different types of fields
964
- // buttons, canvases, inputs of all kinds, selects (dropdowns), textareas. Are there more?
965
- // Will deal with `option` once inside `select`
966
- let all_nodes = $( `#dasigpage, fieldset button, .da-fieldset button, .daquestionactionbutton, fieldset input, da-fieldset input, .da-form-group input, .da-form-group select, form select, .da-form-group textarea` );
1082
+ // All the different types of fields. Will deal with `option` once inside `select`
1083
+
1084
+ // All these hidden inputs moved inside the da form element. We want to keep
1085
+ // some hidden inputs, so we'll have to exclude the new neighbors.
1086
+ let not = `:not([name="${ [
1087
+ `_checkboxes`, `csrf_token`, `_event`, `_question_name`, `_tracker`, `_datatypes`, `_visible`, `_varnames`
1088
+ ].join('"]):not([name="') }"])`;
1089
+ let all_nodes = $(
1090
+ `#dasigpage canvas`
1091
+ + `, fieldset button, .da-fieldset button, .daquestionactionbutton, #daform button, #daform *[role="button"]`
1092
+ + `, fieldset input${ not }, .da-fieldset input${ not }, .da-form-group input${ not }, #daform input${ not }`
1093
+ + `, .da-form-group select, #daform select, #daform form select`
1094
+ + `, .da-form-group textarea, #daform textarea`
1095
+ );
1096
+ let all_buttons = [];
1097
+
1098
+ // // TODO: When various versions of da resolve the `GET` errors, re-activate
1099
+ // // this code out here instead of just for canvas elements.
1100
+ // let question_data = await scope.get_question_data( scope );
1101
+
967
1102
  for ( let node of all_nodes ) {
968
- // Decision: Do not abstract the below. There's too much data to pass around for it to make sense
969
1103
  let $node = $( node );
970
1104
  let field_data = {
971
1105
  selector: '',
972
1106
  tag: node.name,
973
- guesses: [],
974
- type: '',
1107
+ guesses: null,
1108
+ type: node.attribs.type || ``,
975
1109
  trigger: trigger,
976
1110
  };
977
- if ( node.attribs.type ) { field_data.type = node.attribs.type; }
978
1111
 
979
1112
  // Often used for the variable name
980
- let not_decoded_name = $node.attr( `name` );
981
-
982
- // `<input type="hidden" name="_save_as" value="c2lnbmF0dXJlXzE">`
983
- // canvas elements are the only field on a signature page
984
- if ( $(`#dasigpage`).length > 0 && $(`#dasigpage`)[0].name ) {
985
- field_data.selector = `#daquestion canvas`;
986
- field_data.tag = `canvas`;
987
-
988
- // If da server's config value of `restrict input variables`
989
- // set to True, `_save_as` should work to get the var name
990
- if ( $( `input[name="_save_as"]` ).length > 0 ) {
991
- not_decoded_name = $( `input[name="_save_as"]` ).attr( `value` );
992
-
993
- // If not, the author will have to use the trigger var HTML element
994
- // or we can't get the var name being set for the signature
995
- } else if ( trigger !== `` ) {
996
- not_decoded_name = trigger;
997
-
998
- // If we have neither of those, we need to tell the user we can't
999
- // set this variable this way. We can't test this without a
1000
- // server that has that special config value set to `True`
1001
- } else {
1113
+ let encoded_name = $node.attr( `name` );
1114
+
1115
+ if ( field_data.tag === `canvas` ) { // $(`#dasigpage`).length > 0 && $(`#dasigpage`)[0].name ) {
1116
+
1117
+ let question_data = await scope.get_question_data( scope );
1118
+
1119
+ // ⚠️ Cheerio is unable to detect that some `.dasigsave` buttons are
1120
+ // hidden. Ignore buttons here. Take care of it with puppeteer later.
1121
+
1122
+ // Try for guesses that are most likely to be associated with the canvas
1123
+ encoded_name ??= scope.canvas_name.try_for_best_encoded( scope, {
1124
+ existing_guesses: field_data.guesses, $, trigger
1125
+ });
1126
+ if ( !encoded_name && !field_data.guesses ) {
1127
+ field_data.guesses = scope.canvas_name.try_for_mid_guesses( scope, {
1128
+ question_data
1129
+ });
1130
+ }
1131
+
1132
+ // Resort to names that may have proxy vars, such as x and i. See
1133
+ // docassemble generic objects and index vars. They are more
1134
+ // ambiguous since they could match multiple variables.
1135
+
1136
+ if ( !encoded_name && !field_data.guesses ) {
1137
+ field_data.guesses = scope.canvas_name.try_for_worse_guesses( scope, {
1138
+ question_data
1139
+ });
1140
+ }
1141
+ if ( !encoded_name && !field_data.guesses ) {
1142
+ encoded_name = scope.canvas_name.try_for_worse_encoded( scope, {
1143
+ question_data
1144
+ });
1145
+ }
1146
+ // ✏️ var_names handles an undefined encoded_name just fine
1147
+
1148
+ // If we have none of those, we need to tell the user we have too little
1149
+ // information to set the signature field variable We'll fail later
1150
+ // when we can take a proper screenshot.
1151
+ if ( !field_data.guesses && !encoded_name ) {
1002
1152
  reports.addToReport(scope, {
1003
1153
  type: `error`,
1004
1154
  code: `ALK0123`,
1005
- value: `You cannot sign a signature page this way because the `
1006
- + `interview's HTML is missing data. Use the "I sign" Step `
1007
- + `instead. You can also read documentation about `
1008
- + `how to create the "trigger" HTML.`
1155
+ value: `You cannot sign a signature page with this ALKiln Step `
1156
+ + `because the interview's HTML is missing data. Use the "I sign" `
1157
+ + `Step instead. You can also read documentation about `
1158
+ + `how to add the "trigger" to the interview HTML.`
1009
1159
  });
1160
+ // Discuss: Throw here?
1010
1161
  }
1011
- }
1162
+ } // end if signature page
1012
1163
 
1013
1164
  // The selector we'll use to set the DOM node's value later
1014
1165
  if ( !field_data.selector ) {
@@ -1021,19 +1172,19 @@ module.exports = {
1021
1172
  let href = node.attribs.href;
1022
1173
  let match = href.match( /action=([^&]*)/ );
1023
1174
  if ( match ) {
1024
- not_decoded_name = match[1];
1175
+ encoded_name = match[1];
1025
1176
  } else {
1026
1177
  // Some action buttons just use urls instead of da actions
1027
- // This means the first guess of the name should be the not_decoded_name
1178
+ // This means the first guess of the name should be the encoded_name
1028
1179
  // TODO: This only replaces the chars at the start of the string. Is that
1029
1180
  // intentional? Does this `.replace()` need the `g` flag?
1030
- not_decoded_name = href.replace(/^[A-Za-z0-9]*/, '_');
1181
+ encoded_name = href.replace(/^[A-Za-z0-9]*/, '_');
1031
1182
  }
1032
1183
  }
1033
1184
 
1034
- if ( !not_decoded_name ) {
1185
+ if ( !encoded_name ) {
1035
1186
  // Some fields, like comboboxes, have a weird var name setup
1036
- not_decoded_name = $node.attr( `id` );
1187
+ encoded_name = $node.attr( `id` );
1037
1188
  }
1038
1189
 
1039
1190
  // ====================
@@ -1044,12 +1195,12 @@ module.exports = {
1044
1195
  if ( $node.hasClass('danota-checkbox') || $node.parents(`.da-field-container-datatype-object_radio`).name ) {
1045
1196
  is_none_of_the_above = true;
1046
1197
  let first_sib = $(node.parent).find( 'input' )[0];
1047
- not_decoded_name = first_sib.attribs.name;
1198
+ encoded_name = first_sib.attribs.name;
1048
1199
  // TODO: Do we need daaota as well? ("all of the above")
1049
1200
  }
1050
1201
 
1051
1202
  let var_names = await scope.getPossibleDecodedNames( scope, {
1052
- not_decoded: not_decoded_name,
1203
+ not_decoded: encoded_name,
1053
1204
  field_like_names,
1054
1205
  is_none_of_the_above: is_none_of_the_above,
1055
1206
  proxies,
@@ -1070,16 +1221,261 @@ module.exports = {
1070
1221
  values.push( node.attribs.value );
1071
1222
  }
1072
1223
 
1073
- field_data.guesses = await scope.getPossibleTableRowMatchers( scope, { var_names, values });
1074
- fields.push( field_data );
1224
+ // TODO: Try replacing null coalescing with using the guesses as var
1225
+ // names.
1226
+ field_data.guesses ??= await scope.add_values_if_needed( scope, { var_names, values });
1227
+ if ( scope.is_button_ish( scope, { field: field_data }) ) {
1228
+ // Discuss: This makes sure buttons are matched with table rows last. Is
1229
+ // that useful? Appropriate?
1230
+ all_buttons.push( field_data );
1231
+ } else {
1232
+ fields.push( field_data );
1233
+ }
1234
+
1075
1235
  } // ends for every node
1076
1236
 
1237
+ let on_page = [ ...fields, ...all_buttons ];
1238
+
1077
1239
  log.debug({ code: `ALK0124`, context: `page fields`, },
1078
- fields
1240
+ JSON.stringify( on_page, null, 2 )
1079
1241
  );
1080
- return fields;
1242
+ return on_page;
1081
1243
  }, // Ends scope.getAllFields()
1082
1244
 
1245
+ get_question_data: async function ( scope ) {
1246
+ /**
1247
+ * Return server data about the current question.
1248
+ *
1249
+ * da v1.10.n or greater removes some info from the DOM, such as on the
1250
+ * signature page, so we need to supplement our info when possible. We can
1251
+ * do this by making a GET request for the current page data in the
1252
+ * browser. See https://docassemble.org/docs/api.html#session_question. If
1253
+ * we had an API key for the user or an admin, we could use the API, but
1254
+ * we cannot guarantee having that API key without making a lot more work
1255
+ * for authors.
1256
+ *
1257
+ * Discuss: Do we need to wait longer for a server response, as we do for a
1258
+ * page response?
1259
+ *
1260
+ * @param {Object} scope - Tests state object
1261
+ *
1262
+ * @returns {Object} - the question & field data from the current page
1263
+ * */
1264
+ let question_data = { ok: false, status: -1 };
1265
+ let question_response = null;
1266
+
1267
+ try {
1268
+ question_response = await scope?.page?.evaluate?.( async () => {
1269
+ /**
1270
+ * Return da "question data" from the server for the current page
1271
+ *
1272
+ * ‼️ On system error caused by `GET` request, AL's da v1.9.n first
1273
+ * detects the error, but it stays invisible to the DOM since the
1274
+ * page hasn't been reloaded. Then, when the test presses to
1275
+ * continue, a new error appears. Unfortunately, the old error is
1276
+ * lost, so we won't be able to retrieve it through the DOM. We may
1277
+ * want to switch to catching errors during the field-getting part
1278
+ * of the life-cycle if we want to be able to handle this robustly.
1279
+ * I'm not sure what other issues will arise at that point.
1280
+ *
1281
+ * @returns {object} -
1282
+ * { ok: false }; // ok -> response status or false
1283
+ * | { ok, status, ...data, ...( await response.json() ) }
1284
+ * | { ok, status, alk_msg: err_msg, ...data, html: await response.text() }
1285
+ * | { ok, status, alk_msg: err_msg }
1286
+ * | { ok: false, alk_msg: err_msg, error: response_err.message }
1287
+ * */
1288
+ let q_resp_err_msg = `Docassemble couldn't process this page's JSON. ALKiln's`
1289
+ + ` will do its best, but may fail. We would love a report for this.`;
1290
+ let data = { ok: false, alk_msg: null };
1291
+ try {
1292
+ let response = await fetch(
1293
+ window.location.href.replace(`interview?`, `interview?json=1&`)
1294
+ );
1295
+
1296
+ data.ok = response.ok;
1297
+ data.status = response.status;
1298
+
1299
+ let content_type = response?.headers?.get?.(`content-type`);
1300
+ if ( content_type?.startsWith?.(`application/json`) ) {
1301
+ return { ...data, ...( await response.json() ) };
1302
+
1303
+ } else if ( content_type?.startsWith?.(`text/plain`) ) {
1304
+ return { ...data, alk_msg: q_resp_err_msg, html: await response.text() };
1305
+
1306
+ } else {
1307
+ // Research: How can we get in here? What other content-types that
1308
+ // we could get give response ok?
1309
+ return { ...data, alk_msg: q_resp_err_msg, content_type };
1310
+ } // ends content-type check
1311
+
1312
+ } catch ( response_err ) {
1313
+ // Error might be caused by non-da page author navigated to when, for
1314
+ // example, testing an "Exit" destination.
1315
+ return { ok: false, alk_msg: q_resp_err_msg, error: response_err.message, }
1316
+ } // ends fetch try
1317
+
1318
+ return data;
1319
+ } ); // ends page.evaluate() call
1320
+ } catch ( question_data_error ) {
1321
+ question_response = { ok: false, alk_msg: q_resp_err_msg, error: question_data_error.message, }
1322
+ }
1323
+
1324
+ question_data = question_response || question_data;
1325
+ // If we didn't get JSON response, we're assuming fetch ran into a problem.
1326
+ // This isn't critical info. Other ALKiln code will throw if warranted.
1327
+ if ( !question_data.ok ) {
1328
+ scope.log_question_data_not_ok( scope, { data: question_data });
1329
+ return { ok: false };
1330
+ }
1331
+
1332
+ return question_data;
1333
+ }, // Ends get_question_data
1334
+
1335
+ log_question_data_not_ok: function ( scope, { data={} }) {
1336
+ /**
1337
+ * Log question_data response props appropriately.
1338
+ *
1339
+ * @param {Object} scope - Tests state object
1340
+ * @param {Object} obj - Named args
1341
+ * @param {Object} obj.data - Data for a non-JSON response to fetching
1342
+ * docassemble current question data
1343
+ *
1344
+ * Possible data shapes:
1345
+ * { ok: false }; // ok -> response.ok or false
1346
+ * | { ok, status, ...data, ...( await response.json() ) }
1347
+ * | { ok, status, alk_msg: err_msg, ...data, html: await response.text() }
1348
+ * | { ok, status, alk_msg: err_msg, content_type }
1349
+ * | { ok: false, alk_msg: err_msg, error: response_err.message }
1350
+ *
1351
+ * @returns {string} - Log text
1352
+ * */
1353
+ let default_msg = `ALKlin skipped processing the current page's server data.`;
1354
+ let qst_msgs = [ data.alk_msg || default_msg, ]
1355
+
1356
+ if ( data.status || data.html || data.error || data.content_type ) { qst_msgs.push(``); }
1357
+
1358
+ if ( data.status ) { qst_msgs.push(`Response status: ${ data.status }`); }
1359
+ if ( data.content_type ) { qst_msgs.push(`Content type: ${ data.content_type }`) }
1360
+ if ( data.html ) {
1361
+ let $ = cheerio.load( data.html );
1362
+ qst_msgs.push( $('#damain')?.text?.() || data.html );
1363
+ }
1364
+ if ( data.error ) { qst_msgs.push( data.error ); }
1365
+
1366
+ return log.debug({ level: `warn`, code: `ALK0282`, context: `question_data`,
1367
+ }, qst_msgs.join(`\n`));
1368
+ },
1369
+
1370
+ canvas_name: {
1371
+ try_for_best_encoded: function( scope, { existing_guesses, $, trigger }) {
1372
+ /**
1373
+ * Returns the canvas' encoded var instance name if possible. Otherwise
1374
+ * returns null.
1375
+ *
1376
+ * @param {Object} scope - Tests state object
1377
+ * @param {Object} obj - Named arguments
1378
+ * @param {Object} obj.existing_guesses - List of existing guesses
1379
+ * @param {Object} obj.$ - cheerio
1380
+ * @param {Object} obj.trigger - Name of the var the interview is seeking
1381
+ *
1382
+ * @returns {string} - Encoded variable instance name
1383
+ *
1384
+ * @example
1385
+ * scope.canvas_name.try_for_best_encoded( scope, { existing_guesses, $, trigger }
1386
+ * // cHJveHlfbGlzdFswXS5zaWduYXR1cmU // decoded: people[0]...
1387
+ * */
1388
+ if ( existing_guesses?.length > 0 ) { return null; }
1389
+ return (
1390
+ // v1.9.n: If da server's config value of `restrict input
1391
+ // variables` set to True, `_save_as` should work to get the var
1392
+ // name. v 1.10.n, unknown
1393
+ $(`input[name="_save_as"]`)?.attr?.(`value`)
1394
+ || trigger // From ALKiln custom HTML author may or may not be using
1395
+ || null
1396
+ );
1397
+ },
1398
+
1399
+ try_for_mid_guesses: function( scope, { question_data }) {
1400
+ /**
1401
+ * If possible, returns a list of one string - a decoded instance name
1402
+ * that likely belongs to the canvas var. Otherwise returns null.
1403
+ * Example: people[0].signature.
1404
+ *
1405
+ * @param {Object} scope - Tests state object
1406
+ * @param {Object} obj - Named arguments
1407
+ * @param {Object} obj.question_data - Docassemble server data about the
1408
+ * current question
1409
+ *
1410
+ * @returns {array} - List of one decoded canvas element name
1411
+ *
1412
+ * @example
1413
+ * scope.canvas_name.try_for_mid_guesses( scope, { field_data, question_data })
1414
+ * // people[0].signature // If it's the first one in the list, that is
1415
+ * */
1416
+ // `event_list` contains decoded instance names of variables. Sadly, this
1417
+ // code assumes the first item of `event_list` is the canvas name. Note,
1418
+ // though, that I've been unable to trigger an event_list with multiple
1419
+ // items.
1420
+ if ( question_data?.event_list?.[0] ) {
1421
+ return [ question_data.event_list[0] ];
1422
+ }
1423
+ return null;
1424
+ },
1425
+
1426
+ try_for_worse_guesses: function( scope, { question_data }) {
1427
+ /**
1428
+ * If possible, returns a list of one string - a decoded var name that
1429
+ * likely belongs to the canvas, but may contain x, i, or other such
1430
+ * proxy variable names instead of the intrinsic name. Otherwise returns
1431
+ * null. Example: x[i].signature. Note that I've been unable to trigger
1432
+ * a `fields` list with multiple items.
1433
+ *
1434
+ * @param {Object} scope - Tests state object
1435
+ * @param {Object} obj - Named arguments
1436
+ * @param {Object} obj.question_data - Docassemble server data about the
1437
+ * current question
1438
+ *
1439
+ * @returns {[string]} - List of one decoded canvas, maybe proxy, var name
1440
+ *
1441
+ * @example
1442
+ * scope.canvas_name.try_for_mid_guesses( scope, { question_data })
1443
+ * // x[i].signature
1444
+ * */
1445
+ // The canvas should be the only field on the page, so the assumption that
1446
+ // the canvas's name is the first item of `fields` should be reasonable.
1447
+ if ( question_data?.fields?.[0]?.variable_name ) {
1448
+ return [ question_data.fields[0].variable_name ];
1449
+ }
1450
+ return null;
1451
+ },
1452
+
1453
+ try_for_worse_encoded: function( scope, { question_data }) {
1454
+ /**
1455
+ * If possible, returns an encoded var name that likely belongs to the
1456
+ * canvas, but may contain x, i, or other such proxy variable names
1457
+ * instead of the intrinsic name. Otherwise returns null. Example:
1458
+ * x[i].signature. Note that I've been unable to trigger a `fields` list
1459
+ * with multiple items.
1460
+ *
1461
+ * @param {Object} scope - Tests state object
1462
+ * @param {Object} obj - Named arguments
1463
+ * @param {Object} obj.question_data - Docassemble server data about the
1464
+ * current question
1465
+ *
1466
+ * @returns {string} - Encoded canvas, maybe proxy, var name
1467
+ *
1468
+ * @example
1469
+ * scope.canvas_name.try_for_mid_guesses( scope, { question_data })
1470
+ * // eFtpXS5zaWduYXR1cmU // Decodes to x[i].signature
1471
+ * */
1472
+ // The canvas should be the only field on the page, so assuming the
1473
+ // canvas's name the first item of `fields` should be reasonable.
1474
+ // Last resort, encoded name. Example: eFtpXS5zaWduYXR1cmU which decodes to (x[i]...).
1475
+ return question_data?.fields?.[0]?.variable_name_encoded;
1476
+ },
1477
+ },
1478
+
1083
1479
  getPageJSON: async function ( scope ) {
1084
1480
  /* Get the JSON data of the current page. */
1085
1481
  return await scope.page.evaluate(async function ( elem ) {
@@ -1103,6 +1499,7 @@ module.exports = {
1103
1499
  if ( node.attribs.value ) { selector += `[value="${ node.attribs.value }"]`; }
1104
1500
  if ( node.attribs.id ) { selector += `[id="${ node.attribs.id }"]`; }
1105
1501
  if ( node.attribs.class ) { selector += `[class="${ node.attribs.class }"]`; }
1502
+ if ( node.attribs.role ) { selector += `[role="${ node.attribs.role }"]`; }
1106
1503
  if ( $( node ).attr( 'data-linknum' )) { selector += `[data-linknum="${ $( node ).attr( 'data-linknum' ) }"]`; }
1107
1504
  // Everything should be in #daquestion and that's also how our unit tests and fixtures work.
1108
1505
  selector = '#daquestion ' + selector;
@@ -1114,7 +1511,13 @@ module.exports = {
1114
1511
  // Really need everything to be unique here if at all possible. Shall we resort
1115
1512
  // to parents and n-th child?
1116
1513
  // Exceptions: Some specific fields can be duplicates without being dangerous
1117
- if ( matching_nodes.length > 1 && !selector.includes(`[class="file-caption-name"]`) ) {
1514
+ if (
1515
+ matching_nodes.length > 1
1516
+ && !selector.includes(`[class="file-caption-name"]`)
1517
+ && !selector.includes(`fileinput-cancel`) // File upload
1518
+ && !selector.includes(`fileinput-remove`) // File upload
1519
+ && !selector.includes(`kv-file`) // File upload
1520
+ ) {
1118
1521
  let msg = `${ matching_nodes.length } items on this page matched the base64 encoded name '${ selector }'. You may be setting the same variable in multiple places on this page.`;
1119
1522
  reports.addToReport(scope, { type: `warning`, code: `ALK0125`, value: msg });
1120
1523
  }
@@ -1122,12 +1525,12 @@ module.exports = {
1122
1525
  return selector;
1123
1526
  }, // Ends scope.getSelector()
1124
1527
 
1125
- getPossibleTableRowMatchers: async function ( scope, { var_names, values }) {
1528
+ add_values_if_needed: async function ( scope, { var_names, values }) {
1126
1529
  /* Return variable names and values combined in ways that can
1127
1530
  * try to match table rows. Tables can have two properties:
1128
- * variable name and value. */
1531
+ * variable name and value. Despite it's name, non-mutating. */
1129
1532
  log.debug({ code: `ALK0126`, context: `matching`, },
1130
- `scope.getPossibleTableRowMatchers() var_names:`, var_names, `values:`, values
1533
+ `scope.add_values_if_needed() var_names:`, var_names, `values:`, values
1131
1534
  );
1132
1535
  let possible_table_rows = [];
1133
1536
  for ( let var_name of var_names ) {
@@ -1156,7 +1559,7 @@ module.exports = {
1156
1559
  } // end iterate through row possibilities
1157
1560
 
1158
1561
  return possible_table_rows;
1159
- }, // Ends scope.getPossibleTableRowMatchers()
1562
+ }, // Ends scope.add_values_if_needed()
1160
1563
 
1161
1564
  getFieldNamesDict: async function ( scope, { $ }) {
1162
1565
  /* Returns an object containing a map of the `_field_n` names and
@@ -1397,7 +1800,7 @@ module.exports = {
1397
1800
  if ( opt.attribs.value !== undefined ) {
1398
1801
  options.push( opt.attribs.value );
1399
1802
  }
1400
- // Dropdowns created w/ objects are base64 encoded; but it's handled in getPossibleTableRowMatchers
1803
+ // Dropdowns created w/ objects are base64 encoded; but it's handled in add_values_if_needed
1401
1804
  }
1402
1805
  return options;
1403
1806
  }, // Ends scope.getOptionValues()
@@ -1616,7 +2019,7 @@ module.exports = {
1616
2019
  );
1617
2020
  if ( final_matching_rows.length == 0) {
1618
2021
  log.debug({ code: `ALK0132`, context: `matching`, },
1619
- `0 matching rows. Field:`, field
2022
+ `0 matching rows for this field on the page:`, field
1620
2023
  );
1621
2024
  }
1622
2025
 
@@ -1849,6 +2252,12 @@ module.exports = {
1849
2252
 
1850
2253
  enter_answer: {
1851
2254
  button: async function ( scope, { handle, answer }) {
2255
+ // For ambiguous tap discussion, see https://github.com/SuffolkLITLab/ALKiln/issues/1127
2256
+ await scope.tapElementAndNavigate( scope, { elem: handle });
2257
+ return true;
2258
+ },
2259
+ a: async function ( scope, { handle, answer }) {
2260
+ // For ambiguous tap discussion, see https://github.com/SuffolkLITLab/ALKiln/issues/1127
1852
2261
  await scope.tapElementAndNavigate( scope, { elem: handle });
1853
2262
  return true;
1854
2263
  },
@@ -1895,7 +2304,7 @@ module.exports = {
1895
2304
  /* Set value of some `input` element to the given value. */
1896
2305
  let row_was_used = true;
1897
2306
 
1898
- let type = await handle.evaluate( elem => {
2307
+ let type = await handle.evaluate( elem => {
1899
2308
  if (elem) {
1900
2309
  return elem.getAttribute('type');
1901
2310
  } else {
@@ -1921,7 +2330,7 @@ module.exports = {
1921
2330
  await scope.afterStep(scope, { waitForShowIf: true });
1922
2331
 
1923
2332
  } else if ( type === `file` ) {
1924
- row_was_used = await scope.uploadFiles( scope, { handle, answer });
2333
+ row_was_used = await scope.upload_files( scope, { handle, answer });
1925
2334
  await scope.afterStep(scope, { waitForShowIf: true });
1926
2335
 
1927
2336
  } else if (type == `date`) {
@@ -2001,7 +2410,7 @@ module.exports = {
2001
2410
  return valid_date;
2002
2411
  }, // Ends scope.today_to_date()
2003
2412
 
2004
- findFiles: async function( scope, {to_find_names}) {
2413
+ find_files: async function( scope, {to_find_names}) {
2005
2414
  /** Returns a list of paths to the sources folder associated
2006
2415
  * with the given list of file names.
2007
2416
  *
@@ -2058,9 +2467,9 @@ module.exports = {
2058
2467
  return [];
2059
2468
  }
2060
2469
  return paths;
2061
- }, // Ends scope.findFiles()
2470
+ }, // Ends scope.find_files()
2062
2471
 
2063
- uploadFiles: async function ( scope, { handle, answer }) {
2472
+ upload_files: async function ( scope, { handle, answer }) {
2064
2473
  /* Try to upload one or more files to a file-type input field.
2065
2474
  * Does not submit, so no danger of timeout with server reload.
2066
2475
  *
@@ -2070,35 +2479,143 @@ module.exports = {
2070
2479
  // First make sure the list of files was defined. Don't remove spaces yet -
2071
2480
  // allow a typo where someone forgot to include a space after a comma.
2072
2481
  let to_find_names = answer.split(`,`);
2073
- let paths = await scope.findFiles(scope, {to_find_names});
2074
- if (paths.length == 0) {
2482
+ let paths = await scope.find_files(scope, {to_find_names});
2483
+ if ( paths.length == 0 ) {
2075
2484
  // The row wasn't used
2076
2485
  return false;
2077
2486
  }
2078
2487
 
2079
- // Prepare to wait for upload to complete
2080
- let name = await handle.evaluate( elem => { return elem.getAttribute(`name`); });
2081
- let selector = `*[name="${ name }"]`
2082
- let disabled_selector = `${ selector }[disabled]`;
2083
- let enabled_selector = `${ selector }:not([disabled])`;
2488
+ // ✏️ The code below is a great indication that Log and `reports`
2489
+ // architecture may be less than ideal, though js doesn't help.
2490
+
2491
+ // TODO: Pass in a promise aborter to page.evaluate() if possible, though
2492
+ // it's not completely necessary. If the gobal `setTimeout` fires then the
2493
+ // `evaluate` won't be far behind. They both use about the same timeout
2494
+ // duration.
2495
+ let finish_uploading = handle.evaluate( async function (
2496
+ elem, timeout, answer
2497
+ ) {
2498
+ return new Promise (( resolve, reject ) => {
2499
+ /**
2500
+ * Resolve when upload completes. Logging in here will log to the hidden
2501
+ * browser. Must instead pass information out with `resolve`.
2502
+ *
2503
+ * Warn about uploading the same file multiple times. It won't protect
2504
+ * against accidentally uploading same file to a different field.
2505
+ * */
2506
+
2507
+ // If elem.files, the author has previously set this field. Warn them.
2508
+ // Example prev_filenames: "C:\\fakepath\\name_of_file.png"
2509
+ let prev_filenames = [];
2510
+ let shared_names_list = [];
2511
+ let all_matched = true;
2512
+ for ( let file_obj of elem.files ) {
2513
+ let filename = file_obj.name.slice( 12, file_obj.name.length );
2514
+ prev_filenames.push( filename );
2515
+ // May over-match with names that end the same way
2516
+ if ( answer.includes( filename ) ) {
2517
+ shared_names_list.push( filename );
2518
+ } else {
2519
+ all_matched = false;
2520
+ }
2521
+ }
2522
+
2523
+ let result = {
2524
+ ok: false, prev_filenames, type: null,
2525
+ code: `ALK0283`, level: `info`, msgs: [], error: null,
2526
+ };
2527
+
2528
+ if (( prev_filenames?.length || 0) > 0 ) {
2529
+ result.level = `warn`; // log level
2530
+ result.type = `warning`; // report type
2531
+ let shared_names = shared_names_list.join(`", "`);
2532
+
2533
+ if ( shared_names_list.length > 0 ) {
2534
+ if ( all_matched ) {
2535
+ result.code = `ALK0284`;
2536
+ result.msgs.push(`Previously, you uploaded these files to this field: "${ answer }". Just now you tried to upload these files to this field: "${ shared_names }". Those names closely match. Did you mean them to be different?`);
2537
+ } else {
2538
+ result.code = `ALK0285`;
2539
+ result.msgs.push(`Previously, you uploaded these files to this field: "${ answer }". Just now you tried to upload these files to this field: "${ shared_names }". Some of those names closely match. Did you mean them to be different?`);
2540
+ }
2541
+ } else {
2542
+ result.code = `ALK0286`;
2543
+ result.msgs.push(`You already uploaded some files to the current field. Did you mean to do that?`);
2544
+ }
2545
+ }
2546
+
2547
+ let timeout_id = null;
2548
+ let resolve_on_change = function() {
2549
+ // bootstrap-fileinput starts reading
2550
+ result.ok = true; // May contain warnings
2551
+ clearTimeout( timeout_id );
2552
+ result.msgs.push(`Started uploading these files: "${ answer }"`);
2553
+ resolve( result );
2554
+ };
2555
+
2556
+ timeout_id = setTimeout(() => {
2557
+ elem.removeEventListener(`change`, resolve_on_change );
2558
+ result.level = `throw`;
2559
+ result.type = `error`;
2560
+ result.code = `ALK0281`;
2561
+ // Discuss: Will the trace be confusing from here?
2562
+ result.msgs.push(`It took more than ${( timeout - 100 )/1000 } seconds to try to upload the file(s) "${ answer }".`);
2563
+ // ⚠️ Unable to pass Error out of page.evaluate. Must use string.
2564
+ result.error = `Timeout error: It took too long to upload the files.`;
2565
+ resolve( result );
2566
+ }, timeout );
2567
+
2568
+ elem.value = null;
2569
+ elem.addEventListener(`change`, resolve_on_change );
2570
+ }); // ends Promise
2571
+
2572
+ }, scope.timeout - 100, answer ); // ends page.evaluate()
2084
2573
 
2085
- // This comes before the upload starts.
2086
- // Notice `await` is absent. Don't actually wait here yet.
2087
- // Start looking for it before it exists or we might miss it.
2088
- let wait_for_disable = scope.page.waitForSelector( disabled_selector );
2089
-
2090
- // Upload
2091
2574
  await handle.uploadFile(...paths);
2092
2575
 
2093
- // Halt progress to wait for the input to be disabled. If it already is, then this
2094
- // will have resolved already and will keep going.
2095
- await wait_for_disable;
2096
- // When it's again enabled, the files have finished loading. Or so I have observed.
2097
- await scope.page.waitForSelector( enabled_selector );
2576
+ // Wait for upload to finish. If already done, execution will continue
2577
+ let { ok, type, level, code, error, msgs } = await finish_uploading;
2098
2578
 
2099
- // Return that the row was used
2100
- return true;
2101
- }, // Ends scope.uploadFiles()
2579
+ let report_vals = msgs;
2580
+ if ( error ) {
2581
+ // ⚠️ Unable to get Error out of page.evaluate. Must create it here.
2582
+ error = new Error( error );
2583
+ report_vals.push( error );
2584
+ }
2585
+ if ( type !== null ) {
2586
+ reports.addToReport(scope, { type, code, value: report_vals.join(`\n`) });
2587
+ }
2588
+
2589
+ // The ternary calls, but the need for it smells like bad code to me.
2590
+ let log_type = `debug`;
2591
+ if ( level === `throw` ) { log_type = `throw`; }
2592
+ log[ log_type ]({ code, level, error, context: `file_upload`}, ...msgs );
2593
+
2594
+ // LLM Claude (version?) description: While the bootstrap-fileinput is
2595
+ // reading, it disables the input, adds `is-locked` to its container
2596
+ // (lock()), and adds `file-thumb-loading` (_toggleLoading("show")). We'll
2597
+ // wait till these are gone. See GitHub #1125. @plocket tested these
2598
+ // classes out and said they seem legit.
2599
+ await scope.page.waitForFunction(
2600
+ ( el ) => {
2601
+ const container = el.closest(`.file-input`);
2602
+ return (
2603
+ !el.disabled
2604
+ && !( container?.classList?.contains?.(`is-locked`)
2605
+ || container?.classList?.contains?.(`file-thumb-loading`)
2606
+ ));
2607
+ },
2608
+ { timeout: scope.timeout - 100 },
2609
+ handle,
2610
+ );
2611
+
2612
+ log.debug({ code: `ALK0297`, context: `file_upload`, level: `info`, },
2613
+ `Finished uploading files`
2614
+ );
2615
+
2616
+ // Return whether successfully used row
2617
+ return ok;
2618
+ }, // Ends scope.upload_files()
2102
2619
 
2103
2620
  setCheckbox: async function ( scope, { label, answer }) {
2104
2621
  // Depending on the current value/status of a checkbox and the desired
@@ -2150,7 +2667,7 @@ module.exports = {
2150
2667
  let canvas = await scope.page.waitForSelector( `#dasigcanvas` );
2151
2668
  let msg = `Could not find a signature field.`;
2152
2669
  if ( !canvas ) { reports.addToReport(scope, { type: `error`, code: `ALK0137`, value: msg }); }
2153
- expect( canvas, msg ).to.exist;
2670
+ expect( canvas, msg ).to.not.be.null;
2154
2671
 
2155
2672
  // Provides functionality to draw name arg on canvas
2156
2673
  if (name !== undefined) {
@@ -2163,7 +2680,9 @@ module.exports = {
2163
2680
  let bounding_box = await canvas.boundingBox();
2164
2681
  // We draw the dot because it allows the developer to tap continue
2165
2682
  // Docassemble does not allow to tap continue unless the user interacts with the canvas
2166
- await scope.page.mouse.move(bounding_box.x + bounding_box.width / 2, bounding_box.y + bounding_box.height / 2);
2683
+ let pen_x = (bounding_box.x + bounding_box.width / 2) + Math.random() * 50;
2684
+ let pen_y = (bounding_box.y + bounding_box.height / 2) + Math.random() * 50;
2685
+ await scope.page.mouse.move( pen_x, pen_y );
2167
2686
  await scope.page.mouse.down();
2168
2687
  // await scope.page.mouse.move(1, 1); // Too big? Wait a while before drawing?
2169
2688
  await scope.page.mouse.up();
@@ -2201,32 +2720,28 @@ module.exports = {
2201
2720
  return risky_id.toLowerCase().replace(/[^A-Za-z0-9]+/g, '-');
2202
2721
  },
2203
2722
 
2204
- examinePageID: async function ( scope, question_id = '' ) {
2205
- /* Looks for a sanitized version of the question id as it's written
2206
- * in the .yml. docassemble's way in python:
2207
- * re.sub(r'[^A-Za-z0-9]+', '-', interview_status.question.id.lower())
2208
- *
2209
- * @returns { question_has_id: bool, id: str, id_matches: bool }
2210
- */
2211
- let id_as_class = question_id.toLowerCase().replace(/[^A-Za-z0-9]+/g, '-');
2212
- // `.className` is the node's list of classes as a string, separated by spaces
2213
- let classes_str = await scope.page.$eval(`body`, function (elem) { return elem.className });
2214
- let ids_found = classes_str.match(/question-([^ ]+)/);
2215
-
2216
- // Data that we've decided is useful for our purposes
2217
- let found_any_question_id = ids_found !== null;
2218
- // If there is an id, see if it matches the class
2219
- let id = null;
2220
- let ids_match = false;
2221
- if ( found_any_question_id ) {
2222
- id = ids_found[1]
2223
- ids_match = id === id_as_class;
2224
- }
2225
-
2226
- // TODO: What are good names for these properties?
2227
- // question_has_id? found_any_id?
2228
- // class_matches_id? class_and_id_do_match? id_and_page_id_match?
2229
- return { question_has_id: found_any_question_id, id: id, id_matches: ids_match };
2723
+ examinePageID: async function ( scope, page_id = `` ) {
2724
+ /** Looks for a sanitized version of the question id as it's written
2725
+ * in the .yml. docassemble's way in python:
2726
+ * re.sub(r'[^A-Za-z0-9]+', '-', interview_status.question.id.lower())
2727
+ *
2728
+ * TODO: Replace use of this method with refactored functionality in
2729
+ * `current_id_matches`, `id_as_class_name`, and
2730
+ * `current_page_id_class_name`
2731
+ *
2732
+ * @param {Object} scope - Tests state object
2733
+ * @param {Object} obj - Named arguments
2734
+ * @param {str} obj.id_to_match - A docassemble page id, possibly in .yml
2735
+ * format
2736
+ *
2737
+ * @returns { question_has_id: bool, id: str, id_matches: bool }
2738
+ * */
2739
+ // let id_to_match = page_id.toLowerCase().replace(/[^A-Za-z0-9]+/g, '-');
2740
+ let id_to_match = await scope.id_as_class_name( scope, { page_id });
2741
+ let current_id = await scope.current_page_id_class_name( scope );
2742
+ let question_has_id = !!current_id || false;
2743
+ let id_matches = id_to_match === current_id;
2744
+ return { id: current_id, question_has_id, id_matches };
2230
2745
  }, // Ends scope.examinePageID()
2231
2746
 
2232
2747
  // TODO: Rename var_data to input_data?
@@ -2238,7 +2753,7 @@ module.exports = {
2238
2753
  ensure_navigation=true,
2239
2754
  ensure_all_vars_used=false
2240
2755
  }) {
2241
- /* Non-recursive function to set as many variables as possible before
2756
+ /** Non-recursive function to set as many variables as possible before
2242
2757
  * continuing to the next page. Important to avoid recursion because of async ops.
2243
2758
  * If it's not data in a table, the code can be more permissive about some input.
2244
2759
  *
@@ -2248,17 +2763,23 @@ module.exports = {
2248
2763
  * if navigation is not triggered another way.
2249
2764
  * @param ensure_all_vars_used {bool} [false] = Default false. If true, errors if any
2250
2765
  * object in var_data is not used during running of this function.
2251
- */
2252
- log.debug({ code: `ALK0221`, before: `~~~~~~~~~~~~~~\n`, },
2253
- `scope.setFields()`
2766
+ *
2767
+ * @returns {Object} obj - Named return values
2768
+ * @returns {bool} obj.was_found - True if the check found any errors
2769
+ * @returns {bool} obj.ok - False if the check found any errors. Discuss:
2770
+ * Should we only use `ok` for internal errors?
2771
+ * @param {user|system} obj.type - "user" with invalid answers, "system"
2772
+ * with da or AL server errors
2773
+ * @param {[ElementHandle]} obj.error_handles - List of puppeteer element
2774
+ * handles the check found. There can be multiple invalid answer elems.
2775
+ * */
2776
+ log.debug({ code: `ALK0221`, before: `\n~~~~~~~~~~~~~~\n`, },
2777
+ `Starting scope.setFields()`
2254
2778
  );
2255
2779
 
2256
- // Only record the page id once for each page to which we navigate
2257
- let { id } = await scope.examinePageID( scope, 'none to match' );
2258
- if ( scope.page_id !== id ) {
2259
- reports.addToReport(scope, { type: `page_id info`, code: `ALK0138`, value: `${ id }`, });
2260
- scope.page_id = id;
2261
- }
2780
+ let error_data = null;
2781
+
2782
+ let id = await scope.log_and_update_if_new_page_id( scope );
2262
2783
 
2263
2784
  let html = await scope.page.content();
2264
2785
  // These are the fields on the current page. Their handles should all exist.
@@ -2269,7 +2790,6 @@ module.exports = {
2269
2790
  // again until nothing more can be set.
2270
2791
  let something_new_was_set = true; // So loop can start
2271
2792
  while ( something_new_was_set ) {
2272
-
2273
2793
  something_new_was_set = false; // start fresh
2274
2794
  // Go through each field on the page, seeing if we can set its variable
2275
2795
  for ( let field of fields ) {
@@ -2278,7 +2798,9 @@ module.exports = {
2278
2798
  if ( field.was_set ) { continue; }
2279
2799
  // Avoid navigating in this `while`. Will do this later so we get to
2280
2800
  // set all fields in here first. TODO: Can links (<a>) set vars?
2281
- if ( field.tag === 'button' && field.type === 'submit' ) { continue; }
2801
+ if (
2802
+ scope.is_button_ish( scope, { field }) && field.type === `submit`
2803
+ ) { continue; }
2282
2804
 
2283
2805
  // Try to set other types of fields
2284
2806
  let row_used = await scope.setVariable( scope, { field, var_data });
@@ -2303,13 +2825,11 @@ module.exports = {
2303
2825
  if ( session_vars.get_debug() ) { await time.waitForTimeout(2000); }
2304
2826
 
2305
2827
  // Press a continue button with a story table variable name if it exists
2306
- let did_navigate = false;
2307
- let error = {};
2828
+ let navigated = false;
2308
2829
  for ( let field of fields ) {
2309
2830
  // All other fields should have been set
2310
- if ( field.tag === 'button' && field.type === 'submit' ) {
2831
+ if ( scope.is_button_ish( scope, { field }) && field.type === 'submit' ) {
2311
2832
  let page_url = await scope.page.url();
2312
-
2313
2833
  let row_used = await scope.setVariable( scope, { field, var_data, });
2314
2834
  if ( row_used ) {
2315
2835
 
@@ -2321,26 +2841,12 @@ module.exports = {
2321
2841
 
2322
2842
  // Esp important for `.target_number`
2323
2843
  await scope.increment_row_use( scope, { row: row_used });
2844
+ ({ error_data, navigated } = await scope.wait_until_continued( scope, {
2845
+ url: page_url, id
2846
+ }));
2324
2847
 
2325
- // TODO: special continue/navigation button function that handles url and all?
2326
-
2327
- // Have to catch errors right now or may get stuck in an infinite loop?
2328
- // TODO: Don't catch errors - maybe the developer expected/wanted an error
2329
- // For now they'll have to stop the story table early and do the rest the slow way
2330
- // TODO: Discuss: add the option of throwing when an error happens? Per page?
2331
-
2332
- // The developer said to continue by defining this row.
2333
- // For now, that means this page should not cause any kind of error.
2334
- // Make sure no system or user error messages appear
2335
- // Discuss moving this out of here. Having to pass in `id` just for
2336
- // these messages smells bad. Con: Already a lot of code out there.
2337
- error = await scope.waitUntilContinued( scope, { url: page_url, id });
2338
- // if ( error.was_found ) {
2339
- // await scope.throwPageError( scope, { id: id });
2340
- // }
2341
-
2342
- did_navigate = true;
2343
- log.stdout({}, `\x1b[36m${ 'v' }\x1b[0m`); // pressed button successfully
2848
+ // pressed button to set var successfully
2849
+ log.stdout({}, `\x1b[36m${ 'v' }\x1b[0m`);
2344
2850
  break;
2345
2851
 
2346
2852
  } // ends if row_used
@@ -2351,59 +2857,143 @@ module.exports = {
2351
2857
  if ( ensure_all_vars_used ) {
2352
2858
  for ( let row of var_data ) {
2353
2859
  if ( row.times_used === 0 ) {
2354
- let values_used_status = 'The page is missing a value';
2355
2860
  let source_row = row;
2356
2861
  // TODO: Make the row object flatter so we don't need to dig in this way
2357
2862
  if ( row.source ) { source_row = row.source; }
2358
2863
  let original_row = source_row.original;
2359
2864
  let { id } = await scope.examinePageID( scope, 'none to match' );
2360
2865
  let msg = `In a linear Step all the values you give MUST be filled in, but ALKiln was unable `
2361
- + `to find a field on the page "${id}" for the variable "${ original_row.var }" that `
2362
- + `could be set to "${ original_row.value }". See this Scenario's HTML file for details.`;
2363
- reports.addToReport(scope, { type: `error`, code: `ALK0141`, value: msg });
2364
- expect( values_used_status, msg ).to.equal( '' );
2866
+ + `to find a field on the page with the "${ id }" for the variable "${ original_row.var }" that `
2867
+ + `could be set to "${ original_row.value }". Alternatively, you`
2868
+ + ` may have duplicate rows in your table. See this Scenario's HTML`
2869
+ + ` file for details.`;
2870
+ let err_msg = reports.addToReport(scope, { type: `error`, code: `ALK0141`, value: msg });
2871
+ throw err_msg;
2365
2872
  }
2366
2873
  }
2367
2874
  }
2368
2875
 
2369
2876
  // If didn't already press a continue button, press one now.
2370
- if ( !did_navigate && ensure_navigation ) {
2371
- // All fields on this page should be taken care of
2877
+ if ( !navigated && ensure_navigation ) {
2878
+ // All fields on this page should have been taken care of already
2372
2879
  let page_url = await scope.page.url();
2373
- await scope.continue( scope );
2374
- // Have to catch errors right now or may get stuck in an infinite loop?
2375
- // TODO: Don't catch errors - maybe the developer expected/wanted an error
2376
- // For now they'll have to stop the story table early and do the rest the slow way
2377
- // TODO: Discuss: add the option of throwing when an error happens? Per page?
2378
-
2379
- // Make sure no system or user error messages appear
2380
- // Discuss moving this out of here. Having to pass in `id` just for
2381
- // these messages smells bad. Con: Already a lot of code out there.
2382
-
2383
- // TODO: How do we let 'I set the var __ to __' also afterwards check for an invalidation message?
2384
- error = await scope.waitUntilContinued( scope, { url: page_url, id });
2385
- // if ( error.was_found ) {
2386
- // await scope.throwPageError( scope, { id: id });
2387
- // }
2388
-
2389
- did_navigate = true;
2390
- log.stdout({}, `\x1b[36m${ '>' }\x1b[0m`); // continued successfully
2391
- } // ends if !did_navigate && ensure_navigation
2392
-
2393
- if ( did_navigate && scope.check_all_for_a11y ) {
2880
+ error_data = await scope.continue( scope );
2881
+
2882
+ navigated = true;
2883
+ log.stdout({}, `\x1b[36m${ '>' }\x1b[0m`); // continued successfully automatically
2884
+ } // ends if !navigated && ensure_navigation
2885
+
2886
+ if ( navigated && scope.check_all_for_a11y ) {
2394
2887
  let { passed } = await scope.checkForA11y(scope);
2395
2888
  if (!passed) {
2396
2889
  scope.passed_all_for_a11y = false;
2397
2890
  }
2398
2891
  }
2399
2892
 
2400
- // TODO: Since we now continue only once no matter what, we can put
2401
- // the error in the caller of the function, though we may want
2402
- // to pass the symbol to print as well.
2403
- return error;
2893
+ log.debug({ code: `ALK0288`, context: `scenario`, level: `note`,
2894
+ before: `\n~~~~~~~~~~~~~~\n` }, `Finished scope.setFields()`
2895
+ );
2896
+ return error_data;
2404
2897
  // TODO: Maybe we should return something else or something in addition about used vars
2405
2898
  }, // Ends scope.setFields()
2406
2899
 
2900
+ is_button_ish: function ( scope, { field = null }) {
2901
+ /**
2902
+ * Return true if the field is similar enough to a button, else false. This
2903
+ * does NOT determine whether the field submits the form, but does
2904
+ * assume the intention is navigation.
2905
+ *
2906
+ * Discuss: Use "btn" class as well?
2907
+ *
2908
+ * @param {Object} scope - Tests state object
2909
+ * @param {Object} field - One field from `.getAllFields()`
2910
+ *
2911
+ * @returns bool
2912
+ * */
2913
+ if ( field.tag === `button`
2914
+ || field.selector.includes(`role="button"`)
2915
+ || field.selector.includes(`.dasigsave`)
2916
+ || field.selector.includes(`.daquestionactionbutton`)
2917
+ ) { return true; }
2918
+ return false;
2919
+ },
2920
+
2921
+ log_and_update_if_new_page_id: async function ( scope ) {
2922
+ /**
2923
+ * Log the page id if we're on a new page. Abstracting this avoids adding
2924
+ * multiple log codes.
2925
+ *
2926
+ * Discuss: This triggers 2 puppeteer evals. Only 1 is really necessary. How
2927
+ * many pages id checks would make that speed hit matter? Consider copying
2928
+ * `.current_id_matches()` code in here to get the id just once.
2929
+ *
2930
+ * @param {Object} scope - Tests state object
2931
+ *
2932
+ * @returns {str} - the current page .yml id as an HTML class
2933
+ * */
2934
+ let ids_match = await scope.current_id_matches( scope, {
2935
+ id_to_match: scope.page_id
2936
+ });
2937
+ if ( !ids_match ) {
2938
+ let new_id = await scope.current_page_id_class_name( scope );
2939
+ reports.addToReport(scope, { type: `page_id info`, code: `ALK0138`, value: `${ new_id }`, });
2940
+ scope.page_id = new_id;
2941
+ }
2942
+ return scope.page_id;
2943
+ },
2944
+
2945
+ current_id_matches: async function ( scope, { id_to_match=`` }) {
2946
+ /**
2947
+ * Returns true if the given docassemble page id matches the current page id
2948
+ *
2949
+ * @param {Object} scope - Tests state object
2950
+ * @param {Object} obj - Named arguments
2951
+ * @param {str} obj.id_to_match - A docassemble page id, possibly in .yml
2952
+ * format
2953
+ *
2954
+ * @returns {bool} - true if the ids match, otherwise false. Missing ids will match.
2955
+ * */
2956
+ let class_to_match = scope.id_as_class_name( scope, { page_id: id_to_match });
2957
+ let current_id_class = await scope.current_page_id_class_name( scope );
2958
+ return class_to_match === current_id_class;
2959
+ },
2960
+
2961
+ id_as_class_name: function ( scope, { page_id=`` }) {
2962
+ /**
2963
+ * Converts a given page id string (possibly originating from the interview
2964
+ * .yml) as a class name as docassemble does, then returns that value.
2965
+ * This is useful because the .yml page id is included in the DOM as a
2966
+ * class name. docassemble's python code:
2967
+ *
2968
+ * re.sub(r'[^A-Za-z0-9]+', '-', interview_status.question.id.lower())
2969
+ *
2970
+ * @param {Object} scope - Tests state object
2971
+ * @param {Object} obj - Named arguments
2972
+ * @param {str} obj.page_id - A docassemble page id, possibly in .yml format
2973
+ *
2974
+ * @returns {str | null} - the given str as a docassemble-formatted
2975
+ * HTML class name or `null` if `page_id` is not a string.
2976
+ * */
2977
+ return page_id?.toLowerCase?.().replace(/[^A-Za-z0-9]+/g, `-`) || null;
2978
+ },
2979
+
2980
+ current_page_id_class_name: async function ( scope ) {
2981
+ /**
2982
+ * Returns the current page id as an HTML class name or null if missing
2983
+ *
2984
+ * @param {Object} scope - Tests state object
2985
+ *
2986
+ * @returns {str | null} - Current page id as a class name or `null` if none
2987
+ * exists
2988
+ * */
2989
+ // `.className` is a space-sparated list of the node's classes
2990
+ let possible_id_classes = await scope.page.$eval(`body`, function (elem) {
2991
+ return elem.className
2992
+ });
2993
+ let id_matches = possible_id_classes?.match(/question-([^ ]+)/);
2994
+ return id_matches?.[1] || null;
2995
+ },
2996
+
2407
2997
  increment_row_use: async function ( scope, { row }) {
2408
2998
  /** Accumulate number of times this row was used. Esp important for `.target_number`.
2409
2999
  * Mutates `row` and its props. */
@@ -2636,89 +3226,378 @@ module.exports = {
2636
3226
  }
2637
3227
  }, // Ends scope.has_interview_content()
2638
3228
 
2639
- waitUntilContinued: async function ( scope, { url = '', id = '' }) {
2640
- /** Given a url, attempt to continue until the page url changes or an error
2641
- is found. Return the error object no matter what. */
2642
- let prev_url = url;
3229
+ // ⚠️ Keep text of `id=...` unique from others to identify problems better
3230
+ wait_until_continued: async function ( scope, {
3231
+ url=``, id=`Unknown id`, post_nav_handlers=null
3232
+ }) {
3233
+ /**
3234
+ * Given a url and id of an initial page, attempt to detect navigation.
3235
+ * After the results of the navigation attempt, run the post_nav_handlers.
3236
+ *
3237
+ * ‼️ ALWAYS include required critical error handlers in `post_nav_handlers`
3238
+ * that will throw errors. We need the post-navigation handlers in this
3239
+ * order:
3240
+ *
3241
+ * 1.(required) A page with a visible system error is a critical error and always
3242
+ * trumps other errors, so it comes first
3243
+ * 2. (optional) Invalid answers and other mmore specific handlers trump unspecified
3244
+ * nav errs
3245
+ * 3. (required) Navigation attempt that timed out is a critical error that lacks
3246
+ * further information
3247
+ * 4. (optional) Unspecified non-navigation catchall comes last
3248
+ *
3249
+ * See docs/decisions/017_post_navigation_custom_behavior_2026_10_26.md for
3250
+ * a deeper discussion.
3251
+ *
3252
+ * Unfortunately, our navigation detection game is weaksauce. Navigation
3253
+ * detection thoughts: https://github.com/SuffolkLITLab/ALKiln/issues/229
3254
+ *
3255
+ * @param {Object} scope - Tests state object
3256
+ * @param {Object} kwargs - Named arguments
3257
+ * @param {str} kwargs.url - Assumed to be the url of the initial page
3258
+ * @param {str} kwargs.id - ID of the initial page
3259
+ * @param {[function]} [kwargs.post_nav_handlers] - Functions to handle page
3260
+ * errors like system errors, user errors (invalid answers), and
3261
+ * navigation failure
3262
+ *
3263
+ * @returns {Object} kwrvs - Named return values
3264
+ * @returns {bool} kwrvs.navigated - True if the interview got to a new page
3265
+ * @returns {Object} kwrvs.error_data - Data for any errors found
3266
+ * @returns {bool} kwrvs.error_data.was_found - True if the check found any
3267
+ * errors
3268
+ * @returns {bool} kwrvs.error_data.ok - False if the check found any errors.
3269
+ * Discuss: Should we only use `ok` for internal errors?
3270
+ * @returns {`user`|`system`} kwrvs.error_data.type - "user" with invalid
3271
+ * answers, "system" with da or AL server errors
3272
+ * @returns {[ElementHandle]} kwrvs.error_data.error_handles - List of
3273
+ * puppeteer element handles the check found. There can be multiple
3274
+ * invalid answer elements.
3275
+ * */
3276
+ await scope.log_and_update_if_new_page_id( scope );
3277
+
3278
+ post_nav_handlers ??= [
3279
+ scope.throw_on_system_error,
3280
+ scope.throw_on_invalid_answers,
3281
+ scope.throw_on_non_navigation_timeout,
3282
+ scope.throw_on_non_navigation_catchall,
3283
+ ];
3284
+ scope.throw_if_missing_required_post_nav_handlers( scope, { handlers: post_nav_handlers })
3285
+
3286
+ let initial_id = id;
3287
+ let initial_url = url;
2643
3288
  let curr_url = await scope.page.url();
2644
- let error = await scope.checkForError( scope );
2645
-
2646
- let timedOut = false; // Lazy timeout?
2647
- let cont_timeout = setTimeout(function(){ timedOut = true }, scope.timeout);
2648
-
2649
- // Attempt to solve weird race condition issues where page has not properly continued
2650
- while ( curr_url === prev_url && !error.was_found && !timedOut ) {
3289
+ let error_data = await scope.checkForErrors( scope );
3290
+
3291
+ // Attempt to solve weird race condition issues where page has not properly
3292
+ // continued. May exceed actual current timeout.
3293
+ // Discuss: Use a global clock to properly track of remaining time to avoid
3294
+ // scope.timeout - 100 and such? Any such cucumber or puppeteer feature?
3295
+ let timed_out = false; // Lazy timeout?
3296
+ let nav_timeout = setTimeout(function(){ timed_out = true }, scope.timeout - 100);
3297
+ while ( curr_url === initial_url && error_data.ok && !timed_out ) {
2651
3298
  await time.waitForTimeout( 50 );
3299
+ error_data = await scope.checkForErrors( scope );
2652
3300
  curr_url = await scope.page.url();
2653
- error = await scope.checkForError( scope );
2654
3301
  }
3302
+ clearTimeout( nav_timeout );
2655
3303
 
2656
- let timeout_err_message = `The test tried to continue to the next page, but it took longer than ${ (scope.timeout/1000)/60 } min. Check the picture of this page. If a secret variable was used, there will be no picture.`
2657
- if ( timedOut ) { reports.addToReport(scope, { type: `error`, code: `ALK0146`, value: timeout_err_message }); }
2658
- expect( timedOut, timeout_err_message ).to.be.false;
3304
+ // TODO: Review architecture - can we refactor nav out of global state?
3305
+ scope.navigated = curr_url !== initial_url;
2659
3306
 
2660
- clearTimeout( cont_timeout );
3307
+ error_data = await scope.checkForErrors( scope );
3308
+ if ( !error_data.ok ) {
3309
+ for ( let handler of post_nav_handlers ) {
3310
+ await handler( scope, { id: initial_id, error_data, timed_out });
3311
+ }
3312
+ }
2661
3313
 
2662
- return error;
2663
- }, // Ends scope.waitUntilContinued()
3314
+ return { error_data, navigated: curr_url !== initial_url };
3315
+ }, // Ends scope.wait_until_continued()
3316
+
3317
+ throw_if_missing_required_post_nav_handlers: function( scope, { handlers=[] }={}) {
3318
+ /**
3319
+ * Return true if the list of functions contains the required handlers,
3320
+ * otherwise throw an internal error.
3321
+ *
3322
+ * @param {Object} scope - Tests state object
3323
+ * @param {Object} kwargs - Named arguments
3324
+ * @param {[func]} kwargs.handlers - List of functions
3325
+ *
3326
+ * @returns {bool} - True if function completes successfully
3327
+ * */
3328
+ let missing_handler_errors = [];
3329
+ if ( !handlers.includes( scope.throw_on_system_error ) ) {
3330
+ let missing_system_msg = log.debug({ code: `ALK0295`, context: `internal nav_handlers`, level: `error` },
3331
+ `ALKiln's internal list of post-navigation handlers must include "scope.throw_on_system_error()"`
3332
+ );
3333
+ missing_handler_errors.push( missing_system_msg );
3334
+ }
3335
+ if ( !handlers.includes( scope.throw_on_non_navigation_timeout ) ) {
3336
+ let missing_timeout_msg = log.debug({ code: `ALK0296`, context: `internal nav_handlers`, level: `error` },
3337
+ `ALKiln's internal list of post-navigation handlers must include "scope.throw_on_non_navigation_timeout()"`
3338
+ );
3339
+ missing_handler_errors.push( missing_timeout_msg );
3340
+ }
2664
3341
 
2665
- checkForError: async function ( scope ) {
2666
- /* Returns whether any system or user error elements were found and, if so,
2667
- * returns those elements. The error elements are probably exclusive, but
2668
- * we're not sure. */
3342
+ if ( missing_handler_errors.length > 0 ) {
3343
+ throw ReferenceError( missing_handler_errors.join(`\n`) );
3344
+ }
2669
3345
 
2670
- // All results are lists to match `$x`
2671
- let possible_errors = {
2672
- complex_user_error: await scope.page.$$(`.alert-danger`),
2673
- // The below does not work if there's not a space between ':' and 'none'
2674
- simple_user_error: await scope.page.$$(`xpath/.//*[contains(@class, "da-has-error")][not(contains(@style,"display: none"))]`),
2675
- system_error: await scope.page.$$(`xpath/.//meta[@content="docassemble: Error"]` ),
2676
- // Hardcoding the `al_custom_error_action` screen (that's what the base64 here is). It hides actual errors and looks like a continue screen, but isn't.
2677
- al_custom_error: await scope.page.$$('.question-custom-error-action .daattributions #trigger[data-variable="YWxfY3VzdG9tX2Vycm9yX2FjdGlvbg"]'),
2678
- };
3346
+ return true;
3347
+ },
2679
3348
 
2680
- let error_handlers = {};
3349
+ validation_err_selector: `#daflash .alert-danger`,
3350
+ // Discuss: `validation code` message can be on a page even after user fixed
3351
+ // values. Always dismiss `validation code` before trying to continue? Or
3352
+ // just detect navigation through requests and responses, which we need to
3353
+ // switch to anyway.
3354
+ // ⚠️ 'display:' and 'none' MUST be separated by a space
3355
+ invalid_field_selector: `xpath/.//*[contains(@class, "da-has-error")][not(contains(@style,"display: none"))]`,
3356
+ // ⚠️ Assumptions: ": Error" stays in the metadata of the page and #damain
3357
+ // only appears on da error pages
3358
+ sys_err_selector: `xpath/.//meta[contains(@content, ": Error")]|.//*[@id="damain"]//h1[contains(text(), "Error")]`,
3359
+ // `al_custom_error_action` in base64. Replaces system error pages, but acts
3360
+ // like a question screen. Shows "The error details are no longer
3361
+ // available..." message when GET requests error (default da message?)
3362
+ al_err_selector: `.question-custom-error-action .daattributions #trigger[data-variable="YWxfY3VzdG9tX2Vycm9yX2FjdGlvbg"]`,
3363
+ checkForErrors: async function ( scope ) {
3364
+ /**
3365
+ * Return any elements from invalid user answers or system errors & other
3366
+ * data. Avoid throwing. Invalid answers may be the author's goal.
3367
+ *
3368
+ * Note: May later want the actual text of the invalid answer messages.
3369
+ *
3370
+ * @param {Object} scope - Tests state object
3371
+ *
3372
+ * @returns {Object} obj - Named return values
3373
+ * @returns {bool} obj.was_found - True if the check found any errors
3374
+ * @returns {bool} obj.ok - False if the check found any errors. Discuss:
3375
+ * Should we only use `ok` for internal errors?
3376
+ * @param {user|system} obj.type - "user" with invalid answers, "system"
3377
+ * with da or AL server errors
3378
+ * @param {[ElementHandle]} obj.error_handles - List of puppeteer element
3379
+ * handles the check found. There can be multiple invalid answer elems.
3380
+ * */
3381
+ // ⚠️ Keep these error objs in this order. For now, the first item on the
3382
+ // list is the one we later use, and `system` errors have priority.
3383
+ let possible_error_types = [
3384
+ { type: `system`, handles: await scope.page.$$( scope.sys_err_selector ) },
3385
+ { type: `system`, handles: await scope.page.$$( scope.al_err_selector ) },
3386
+ { type: `user`, handles: await scope.page.$$( scope.validation_err_selector ) },
3387
+ { type: `user`, handles: await scope.page.$$( scope.invalid_field_selector ) },
3388
+ ];
3389
+
3390
+ // Can have multiple user errors which we may get the text of in future
3391
+ let error_handles = [];
2681
3392
  let was_found = false;
2682
- for ( let error_key in possible_errors ) {
2683
- if ( possible_errors[ error_key ][0] ) {
2684
- // If there's a page visible (locally), give us as much time to examine it as timeout allows.
2685
- // Because of previous actions, this may still be a bit too long.
2686
- // Also, if you're debugging and you close the window early, the process will still
2687
- // take this long to exit after the tests finish. Not sure how to account for that.
3393
+ let type = null;
3394
+ for ( let one_err_type of possible_error_types ) {
3395
+ if ( one_err_type.handles?.[0] ) {
3396
+ // If there's a page visible (locally), give us almost as much time to
3397
+ // examine it as timeout allows. Because of previous actions, this may
3398
+ // still be a bit too long.
3399
+ // Also, if you're debugging and you close the window early, the process
3400
+ // will still take this long to exit after the tests finish. May fix
3401
+ // in future, but it's just during debugging, so yolo for now!
2688
3402
  if ( session_vars.get_debug() ) {
2689
3403
  await time.waitForTimeout( ( scope.timeout - (1 * 1000)) );
2690
3404
  }
3405
+
2691
3406
  was_found = true;
2692
- error_handlers[ error_key ] = possible_errors[ error_key ][0];
2693
- }
3407
+ error_handles = error_handles.concat( one_err_type.handles )
3408
+ if ( !type || one_err_type.type == `system` ) {
3409
+ type = one_err_type.type
3410
+ }
3411
+ } // ends if first handler
3412
+ } // ends for each error type
3413
+
3414
+ return { was_found, ok: !was_found, type, error_handles };
3415
+ }, // Ends scope.checkForErrors()
3416
+
3417
+ throw_on_system_error: async function ( scope, {
3418
+ error_data=null, id=`Unknown ID`
3419
+ }) {
3420
+ /**
3421
+ * Throw if there is a system/server error. Include some page text.
3422
+ *
3423
+ * @param {Object} scope - Tests state object
3424
+ * */
3425
+ if ( error_data?.type === `system` ) {
3426
+ let alk_msg = [`The test ran into a system error before or on the page with the id "${ id }". The picture of this page, if available, may show more.`];
3427
+ let report_msg = reports.addToReport(scope, {
3428
+ type: `error`, code: `ALK0147`,
3429
+ value: [ alk_msg, ``, await scope.get_system_err( scope ) ].join(`\n`),
3430
+ });
3431
+ throw Error( report_msg );
2694
3432
  }
2695
3433
 
2696
- return { was_found, error_handlers };
2697
- }, // Ends scope.checkForError()
3434
+ return scope;
3435
+ }, // Ends scope.throw_on_system_error()
2698
3436
 
2699
- throwPageError: async function ( scope, { error_handlers = {}, id = '' }) {
2700
- /* Throw a cucumber error with the error that appeared on the page. */
2701
- if ( error_handlers.system_error !== undefined ) {
2702
- let sys_err_message = `The test tried to continue to the next page after ${ id }, but there was a system error. Check the picture of this page.`
2703
- reports.addToReport(scope, { type: `error`, code: `ALK0147`, value: sys_err_message });
2704
- expect( was_system_error, sys_err_message ).to.be.false;
3437
+ get_system_err: async function ( scope, { num_lines=8 }={}) {
3438
+ /**
3439
+ * Returns a sample of error-related text on a docassemble or Assembly Line
3440
+ * system error page
3441
+ *
3442
+ * Note: YWxfZm9ybWF0dGVkX2Vycm9yX21lc3NhZ2U is "al_formatted_error_message"
3443
+ *
3444
+ * @param {Object} scope - Tests state object
3445
+ * @param {Object} obj - Named arguments
3446
+ * @param {int} obj.num_lines - Number of lines of HTML text to return
3447
+ *
3448
+ * @returns {str} - A sample of the HTML related to the system error
3449
+ * */
3450
+ let truncated_text = `Unable to get the text on this page. Check the picture of this page if possible.`
3451
+ try {
3452
+
3453
+ let page_err_text = await scope.page.evaluate(() => {
3454
+ return (
3455
+ ( document.querySelector(`#YWxfZm9ybWF0dGVkX2Vycm9yX21lc3NhZ2U`)
3456
+ || document.querySelector(`.dabody`)
3457
+ ).textContent
3458
+ );
3459
+ });
2705
3460
 
2706
- } else {
2707
- // If it wasn't a system error, it was a user error
2708
- let user_err_message = `The test tried to continue to the next page after ${ id }, but the user got an error. Check the picture of this page.`
2709
- reports.addToReport(scope, { type: `error`, code: `ALK0148`, value: user_err_message });
2710
- expect( true, user_err_message ).to.be.false;
3461
+ log.debug({ code: `ALK0287`, context: `page_error`, level: `info` },
3462
+ `Full text of page error:`, page_err_text
3463
+ );
3464
+
3465
+ truncated_text = page_err_text.split(`\n`)
3466
+ .slice( 0, num_lines )
3467
+ .join(`\n`)
3468
+ .trim()
3469
+ + `\n...`;
3470
+
3471
+ } catch ( pupp_err ) {
3472
+ log.debug({ code: `ALK0292`, context: `internal`, level: `note` },
3473
+ `Skipped getting system error screen text with puppeteerjs:`, pupp_err,
3474
+ );
2711
3475
  }
2712
3476
 
2713
- return scope;
2714
- }, // Ends scope.throwPageError()
3477
+ return truncated_text;
3478
+ }, // Ends scope.get_system_err()
3479
+
3480
+ throw_on_invalid_answers: async function ( scope, {
3481
+ error_data={}, id=`Unknown ID`
3482
+ }={}) {
3483
+ /**
3484
+ * Throw an error if the user has given invalid answers. If the author
3485
+ * simply wants to detect invalid answers and avoid throwing, use
3486
+ * `scope.checkForErrors()`.
3487
+ *
3488
+ * Discuss: Change our architecture to handle throwing and reporting in some
3489
+ * central location down/up-stream. Here, for example, I want to add an
3490
+ * error to the report, but "I can't continue" is catching this error and
3491
+ * letting the test pass. How can we handle that? Allow editing the report
3492
+ * after the fact? Throwing the report message with log code and all and
3493
+ * count on the caller to report it?
3494
+ *
3495
+ * @param {Object} scope - Tests state object
3496
+ * @param {Object} [obj] - Named arguments
3497
+ * @param {Object} [obj.error_data] - Maybe data for invalid answers' markers
3498
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3499
+ *
3500
+ * @returns {Object} - Error data given
3501
+ * */
3502
+ if ( !error_data.ok && error_data.type === `user` ) {
3503
+ let err_msg = `The user got an invalid answer on the page with the id "${ id }". You can check the picture of this page unless you used secrets.`;
3504
+ let report_msg = reports.addToReport(scope, { type: `error`, code: `ALK0148`, value: err_msg });
3505
+ throw Error( report_msg );
3506
+ }
3507
+
3508
+ return error_data;
3509
+ }, // Ends scope.throw_on_invalid_answers()
3510
+
3511
+ warn_on_invalid_answers: async function ( scope, {
3512
+ error_data={}, id=`Unknown ID`
3513
+ }={}) {
3514
+ /**
3515
+ * Add a warning to the report if the page has invalid answer indicators.
3516
+ *
3517
+ * @param {Object} scope - Tests state object
3518
+ * @param {Object} [obj] - Named arguments
3519
+ * @param {Object} [obj.error_data] - Maybe data for invalid answers' markers
3520
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3521
+ *
3522
+ * @returns {Object} - Error data given
3523
+ * */
3524
+ let initial_id = id;
3525
+ if ( !error_data.ok && error_data.type === `user` ) {
3526
+ // TOOD: Add a deprecation message to Steps that call this
3527
+ let report_msg = reports.addToReport(scope, {
3528
+ type: `warning`, code: `ALK0290`, value: `The user got an invalid answer on the page with the id "${ initial_id }".`
3529
+ });
3530
+ }
3531
+
3532
+ return error_data;
3533
+ }, // Ends scope.warn_on_invalid_answers()
3534
+
3535
+ throw_on_non_navigation_timeout: async function ( scope, {
3536
+ timed_out=false, id=`Unknown ID`
3537
+ }={}) {
3538
+ /**
3539
+ * Throw an error if the page hasn't navigated and the nav check timed out.
3540
+ *
3541
+ * @param {Object} scope - Tests state object
3542
+ * @param {Object} [obj] - Named arguments
3543
+ * @param {Object} [obj.timed_out] - Whether navigation timed out
3544
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3545
+ *
3546
+ * @returns {undefined}
3547
+ * */
3548
+ let initial_id = id;
3549
+ let timeout_err_message = `The test tried to navigate away from the page with the id "${ initial_id }", but it took longer than ${ (scope.timeout/1000)/60 } min. Unless you used a secret, you can check the picture of this page.`
3550
+ if ( !scope.navigated && timed_out ) {
3551
+ reports.addToReport(scope, { type: `error`, code: `ALK0146`, value: timeout_err_message });
3552
+ throw Error( timeout_err_message );
3553
+ }
3554
+ },
3555
+
3556
+ throw_on_non_navigation_catchall: async function ( scope, { id=`Unknown ID` }={}) {
3557
+ /**
3558
+ * Throw an error if the page hasn't navigated. Used in cases where we
3559
+ * haven't identified why the interaction didn't navigate.
3560
+ *
3561
+ * @param {Object} scope - Tests state object
3562
+ * @param {Object} [obj] - Named arguments
3563
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3564
+ *
3565
+ * @returns {undefined}
3566
+ * */
3567
+ let initial_id = id;
3568
+ if ( !scope.navigated ) {
3569
+ let non_nav_msg = `Was unable to navigate away from page with the id "${ initial_id }".`
3570
+ reports.addToReport(scope, { type: `error`, code: `ALK0291`, value: non_nav_msg });
3571
+ throw Error( non_nav_msg );
3572
+ }
3573
+ },
3574
+
3575
+ warn_on_non_navigation_catchall: async function ( scope, { id=`Unknown ID` }={}) {
3576
+ /**
3577
+ * Warn if the page hasn't navigated. Used in cases where we haven't
3578
+ * identified why the interaction didn't navigate.
3579
+ *
3580
+ * @param {Object} scope - Tests state object
3581
+ * @param {Object} [obj] - Named arguments
3582
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3583
+ *
3584
+ * @returns {undefined}
3585
+ * */
3586
+ let initial_id = id;
3587
+ if ( !scope.navigated ) {
3588
+ let non_nav_msg = `Was unable to navigate away from page with the id "${ initial_id }".`
3589
+ reports.addToReport(scope, { type: `warning`, code: `ALK0293`, value: non_nav_msg });
3590
+ }
3591
+ },
2715
3592
 
2716
3593
  take_a_screenshot: async ( scope,{ path }) => {
2717
3594
  /* Takes a jpeg screenshot. Avoids destroying signatures. */
2718
3595
 
2719
3596
  let fullPage = true;
2720
- let signature_elem = await scope.page.$(scope.signature_selector);
2721
- signature_elem = signature_elem || await scope.page.$(scope.signature_selector_backup);
3597
+ // Use only $ as opposed to $$ - just checking for presence of any element
3598
+ let signature_elem = await scope.page.$(scope.signature_cont_btn_selector_da_v_1_9_n_and_earlier, { visible: true })
3599
+ || await scope.page.$(scope.signature_cont_btn_selector_backup_da_v_1_9_n_and_earlier, { visible: true })
3600
+ || await scope.page.$(scope.signature_cont_btn_selector_da_v_1_10_n_and_later, { visible: true });
2722
3601
  if ( signature_elem !== null ) {
2723
3602
  fullPage = false;
2724
3603
  }
@@ -2932,7 +3811,7 @@ module.exports = {
2932
3811
  /** Given a fetch response headers' Content-Disposition str, return the
2933
3812
  * filename of the response's file.
2934
3813
  *
2935
- * @return { string | null } - Name of fetched file
3814
+ * @returns { string | null } - Name of fetched file
2936
3815
  * */
2937
3816
  let filename = default_name;
2938
3817
  if ( disposition ) {
@@ -2948,15 +3827,13 @@ module.exports = {
2948
3827
  }
2949
3828
 
2950
3829
  // TODO: Add debug log here
2951
- // console.log(`filename:`, filename);
2952
-
2953
3830
  // TODO: Add to the report if we had to use the default name
2954
3831
 
2955
3832
  return filename;
2956
3833
  }, // Ends scope.steps.get_response_filename()
2957
3834
 
2958
3835
  compare_pdfs: async function (scope, {existing_pdf_path, new_pdf_path}) {
2959
- let existing_paths = await scope.findFiles(scope, {to_find_names: [existing_pdf_path]});
3836
+ let existing_paths = await scope.find_files(scope, {to_find_names: [existing_pdf_path]});
2960
3837
  if (existing_paths.length == 0) {
2961
3838
  // The row wasn't used
2962
3839
  let msg = `Could not find the existing PDF at ${ existing_pdf_path }`;
@@ -3236,7 +4113,12 @@ module.exports = {
3236
4113
  },
3237
4114
 
3238
4115
  set_random_page_vars: async ( scope ) => {
3239
- /** Answer inputs randomly. */
4116
+ /**
4117
+ * Give random answers to every field.
4118
+ *
4119
+ * ⚠️ Keep hidden fields! With some customdatatypes, we set their values.
4120
+ *
4121
+ * */
3240
4122
 
3241
4123
  let html = await scope.page.content();
3242
4124
  // These are the fields on the current page. Their handles should all exist.
@@ -3247,8 +4129,11 @@ module.exports = {
3247
4129
  for ( let field of fields ) {
3248
4130
  // fields[n].tag is HTML tag of node, as a string
3249
4131
 
3250
- // If the field is a button, save it in a list of buttons that we'll deal with later
3251
- if ( field.tag === `button` ) {
4132
+ // ⚠️ Keep hidden fields! With some customdatatypes, we set their vals.
4133
+
4134
+ // If the field is a button, save it in a list of buttons that we'll
4135
+ // deal with later
4136
+ if ( scope.is_button_ish( scope, { field }) ) {
3252
4137
  buttons.push( field );
3253
4138
  } else {
3254
4139
  // Try to give a random answer for that element tag
@@ -3265,15 +4150,24 @@ module.exports = {
3265
4150
  }, // ends scope.steps
3266
4151
 
3267
4152
  set_random_input_for: {
4153
+ /**
4154
+ * ‼️ We call these methods with `set_random_input_for[ tag ]`. You will not
4155
+ * find them called explicitly.
4156
+ * */
3268
4157
  a: async function (scope, { fields }) {
3269
4158
  // We don't do anything with links for random input tests
3270
4159
  },
3271
4160
  buttons: async function (scope, { fields }) {
3272
4161
  // Get a random button
3273
4162
  let back_btn = null;
3274
- fields = fields.filter((ff) => {
3275
- if (ff.selector.includes("daquestionbackbutton")) {
3276
- back_btn = ff;
4163
+ fields = fields.filter( async( field ) => {
4164
+ if ( field.selector.includes("daquestionbackbutton") ) {
4165
+ back_btn = field;
4166
+ return false;
4167
+ }
4168
+ let handle = await scope.page.$( field.selector );
4169
+ let bounding_box = await handle?.boundingBox();
4170
+ if ( bounding_box?.height === 0 || bounding_box?.width === 0) {
3277
4171
  return false;
3278
4172
  }
3279
4173
  return true;
@@ -3330,10 +4224,10 @@ module.exports = {
3330
4224
 
3331
4225
  },
3332
4226
  canvas: async function (scope, { field }) {
3333
- // WARNING: Do not test this in headless mode.
3334
- // If we do this in headless mode, the test will fail. It signs, but
3335
- // can't continue. Not sure why.
3336
- await scope.funnel_the_answer( scope, { field, handle: null, answer: `doesn't matter` });
4227
+ let handle = await scope.page.waitForSelector(`#dasigcanvas`);
4228
+ // We cannot be sure of knowing the name of the signer and so want to
4229
+ // indicate it's random, but we do want something unique
4230
+ await scope.funnel_the_answer( scope, { field, handle, answer: `Random ${ faker.name.lastName() }` });
3337
4231
  },
3338
4232
  input: async function (scope, { field }) {
3339
4233
  /** Handles various `type`s of input element fields, except:
@@ -3361,8 +4255,7 @@ module.exports = {
3361
4255
 
3362
4256
  // If this is a custom datatype
3363
4257
  let custom_datatype = await scope.get_custom_datatype( scope, { handle });
3364
- let is_custom_datatype = scope.get_random_input_for[ custom_datatype ] !== undefined;
3365
- if ( is_custom_datatype ) {
4258
+ if ( scope.get_random_input_for[ custom_datatype ] !== undefined ) {
3366
4259
  // And the custom datatype has a hidden field (which will be the original field)
3367
4260
  let hidden_field = await scope.get_custom_datatype_hidden_field( scope, { handle });
3368
4261
  if ( hidden_field !== null ) {