@suffolklitlab/alkiln 5.17.0 → 5.17.1-fix-stop-on-error-3

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 ) {
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
+ }));
277
309
 
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' );
281
-
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
593
674
 
594
- return regular !== null || regular_backup !== null || signature !== null || signature_backup !== null;
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 });
595
679
 
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 });
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 {
@@ -2076,28 +2485,112 @@ module.exports = {
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 detect_finished_upload(
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
2504
+ // Won't protect against accidentally uploading same file to diff field
2505
+
2506
+ // If elem.files, the author has previously set this field. Warn them.
2507
+ // Example prev_filenames: "C:\\fakepath\\name_of_file.png"
2508
+ let prev_filenames = [];
2509
+ let shared_names_list = [];
2510
+ let all_matched = true;
2511
+ for ( let file_obj of elem.files ) {
2512
+ let filename = file_obj.name.slice( 12, file_obj.name.length );
2513
+ prev_filenames.push( filename );
2514
+ // May over-match with names that end the same way
2515
+ if ( answer.includes( filename ) ) {
2516
+ shared_names_list.push( filename );
2517
+ } else {
2518
+ all_matched = false;
2519
+ }
2520
+ }
2521
+
2522
+ let result = {
2523
+ ok: false, prev_filenames, type: null,
2524
+ code: `ALK0283`, level: `debug`, msgs: [], error: null,
2525
+ };
2526
+
2527
+ if ( prev_filenames ) {
2528
+ result.level = `warn`; // log level
2529
+ result.type = `warning`; // report type
2530
+ shared_names = shared_names_list.join(`", "`);
2531
+
2532
+ if ( shared_names_list.length > 0 ) {
2533
+ if ( all_matched ) {
2534
+ result.code = `ALK0284`;
2535
+ 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?`);
2536
+ } else {
2537
+ result.code = `ALK0285`;
2538
+ 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?`);
2539
+ }
2540
+ } else {
2541
+ result.code = `ALK0286`;
2542
+ result.msgs.push(`You already uploaded some files to the current field. Did you mean to do that?`);
2543
+ }
2544
+ }
2084
2545
 
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 );
2546
+ let timeout_id = null;
2547
+ let resolve_on_change = function() {
2548
+ result.ok = true; // May contain warnings
2549
+ clearTimeout( timeout_id );
2550
+ result.msgs.push(`Finished uploading these files: "${ answer }"`);
2551
+ resolve( result );
2552
+ };
2553
+
2554
+ timeout_id = setTimeout(() => {
2555
+ elem.removeEventListener(`change`, resolve_on_change );
2556
+ result.level = `throw`;
2557
+ result.type = `error`;
2558
+ result.code = `ALK0281`;
2559
+ // Discuss: Will the trace be confusing from here?
2560
+ result.msgs.push(`It took more than ${( timeout - 100 )/1000 } seconds to try to upload the file(s) "${ answer }".`);
2561
+ // ⚠️ Unable to pass Error out of page.evaluate. Must use string.
2562
+ result.error = `Timeout error: It took too long to upload the files.`;
2563
+ resolve( result );
2564
+ }, timeout );
2565
+
2566
+ elem.value = null;
2567
+ elem.addEventListener(`change`, resolve_on_change );
2568
+ }); // ends Promise
2569
+
2570
+ }, scope.timeout - 100, answer ); // ends page.evaluate()
2089
2571
 
2090
- // Upload
2091
2572
  await handle.uploadFile(...paths);
2092
2573
 
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 );
2574
+ // Wait for upload to finish. If already done, execution will continue
2575
+ let { ok, type, level, code, error, msgs } = await finish_uploading;
2098
2576
 
2099
- // Return that the row was used
2100
- return true;
2577
+ let report_vals = msgs;
2578
+ if ( error ) {
2579
+ // ⚠️ Unable to get Error out of page.evaluate. Must create it here.
2580
+ error = new Error( error );
2581
+ report_vals.push( error );
2582
+ }
2583
+ if ( type !== null ) {
2584
+ reports.addToReport(scope, { type, code, value: report_vals.join(`\n`) });
2585
+ }
2586
+
2587
+ // The ternary calls, but the need for it smells like bad code to me.
2588
+ let log_type = `debug`;
2589
+ if ( level === `throw` ) { log_type = `throw`; }
2590
+ log[ log_type ]({ code, level, error, context: `file_upload`}, ...msgs );
2591
+
2592
+ // Return whether successfully used row
2593
+ return ok;
2101
2594
  }, // Ends scope.uploadFiles()
2102
2595
 
2103
2596
  setCheckbox: async function ( scope, { label, answer }) {
@@ -2150,7 +2643,7 @@ module.exports = {
2150
2643
  let canvas = await scope.page.waitForSelector( `#dasigcanvas` );
2151
2644
  let msg = `Could not find a signature field.`;
2152
2645
  if ( !canvas ) { reports.addToReport(scope, { type: `error`, code: `ALK0137`, value: msg }); }
2153
- expect( canvas, msg ).to.exist;
2646
+ expect( canvas, msg ).to.not.be.null;
2154
2647
 
2155
2648
  // Provides functionality to draw name arg on canvas
2156
2649
  if (name !== undefined) {
@@ -2163,7 +2656,9 @@ module.exports = {
2163
2656
  let bounding_box = await canvas.boundingBox();
2164
2657
  // We draw the dot because it allows the developer to tap continue
2165
2658
  // 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);
2659
+ let pen_x = (bounding_box.x + bounding_box.width / 2) + Math.random() * 50;
2660
+ let pen_y = (bounding_box.y + bounding_box.height / 2) + Math.random() * 50;
2661
+ await scope.page.mouse.move( pen_x, pen_y );
2167
2662
  await scope.page.mouse.down();
2168
2663
  // await scope.page.mouse.move(1, 1); // Too big? Wait a while before drawing?
2169
2664
  await scope.page.mouse.up();
@@ -2201,32 +2696,28 @@ module.exports = {
2201
2696
  return risky_id.toLowerCase().replace(/[^A-Za-z0-9]+/g, '-');
2202
2697
  },
2203
2698
 
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 };
2699
+ examinePageID: async function ( scope, page_id = `` ) {
2700
+ /** Looks for a sanitized version of the question id as it's written
2701
+ * in the .yml. docassemble's way in python:
2702
+ * re.sub(r'[^A-Za-z0-9]+', '-', interview_status.question.id.lower())
2703
+ *
2704
+ * TODO: Replace use of this method with refactored functionality in
2705
+ * `current_id_matches`, `id_as_class_name`, and
2706
+ * `current_page_id_class_name`
2707
+ *
2708
+ * @param {Object} scope - Tests state object
2709
+ * @param {Object} obj - Named arguments
2710
+ * @param {str} obj.id_to_match - A docassemble page id, possibly in .yml
2711
+ * format
2712
+ *
2713
+ * @returns { question_has_id: bool, id: str, id_matches: bool }
2714
+ * */
2715
+ // let id_to_match = page_id.toLowerCase().replace(/[^A-Za-z0-9]+/g, '-');
2716
+ let id_to_match = await scope.id_as_class_name( scope, { page_id });
2717
+ let current_id = await scope.current_page_id_class_name( scope );
2718
+ let question_has_id = !!current_id || false;
2719
+ let id_matches = id_to_match === current_id;
2720
+ return { id: current_id, question_has_id, id_matches };
2230
2721
  }, // Ends scope.examinePageID()
2231
2722
 
2232
2723
  // TODO: Rename var_data to input_data?
@@ -2238,7 +2729,7 @@ module.exports = {
2238
2729
  ensure_navigation=true,
2239
2730
  ensure_all_vars_used=false
2240
2731
  }) {
2241
- /* Non-recursive function to set as many variables as possible before
2732
+ /** Non-recursive function to set as many variables as possible before
2242
2733
  * continuing to the next page. Important to avoid recursion because of async ops.
2243
2734
  * If it's not data in a table, the code can be more permissive about some input.
2244
2735
  *
@@ -2248,17 +2739,23 @@ module.exports = {
2248
2739
  * if navigation is not triggered another way.
2249
2740
  * @param ensure_all_vars_used {bool} [false] = Default false. If true, errors if any
2250
2741
  * object in var_data is not used during running of this function.
2251
- */
2252
- log.debug({ code: `ALK0221`, before: `~~~~~~~~~~~~~~\n`, },
2253
- `scope.setFields()`
2742
+ *
2743
+ * @returns {Object} obj - Named return values
2744
+ * @returns {bool} obj.was_found - True if the check found any errors
2745
+ * @returns {bool} obj.ok - False if the check found any errors. Discuss:
2746
+ * Should we only use `ok` for internal errors?
2747
+ * @param {user|system} obj.type - "user" with invalid answers, "system"
2748
+ * with da or AL server errors
2749
+ * @param {[ElementHandle]} obj.error_handles - List of puppeteer element
2750
+ * handles the check found. There can be multiple invalid answer elems.
2751
+ * */
2752
+ log.debug({ code: `ALK0221`, before: `\n~~~~~~~~~~~~~~\n`, },
2753
+ `Starting scope.setFields()`
2254
2754
  );
2255
2755
 
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
- }
2756
+ let error_data = null;
2757
+
2758
+ let id = await scope.log_and_update_if_new_page_id( scope );
2262
2759
 
2263
2760
  let html = await scope.page.content();
2264
2761
  // These are the fields on the current page. Their handles should all exist.
@@ -2269,7 +2766,6 @@ module.exports = {
2269
2766
  // again until nothing more can be set.
2270
2767
  let something_new_was_set = true; // So loop can start
2271
2768
  while ( something_new_was_set ) {
2272
-
2273
2769
  something_new_was_set = false; // start fresh
2274
2770
  // Go through each field on the page, seeing if we can set its variable
2275
2771
  for ( let field of fields ) {
@@ -2278,7 +2774,9 @@ module.exports = {
2278
2774
  if ( field.was_set ) { continue; }
2279
2775
  // Avoid navigating in this `while`. Will do this later so we get to
2280
2776
  // set all fields in here first. TODO: Can links (<a>) set vars?
2281
- if ( field.tag === 'button' && field.type === 'submit' ) { continue; }
2777
+ if (
2778
+ scope.is_button_ish( scope, { field }) && field.type === `submit`
2779
+ ) { continue; }
2282
2780
 
2283
2781
  // Try to set other types of fields
2284
2782
  let row_used = await scope.setVariable( scope, { field, var_data });
@@ -2303,13 +2801,11 @@ module.exports = {
2303
2801
  if ( session_vars.get_debug() ) { await time.waitForTimeout(2000); }
2304
2802
 
2305
2803
  // Press a continue button with a story table variable name if it exists
2306
- let did_navigate = false;
2307
- let error = {};
2804
+ let navigated = false;
2308
2805
  for ( let field of fields ) {
2309
2806
  // All other fields should have been set
2310
- if ( field.tag === 'button' && field.type === 'submit' ) {
2807
+ if ( scope.is_button_ish( scope, { field }) && field.type === 'submit' ) {
2311
2808
  let page_url = await scope.page.url();
2312
-
2313
2809
  let row_used = await scope.setVariable( scope, { field, var_data, });
2314
2810
  if ( row_used ) {
2315
2811
 
@@ -2321,26 +2817,12 @@ module.exports = {
2321
2817
 
2322
2818
  // Esp important for `.target_number`
2323
2819
  await scope.increment_row_use( scope, { row: row_used });
2820
+ ({ error_data, navigated } = await scope.wait_until_continued( scope, {
2821
+ url: page_url, id
2822
+ }));
2324
2823
 
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
2824
+ // pressed button to set var successfully
2825
+ log.stdout({}, `\x1b[36m${ 'v' }\x1b[0m`);
2344
2826
  break;
2345
2827
 
2346
2828
  } // ends if row_used
@@ -2351,59 +2833,143 @@ module.exports = {
2351
2833
  if ( ensure_all_vars_used ) {
2352
2834
  for ( let row of var_data ) {
2353
2835
  if ( row.times_used === 0 ) {
2354
- let values_used_status = 'The page is missing a value';
2355
2836
  let source_row = row;
2356
2837
  // TODO: Make the row object flatter so we don't need to dig in this way
2357
2838
  if ( row.source ) { source_row = row.source; }
2358
2839
  let original_row = source_row.original;
2359
2840
  let { id } = await scope.examinePageID( scope, 'none to match' );
2360
2841
  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( '' );
2842
+ + `to find a field on the page with the "${ id }" for the variable "${ original_row.var }" that `
2843
+ + `could be set to "${ original_row.value }". Alternatively, you`
2844
+ + ` may have duplicate rows in your table. See this Scenario's HTML`
2845
+ + ` file for details.`;
2846
+ let err_msg = reports.addToReport(scope, { type: `error`, code: `ALK0141`, value: msg });
2847
+ throw err_msg;
2365
2848
  }
2366
2849
  }
2367
2850
  }
2368
2851
 
2369
2852
  // 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
2853
+ if ( !navigated && ensure_navigation ) {
2854
+ // All fields on this page should have been taken care of already
2372
2855
  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 ) {
2856
+ error_data = await scope.continue( scope );
2857
+
2858
+ navigated = true;
2859
+ log.stdout({}, `\x1b[36m${ '>' }\x1b[0m`); // continued successfully automatically
2860
+ } // ends if !navigated && ensure_navigation
2861
+
2862
+ if ( navigated && scope.check_all_for_a11y ) {
2394
2863
  let { passed } = await scope.checkForA11y(scope);
2395
2864
  if (!passed) {
2396
2865
  scope.passed_all_for_a11y = false;
2397
2866
  }
2398
2867
  }
2399
2868
 
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;
2869
+ log.debug({ code: `ALK0288`, context: `scenario`, level: `note`,
2870
+ before: `\n~~~~~~~~~~~~~~\n` }, `Finished scope.setFields()`
2871
+ );
2872
+ return error_data;
2404
2873
  // TODO: Maybe we should return something else or something in addition about used vars
2405
2874
  }, // Ends scope.setFields()
2406
2875
 
2876
+ is_button_ish: function ( scope, { field = null }) {
2877
+ /**
2878
+ * Return true if the field is similar enough to a button, else false. This
2879
+ * does NOT determine whether the field submits the form, but does
2880
+ * assume the intention is navigation.
2881
+ *
2882
+ * Discuss: Use "btn" class as well?
2883
+ *
2884
+ * @param {Object} scope - Tests state object
2885
+ * @param {Object} field - One field from `.getAllFields()`
2886
+ *
2887
+ * @returns bool
2888
+ * */
2889
+ if ( field.tag === `button`
2890
+ || field.selector.includes(`role="button"`)
2891
+ || field.selector.includes(`.dasigsave`)
2892
+ || field.selector.includes(`.daquestionactionbutton`)
2893
+ ) { return true; }
2894
+ return false;
2895
+ },
2896
+
2897
+ log_and_update_if_new_page_id: async function ( scope ) {
2898
+ /**
2899
+ * Log the page id if we're on a new page. Abstracting this avoids adding
2900
+ * multiple log codes.
2901
+ *
2902
+ * Discuss: This triggers 2 puppeteer evals. Only 1 is really necessary. How
2903
+ * many pages id checks would make that speed hit matter? Consider copying
2904
+ * `.current_id_matches()` code in here to get the id just once.
2905
+ *
2906
+ * @param {Object} scope - Tests state object
2907
+ *
2908
+ * @returns {str} - the current page .yml id as an HTML class
2909
+ * */
2910
+ let ids_match = await scope.current_id_matches( scope, {
2911
+ id_to_match: scope.page_id
2912
+ });
2913
+ if ( !ids_match ) {
2914
+ let new_id = await scope.current_page_id_class_name( scope );
2915
+ reports.addToReport(scope, { type: `page_id info`, code: `ALK0138`, value: `${ new_id }`, });
2916
+ scope.page_id = new_id;
2917
+ }
2918
+ return scope.page_id;
2919
+ },
2920
+
2921
+ current_id_matches: async function ( scope, { id_to_match=`` }) {
2922
+ /**
2923
+ * Returns true if the given docassemble page id matches the current page id
2924
+ *
2925
+ * @param {Object} scope - Tests state object
2926
+ * @param {Object} obj - Named arguments
2927
+ * @param {str} obj.id_to_match - A docassemble page id, possibly in .yml
2928
+ * format
2929
+ *
2930
+ * @returns {bool} - true if the ids match, otherwise false. Missing ids will match.
2931
+ * */
2932
+ let class_to_match = scope.id_as_class_name( scope, { page_id: id_to_match });
2933
+ let current_id_class = await scope.current_page_id_class_name( scope );
2934
+ return class_to_match === current_id_class;
2935
+ },
2936
+
2937
+ id_as_class_name: function ( scope, { page_id=`` }) {
2938
+ /**
2939
+ * Converts a given page id string (possibly originating from the interview
2940
+ * .yml) as a class name as docassemble does, then returns that value.
2941
+ * This is useful because the .yml page id is included in the DOM as a
2942
+ * class name. docassemble's python code:
2943
+ *
2944
+ * re.sub(r'[^A-Za-z0-9]+', '-', interview_status.question.id.lower())
2945
+ *
2946
+ * @param {Object} scope - Tests state object
2947
+ * @param {Object} obj - Named arguments
2948
+ * @param {str} obj.page_id - A docassemble page id, possibly in .yml format
2949
+ *
2950
+ * @returns {str | null} - the given str as a docassemble-formatted
2951
+ * HTML class name or `null` if `page_id` is not a string.
2952
+ * */
2953
+ return page_id?.toLowerCase?.().replace(/[^A-Za-z0-9]+/g, `-`) || null;
2954
+ },
2955
+
2956
+ current_page_id_class_name: async function ( scope ) {
2957
+ /**
2958
+ * Returns the current page id as an HTML class name or null if missing
2959
+ *
2960
+ * @param {Object} scope - Tests state object
2961
+ *
2962
+ * @returns {str | null} - Current page id as a class name or `null` if none
2963
+ * exists
2964
+ * */
2965
+ // `.className` is a space-sparated list of the node's classes
2966
+ let possible_id_classes = await scope.page.$eval(`body`, function (elem) {
2967
+ return elem.className
2968
+ });
2969
+ let id_matches = possible_id_classes?.match(/question-([^ ]+)/);
2970
+ return id_matches?.[1] || null;
2971
+ },
2972
+
2407
2973
  increment_row_use: async function ( scope, { row }) {
2408
2974
  /** Accumulate number of times this row was used. Esp important for `.target_number`.
2409
2975
  * Mutates `row` and its props. */
@@ -2636,89 +3202,378 @@ module.exports = {
2636
3202
  }
2637
3203
  }, // Ends scope.has_interview_content()
2638
3204
 
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;
3205
+ // ⚠️ Keep text of `id=...` unique from others to identify problems better
3206
+ wait_until_continued: async function ( scope, {
3207
+ url=``, id=`Unknown id`, post_nav_handlers=null
3208
+ }) {
3209
+ /**
3210
+ * Given a url and id of an initial page, attempt to detect navigation.
3211
+ * After the results of the navigation attempt, run the post_nav_handlers.
3212
+ *
3213
+ * ‼️ ALWAYS include required critical error handlers in `post_nav_handlers`
3214
+ * that will throw errors. We need the post-navigation handlers in this
3215
+ * order:
3216
+ *
3217
+ * 1.(required) A page with a visible system error is a critical error and always
3218
+ * trumps other errors, so it comes first
3219
+ * 2. (optional) Invalid answers and other mmore specific handlers trump unspecified
3220
+ * nav errs
3221
+ * 3. (required) Navigation attempt that timed out is a critical error that lacks
3222
+ * further information
3223
+ * 4. (optional) Unspecified non-navigation catchall comes last
3224
+ *
3225
+ * Unfortunately, our navigation detection game is weaksauce. Navigation
3226
+ * detection thoughts: https://github.com/SuffolkLITLab/ALKiln/issues/229
3227
+ *
3228
+ * See docs/decisions/017_post_navigation_custom_behavior_2026_10_26.md for
3229
+ * a deeper discussion.
3230
+ *
3231
+ * @param {Object} scope - Tests state object
3232
+ * @param {Object} kwargs - Named arguments
3233
+ * @param {str} kwargs.url - Assumed to be the url of the initial page
3234
+ * @param {str} kwargs.id - ID of the initial page
3235
+ * @param {[function]} [kwargs.post_nav_handlers] - Functions to handle page
3236
+ * errors like system errors, user errors (invalid answers), and
3237
+ * navigation failure
3238
+ *
3239
+ * @returns {Object} kwrvs - Named return values
3240
+ * @returns {bool} kwrvs.navigated - True if the interview got to a new page
3241
+ * @returns {Object} kwrvs.error_data - Data for any errors found
3242
+ * @returns {bool} kwrvs.error_data.was_found - True if the check found any
3243
+ * errors
3244
+ * @returns {bool} kwrvserror_data.ok - False if the check found any errors.
3245
+ * Discuss: Should we only use `ok` for internal errors?
3246
+ * @returns {`user`|`system`} kwrvserror_data.type - "user" with invalid
3247
+ * answers, "system" with da or AL server errors
3248
+ * @returns {[ElementHandle]} kwrvserror_data.error_handles - List of
3249
+ * puppeteer element handles the check found. There can be multiple
3250
+ * invalid answer elements.
3251
+ * */
3252
+ await scope.log_and_update_if_new_page_id( scope );
3253
+
3254
+ post_nav_handlers ??= [
3255
+ scope.throw_on_system_error,
3256
+ scope.throw_on_invalid_answers,
3257
+ scope.throw_on_non_navigation_timeout,
3258
+ scope.throw_on_non_navigation_catchall,
3259
+ ];
3260
+ scope.throw_if_missing_required_post_nav_handlers( scope, { handlers: post_nav_handlers })
3261
+
3262
+ let initial_id = id;
3263
+ let initial_url = url;
2643
3264
  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 ) {
3265
+ let error_data = await scope.checkForErrors( scope );
3266
+
3267
+ // Attempt to solve weird race condition issues where page has not properly
3268
+ // continued. May exceed actual current timeout.
3269
+ // Discuss: Use a global clock to properly track of remaining time to avoid
3270
+ // scope.timeout - 100 and such? Any such cucumber or puppeteer feature?
3271
+ let timed_out = false; // Lazy timeout?
3272
+ let nav_timeout = setTimeout(function(){ timed_out = true }, scope.timeout - 100);
3273
+ while ( curr_url === initial_url && error_data.ok && !timed_out ) {
2651
3274
  await time.waitForTimeout( 50 );
3275
+ error_data = await scope.checkForErrors( scope );
2652
3276
  curr_url = await scope.page.url();
2653
- error = await scope.checkForError( scope );
2654
3277
  }
3278
+ clearTimeout( nav_timeout );
3279
+
3280
+ // TODO: Review architecture - can we refactor nav out of global state?
3281
+ scope.navigated = curr_url !== initial_url;
2655
3282
 
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;
3283
+ error_data = await scope.checkForErrors( scope );
3284
+ if ( !error_data.ok ) {
3285
+ for ( let handler of post_nav_handlers ) {
3286
+ await handler( scope, { id: initial_id, error_data, timed_out });
3287
+ }
3288
+ }
2659
3289
 
2660
- clearTimeout( cont_timeout );
3290
+ return { error_data, navigated: curr_url !== initial_url };
3291
+ }, // Ends scope.wait_until_continued()
2661
3292
 
2662
- return error;
2663
- }, // Ends scope.waitUntilContinued()
3293
+ throw_if_missing_required_post_nav_handlers: function( scope, { handlers=[] }={}) {
3294
+ /**
3295
+ * Return true if the list of functions contains the required handlers,
3296
+ * otherwise throw an internal error.
3297
+ *
3298
+ * @param {Object} scope - Tests state object
3299
+ * @param {Object} kwargs - Named arguments
3300
+ * @param {[func]} kwargs.handlers - List of functions
3301
+ *
3302
+ * @returns {bool} - True if function completes successfully
3303
+ * */
3304
+ let missing_handler_errors = [];
3305
+ if ( !handlers.includes( scope.throw_on_system_error ) ) {
3306
+ let missing_system_msg = log.debug({ code: `ALK0295`, context: `internal nav_handlers`, level: `error` },
3307
+ `ALKiln's internal list of post-navigation handlers must include "scope.throw_on_system_error()"`
3308
+ );
3309
+ missing_handler_errors.push( missing_system_msg );
3310
+ }
3311
+ if ( !handlers.includes( scope.throw_on_non_navigation_timeout ) ) {
3312
+ let missing_timeout_msg = log.debug({ code: `ALK0296`, context: `internal nav_handlers`, level: `error` },
3313
+ `ALKiln's internal list of post-navigation handlers must include "scope.throw_on_non_navigation_timeout()"`
3314
+ );
3315
+ missing_handler_errors.push( missing_timeout_msg );
3316
+ }
2664
3317
 
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. */
3318
+ if ( missing_handler_errors.length > 0 ) {
3319
+ throw ReferenceError( missing_handler_errors.join(`\n`) );
3320
+ }
2669
3321
 
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
- };
3322
+ return true;
3323
+ },
2679
3324
 
2680
- let error_handlers = {};
3325
+ validation_err_selector: `#daflash .alert-danger`,
3326
+ // Discuss: `validation code` message can be on a page even after user fixed
3327
+ // values. Always dismiss `validation code` before trying to continue? Or
3328
+ // just detect navigation through requests and responses, which we need to
3329
+ // switch to anyway.
3330
+ // ⚠️ 'display:' and 'none' MUST be separated by a space
3331
+ invalid_field_selector: `xpath/.//*[contains(@class, "da-has-error")][not(contains(@style,"display: none"))]`,
3332
+ // ⚠️ Assumptions: ": Error" stays in the metadata of the page and #damain
3333
+ // only appears on da error pages
3334
+ sys_err_selector: `xpath/.//meta[contains(@content, ": Error")]|.//*[@id="damain"]//h1[contains(text(), "Error")]`,
3335
+ // `al_custom_error_action` in base64. Replaces system error pages, but acts
3336
+ // like a question screen. Shows "The error details are no longer
3337
+ // available..." message when GET requests error (default da message?)
3338
+ al_err_selector: `.question-custom-error-action .daattributions #trigger[data-variable="YWxfY3VzdG9tX2Vycm9yX2FjdGlvbg"]`,
3339
+ checkForErrors: async function ( scope ) {
3340
+ /**
3341
+ * Return any elements from invalid user answers or system errors & other
3342
+ * data. Avoid throwing. Invalid answers may be the author's goal.
3343
+ *
3344
+ * Note: May later want the actual text of the invalid answer messages.
3345
+ *
3346
+ * @param {Object} scope - Tests state object
3347
+ *
3348
+ * @returns {Object} obj - Named return values
3349
+ * @returns {bool} obj.was_found - True if the check found any errors
3350
+ * @returns {bool} obj.ok - False if the check found any errors. Discuss:
3351
+ * Should we only use `ok` for internal errors?
3352
+ * @param {user|system} obj.type - "user" with invalid answers, "system"
3353
+ * with da or AL server errors
3354
+ * @param {[ElementHandle]} obj.error_handles - List of puppeteer element
3355
+ * handles the check found. There can be multiple invalid answer elems.
3356
+ * */
3357
+ // ⚠️ Keep these error objs in this order. For now, the first item on the
3358
+ // list is the one we later use, and `system` errors have priority.
3359
+ let possible_error_types = [
3360
+ { type: `system`, handles: await scope.page.$$( scope.sys_err_selector ) },
3361
+ { type: `system`, handles: await scope.page.$$( scope.al_err_selector ) },
3362
+ { type: `user`, handles: await scope.page.$$( scope.validation_err_selector ) },
3363
+ { type: `user`, handles: await scope.page.$$( scope.invalid_field_selector ) },
3364
+ ];
3365
+
3366
+ // Can have multiple user errors which we may get the text of in future
3367
+ let error_handles = [];
2681
3368
  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.
3369
+ let type = null;
3370
+ for ( let one_err_type of possible_error_types ) {
3371
+ if ( one_err_type.handles?.[0] ) {
3372
+ // If there's a page visible (locally), give us almost as much time to
3373
+ // examine it as timeout allows. Because of previous actions, this may
3374
+ // still be a bit too long.
3375
+ // Also, if you're debugging and you close the window early, the process
3376
+ // will still take this long to exit after the tests finish. May fix
3377
+ // in future, but it's just during debugging, so yolo!
2688
3378
  if ( session_vars.get_debug() ) {
2689
3379
  await time.waitForTimeout( ( scope.timeout - (1 * 1000)) );
2690
3380
  }
3381
+
2691
3382
  was_found = true;
2692
- error_handlers[ error_key ] = possible_errors[ error_key ][0];
2693
- }
3383
+ error_handles = error_handles.concat( one_err_type.handles )
3384
+ if ( !type || one_err_type.type == `system` ) {
3385
+ type = one_err_type.type
3386
+ }
3387
+ } // ends if first handler
3388
+ } // ends for each error type
3389
+
3390
+ return { was_found, ok: !was_found, type, error_handles };
3391
+ }, // Ends scope.checkForErrors()
3392
+
3393
+ throw_on_system_error: async function ( scope, {
3394
+ error_data=null, id=`Unknown ID`
3395
+ }) {
3396
+ /**
3397
+ * Throw if there is a system/server error. Include some page text.
3398
+ *
3399
+ * @param {Object} scope - Tests state object
3400
+ * */
3401
+ if ( error_data?.type === `system` ) {
3402
+ 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.`];
3403
+ let report_msg = reports.addToReport(scope, {
3404
+ type: `error`, code: `ALK0147`,
3405
+ value: [ alk_msg, ``, await scope.get_system_err( scope ) ].join(`\n`),
3406
+ });
3407
+ throw Error( report_msg );
2694
3408
  }
2695
3409
 
2696
- return { was_found, error_handlers };
2697
- }, // Ends scope.checkForError()
3410
+ return scope;
3411
+ }, // Ends scope.throw_on_system_error()
3412
+
3413
+ get_system_err: async function ( scope, { num_lines=8 }={}) {
3414
+ /**
3415
+ * Returns a sample of error-related text on a docassemble or Assembly Line
3416
+ * system error page
3417
+ *
3418
+ * Note: YWxfZm9ybWF0dGVkX2Vycm9yX21lc3NhZ2U is "al_formatted_error_message"
3419
+ *
3420
+ * @param {Object} scope - Tests state object
3421
+ * @param {Object} obj - Named arguments
3422
+ * @param {int} obj.num_lines - Number of lines of HTML text to return
3423
+ *
3424
+ * @returns {str} - A sample of the HTML related to the system error
3425
+ * */
3426
+ let truncated_text = `Unable to get the text on this page. Check the picture of this page if possible.`
3427
+ try {
3428
+
3429
+ let page_err_text = await scope.page.evaluate(() => {
3430
+ return (
3431
+ ( document.querySelector(`#YWxfZm9ybWF0dGVkX2Vycm9yX21lc3NhZ2U`)
3432
+ || document.querySelector(`.dabody`)
3433
+ ).textContent
3434
+ );
3435
+ });
3436
+
3437
+ log.debug({ code: `ALK0287`, context: `page_error`, level: `info` },
3438
+ `Full text of page error:`, page_err_text
3439
+ );
2698
3440
 
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;
3441
+ truncated_text = page_err_text.split(`\n`)
3442
+ .slice( 0, num_lines )
3443
+ .join(`\n`)
3444
+ .trim()
3445
+ + `\n...`;
2705
3446
 
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;
3447
+ } catch ( pupp_err ) {
3448
+ log.debug({ code: `ALK0292`, context: `internal`, level: `note` },
3449
+ `Skipped getting system error screen text with puppeteerjs:`, pupp_err,
3450
+ );
2711
3451
  }
2712
3452
 
2713
- return scope;
2714
- }, // Ends scope.throwPageError()
3453
+ return truncated_text;
3454
+ }, // Ends scope.get_system_err()
3455
+
3456
+ throw_on_invalid_answers: async function ( scope, {
3457
+ error_data={}, id=`Unknown ID`
3458
+ }={}) {
3459
+ /**
3460
+ * Throw an error if the user has given invalid answers. If the author
3461
+ * simply wants to detect invalid answers and avoid throwing, use
3462
+ * `scope.checkForErrors()`.
3463
+ *
3464
+ * Discuss: Change our architecture to handle throwing and reporting in some
3465
+ * central location down/up-stream. Here, for example, I want to add an
3466
+ * error to the report, but "I can't continue" is catching this error and
3467
+ * letting the test pass. How can we handle that? Allow editing the report
3468
+ * after the fact? Throwing the report message with log code and all and
3469
+ * count on the caller to report it?
3470
+ *
3471
+ * @param {Object} scope - Tests state object
3472
+ * @param {Object} [obj] - Named arguments
3473
+ * @param {Object} [obj.error_data] - Maybe data for invalid answers' markers
3474
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3475
+ *
3476
+ * @returns {Object} - Error data given
3477
+ * */
3478
+ if ( !error_data.ok && error_data.type === `user` ) {
3479
+ 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.`;
3480
+ let report_msg = reports.addToReport(scope, { type: `error`, code: `ALK0148`, value: err_msg });
3481
+ throw Error( report_msg );
3482
+ }
3483
+
3484
+ return error_data;
3485
+ }, // Ends scope.throw_on_invalid_answers()
3486
+
3487
+ warn_on_invalid_answers: async function ( scope, {
3488
+ error_data={}, id=`Unknown ID`
3489
+ }={}) {
3490
+ /**
3491
+ * Add a warning to the report if the page has invalid answer indicators.
3492
+ *
3493
+ * @param {Object} scope - Tests state object
3494
+ * @param {Object} [obj] - Named arguments
3495
+ * @param {Object} [obj.error_data] - Maybe data for invalid answers' markers
3496
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3497
+ *
3498
+ * @returns {Object} - Error data given
3499
+ * */
3500
+ let initial_id = id;
3501
+ if ( !error_data.ok && error_data.type === `user` ) {
3502
+ // TOOD: Add a deprecation message to Steps that call this
3503
+ let report_msg = reports.addToReport(scope, {
3504
+ type: `warning`, code: `ALK0290`, value: `The user got an invalid answer on the page with the id "${ initial_id }".`
3505
+ });
3506
+ }
3507
+
3508
+ return error_data;
3509
+ }, // Ends scope.warn_on_invalid_answers()
3510
+
3511
+ throw_on_non_navigation_timeout: async function ( scope, {
3512
+ timed_out=false, id=`Unknown ID`
3513
+ }={}) {
3514
+ /**
3515
+ * Throw an error if the page hasn't navigated and the nav check timed out.
3516
+ *
3517
+ * @param {Object} scope - Tests state object
3518
+ * @param {Object} [obj] - Named arguments
3519
+ * @param {Object} [obj.timed_out] - Whether navigation timed out
3520
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3521
+ *
3522
+ * @returns {undefined}
3523
+ * */
3524
+ let initial_id = id;
3525
+ 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.`
3526
+ if ( !scope.navigated && timed_out ) {
3527
+ reports.addToReport(scope, { type: `error`, code: `ALK0146`, value: timeout_err_message });
3528
+ throw Error( timeout_err_message );
3529
+ }
3530
+ },
3531
+
3532
+ throw_on_non_navigation_catchall: async function ( scope, { id=`Unknown ID` }={}) {
3533
+ /**
3534
+ * Throw an error if the page hasn't navigated. Used in cases where we
3535
+ * haven't identified why the interaction didn't navigate.
3536
+ *
3537
+ * @param {Object} scope - Tests state object
3538
+ * @param {Object} [obj] - Named arguments
3539
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3540
+ *
3541
+ * @returns {undefined}
3542
+ * */
3543
+ let initial_id = id;
3544
+ if ( !scope.navigated ) {
3545
+ let non_nav_msg = `Was unable to navigate away from page with the id "${ initial_id }".`
3546
+ reports.addToReport(scope, { type: `error`, code: `ALK0291`, value: non_nav_msg });
3547
+ throw Error( non_nav_msg );
3548
+ }
3549
+ },
3550
+
3551
+ warn_on_non_navigation_catchall: async function ( scope, { id=`Unknown ID` }={}) {
3552
+ /**
3553
+ * Warn if the page hasn't navigated. Used in cases where we haven't
3554
+ * identified why the interaction didn't navigate.
3555
+ *
3556
+ * @param {Object} scope - Tests state object
3557
+ * @param {Object} [obj] - Named arguments
3558
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3559
+ *
3560
+ * @returns {undefined}
3561
+ * */
3562
+ let initial_id = id;
3563
+ if ( !scope.navigated ) {
3564
+ let non_nav_msg = `Was unable to navigate away from page with the id "${ initial_id }".`
3565
+ reports.addToReport(scope, { type: `warning`, code: `ALK0293`, value: non_nav_msg });
3566
+ }
3567
+ },
2715
3568
 
2716
3569
  take_a_screenshot: async ( scope,{ path }) => {
2717
3570
  /* Takes a jpeg screenshot. Avoids destroying signatures. */
2718
3571
 
2719
3572
  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);
3573
+ // Use only $ as opposed to $$ - just checking for presence of any element
3574
+ let signature_elem = await scope.page.$(scope.signature_cont_btn_selector_da_v_1_9_n_and_earlier, { visible: true })
3575
+ || await scope.page.$(scope.signature_cont_btn_selector_backup_da_v_1_9_n_and_earlier, { visible: true })
3576
+ || await scope.page.$(scope.signature_cont_btn_selector_da_v_1_10_n_and_later, { visible: true });
2722
3577
  if ( signature_elem !== null ) {
2723
3578
  fullPage = false;
2724
3579
  }
@@ -2932,7 +3787,7 @@ module.exports = {
2932
3787
  /** Given a fetch response headers' Content-Disposition str, return the
2933
3788
  * filename of the response's file.
2934
3789
  *
2935
- * @return { string | null } - Name of fetched file
3790
+ * @returns { string | null } - Name of fetched file
2936
3791
  * */
2937
3792
  let filename = default_name;
2938
3793
  if ( disposition ) {
@@ -2948,8 +3803,6 @@ module.exports = {
2948
3803
  }
2949
3804
 
2950
3805
  // TODO: Add debug log here
2951
- // console.log(`filename:`, filename);
2952
-
2953
3806
  // TODO: Add to the report if we had to use the default name
2954
3807
 
2955
3808
  return filename;
@@ -3236,7 +4089,12 @@ module.exports = {
3236
4089
  },
3237
4090
 
3238
4091
  set_random_page_vars: async ( scope ) => {
3239
- /** Answer inputs randomly. */
4092
+ /**
4093
+ * Give random answers to every field.
4094
+ *
4095
+ * ⚠️ Keep hidden fields! With some customdatatypes, we set their values.
4096
+ *
4097
+ * */
3240
4098
 
3241
4099
  let html = await scope.page.content();
3242
4100
  // These are the fields on the current page. Their handles should all exist.
@@ -3247,8 +4105,11 @@ module.exports = {
3247
4105
  for ( let field of fields ) {
3248
4106
  // fields[n].tag is HTML tag of node, as a string
3249
4107
 
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` ) {
4108
+ // ⚠️ Keep hidden fields! With some customdatatypes, we set their vals.
4109
+
4110
+ // If the field is a button, save it in a list of buttons that we'll
4111
+ // deal with later
4112
+ if ( scope.is_button_ish( scope, { field }) ) {
3252
4113
  buttons.push( field );
3253
4114
  } else {
3254
4115
  // Try to give a random answer for that element tag
@@ -3265,15 +4126,24 @@ module.exports = {
3265
4126
  }, // ends scope.steps
3266
4127
 
3267
4128
  set_random_input_for: {
4129
+ /**
4130
+ * ‼️ We call these methods with `set_random_input_for[ tag ]`. You will not
4131
+ * find them called explicitly.
4132
+ * */
3268
4133
  a: async function (scope, { fields }) {
3269
4134
  // We don't do anything with links for random input tests
3270
4135
  },
3271
4136
  buttons: async function (scope, { fields }) {
3272
4137
  // Get a random button
3273
4138
  let back_btn = null;
3274
- fields = fields.filter((ff) => {
3275
- if (ff.selector.includes("daquestionbackbutton")) {
3276
- back_btn = ff;
4139
+ fields = fields.filter( async( field ) => {
4140
+ if ( field.selector.includes("daquestionbackbutton") ) {
4141
+ back_btn = field;
4142
+ return false;
4143
+ }
4144
+ let handle = await scope.page.$( field.selector );
4145
+ let bounding_box = await handle?.boundingBox();
4146
+ if ( bounding_box?.height === 0 || bounding_box?.width === 0) {
3277
4147
  return false;
3278
4148
  }
3279
4149
  return true;
@@ -3330,10 +4200,10 @@ module.exports = {
3330
4200
 
3331
4201
  },
3332
4202
  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` });
4203
+ let handle = await scope.page.waitForSelector(`#dasigcanvas`);
4204
+ // We cannot be sure of knowing the name of the signer and so want to
4205
+ // indicate it's random, but we do want something unique
4206
+ await scope.funnel_the_answer( scope, { field, handle, answer: `Random ${ faker.name.lastName() }` });
3337
4207
  },
3338
4208
  input: async function (scope, { field }) {
3339
4209
  /** Handles various `type`s of input element fields, except:
@@ -3361,8 +4231,7 @@ module.exports = {
3361
4231
 
3362
4232
  // If this is a custom datatype
3363
4233
  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 ) {
4234
+ if ( scope.get_random_input_for[ custom_datatype ] !== undefined ) {
3366
4235
  // And the custom datatype has a hidden field (which will be the original field)
3367
4236
  let hidden_field = await scope.get_custom_datatype_hidden_field( scope, { handle });
3368
4237
  if ( hidden_field !== null ) {