@suffolklitlab/alkiln 5.17.0 → 5.17.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
@@ -347,6 +347,7 @@ module.exports = {
347
347
  // This is meant to detect user-visible alerts/errors,
348
348
  // not breaking errors in the testing code.
349
349
  // No need for aborters since puppeteer has its own timeout
350
+ // TODO: Be more specific. Authors may use `.alert-danger` themselves.
350
351
  scope.page.waitForSelector('.alert-danger', { visible: true }),
351
352
  scope.page.waitForSelector('.da-has-error', { visible: true }),
352
353
  ]);
@@ -556,22 +557,56 @@ module.exports = {
556
557
  return false;
557
558
  },
558
559
 
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`,
560
+ continue_button_selector_da_v_1_9_n_and_earlier: `fieldset.da-field-buttons button[type="submit"].btn-primary`,
561
+ continue_button_selector_da_v_1_10_n_and_later: `button[type="submit"]#da-continue-button`,
562
+ continue_button_selector_backup_da_v_1_9_n_and_earlier: `.da-fieldset.da-field-buttons button[type="submit"].btn-primary`,
563
+ signature_cont_btn_selector_da_v_1_9_n_and_earlier: `fieldset .dasigsave`,
564
+
565
+ signature_cont_btn_selector_backup_da_v_1_9_n_and_earlier: `.da-fieldset .dasigsave`,
566
+ // ⚠️ docassemble >= 1.10.n, 2 signature page continue buttons. One visible on
567
+ // wide screen, one visible on narrow screen. We need to get the visible
568
+ // one, but puppeteer v22.15.0 doesn't recognize an element with a hidden
569
+ // ancestor as invisible, so we need to get both `.dasigsave` and test for
570
+ // visibility our own way for now.
571
+ signature_cont_btn_selector_da_v_1_10_n_and_later: `.dasigsave`,
563
572
  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 });
573
+ /**
574
+ * Presses whatever button it finds that might lead to the next page.
575
+ *
576
+ * @param {object} scope - Tests state object
577
+ * */
578
+ // ⚠️ Any selectors I find seem somewhat precarious.
579
+ let elem_lists = [
580
+ // non-signature pages (these are the most consistent ways)
581
+ await scope.page.$$(scope.continue_button_selector_da_v_1_9_n_and_earlier, { visible: true }),
582
+ await scope.page.$$(scope.continue_button_selector_da_v_1_10_n_and_later, { visible: true }),
583
+ await scope.page.$$(scope.continue_button_selector_backup_da_v_1_9_n_and_earlier, { visible: true }),
584
+ // signature pages
585
+ await scope.page.$$(scope.signature_cont_btn_selector_da_v_1_9_n_and_earlier, { visible: true }),
586
+ await scope.page.$$(scope.signature_cont_btn_selector_backup_da_v_1_9_n_and_earlier, { visible: true }),
587
+ // Two possible buttons if `visible: true` doesn't work
588
+ await scope.page.$$(scope.signature_cont_btn_selector_da_v_1_10_n_and_later, { visible: true }),
589
+ ];
590
+
591
+ // ⚠️ Avoid whichever `.dasigsave` button is hidden on signature screens. We
592
+ // must do this ourselves because puppeteer v22.15.0 doesn't seem to
593
+ // recognize when an ancestor of an element is `display: none`.
594
+ let continue_button = null;
595
+ for ( let possible_cont_buttons of elem_lists ) {
596
+ let found = false;
597
+ for ( let possible_cont_button of possible_cont_buttons ) {
598
+ let bounding_box = await possible_cont_button.boundingBox();
599
+ if ( bounding_box?.height !== 0 && bounding_box?.width !== 0) {
600
+ continue_button = possible_cont_button;
601
+ found = true;
602
+ break;
603
+ }
604
+ }
605
+ if ( found ) { break; }
606
+ }
607
+
573
608
  // Waits for navigation or user error
574
- await scope.tapElementAndNavigate( scope, { elem });
609
+ await scope.tapElementAndNavigate( scope, { elem: continue_button });
575
610
  }, // Ends scope.continue()
576
611
 
577
612
  continue_exists: async function ( scope ) {
@@ -585,13 +620,18 @@ module.exports = {
585
620
  // <button type="submit" class="btn btn-da btn-primary" name="Zm9v" value="True">Continue</button>
586
621
  // It will not find event screen `buttons:`, like an exit button or a restart button
587
622
  // <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);
623
+ // ⚠️ `buttons:` can be used in question blocks as choices
624
+
625
+ // Multiple matches not needed. We're just checking for presence.
626
+ let regular = await scope.page.$(scope.continue_button_selector_da_v_1_9_n_and_earlier, { visible: true });
627
+ let regular_da_v_1_10_x = await scope.page.$(scope.continue_button_selector_da_v_1_10_n_and_later, { visible: true });
628
+ let regular_backup = await scope.page.$(scope.continue_button_selector_backup_da_v_1_9_n_and_earlier, { visible: true });
593
629
 
594
- return regular !== null || regular_backup !== null || signature !== null || signature_backup !== null;
630
+ let signature = await scope.page.$(scope.signature_cont_btn_selector_da_v_1_9_n_and_earlier, { visible: true });
631
+ let signature_backup = await scope.page.$(scope.signature_cont_btn_selector_backup_da_v_1_9_n_and_earlier, { visible: true });
632
+ let signature_da_v_1_10_x = await scope.page.$(scope.signature_cont_btn_selector_da_v_1_10_n_and_later, { visible: true });
633
+
634
+ 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;
595
635
 
596
636
  }, // Ends scope.continue_exists()
597
637
 
@@ -750,6 +790,14 @@ module.exports = {
750
790
  set_interview_session_data: function ( scope, { vars = null }) {
751
791
  /** Sets this interview's session data for this scenario if it doesn't
752
792
  * already exist.
793
+ *
794
+ * Discuss: Keep a record of insertion order of .interviews uids so we can
795
+ * tell which interview is most recent and the order in which interviews
796
+ * were visited.
797
+ *
798
+ * TODO: If we don't already, when deleting interviews, use
799
+ * scenario.interviews to get the relevant uids instead of just the most
800
+ * recent uid? Multiple tests may be running.
753
801
  *
754
802
  * @returns final_vars { object | null }
755
803
  * @returns final_vars.i { str } File path of interview
@@ -931,8 +979,12 @@ module.exports = {
931
979
  * as some kind of sophisticated data? With their selectors, for example.
932
980
  * We will choose selectors that will continue to identify the node, eve
933
981
  * after a hidden field is revealed, etc.
934
- * TODO: Not sure where to bring in cheerio
935
- * TODO: Pass in `$` instead? */
982
+ *
983
+ * TODO: Not sure where to bring in cheerio. Pass in `$` instead?
984
+ *
985
+ * TODO: Consider getting more data here so that other functionality can use
986
+ * the data to reduce use of `page.evaluate()`.
987
+ * */
936
988
  let fields = [];
937
989
 
938
990
  const $ = cheerio.load( html ); // Could this be on `scope`?
@@ -960,55 +1012,87 @@ module.exports = {
960
1012
  // Some `show if` fields have `names` like '_field_n' instead of their var name. Ex: _field_1
961
1013
  let field_like_names = await scope.getFieldNamesDict( scope, { $: $ });
962
1014
 
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` );
1015
+ // All the different types of fields. Will deal with `option` once inside `select`
1016
+
1017
+ // All these hidden inputs moved inside the da form element. We want to keep
1018
+ // some hidden inputs, so we'll have to exclude the new neighbors.
1019
+ let not = `:not([name="${ [
1020
+ `_checkboxes`, `csrf_token`, `_event`, `_question_name`, `_tracker`, `_datatypes`, `_visible`, `_varnames`
1021
+ ].join('"]):not([name="') }"])`;
1022
+ let all_nodes = $(
1023
+ `#dasigpage canvas`
1024
+ + `, fieldset button, .da-fieldset button, .daquestionactionbutton, #daform button, #daform *[role="button"]`
1025
+ + `, fieldset input${ not }, .da-fieldset input${ not }, .da-form-group input${ not }, #daform input${ not }`
1026
+ + `, .da-form-group select, #daform select, #daform form select`
1027
+ + `, .da-form-group textarea, #daform textarea`
1028
+ );
1029
+ let all_buttons = [];
1030
+
1031
+ // // TODO: When various versions of da resolve the `GET` errors, re-activate
1032
+ // // this code out here instead of just for canvas elements.
1033
+ // let question_data = await scope.get_question_data( scope );
1034
+
967
1035
  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
1036
  let $node = $( node );
970
1037
  let field_data = {
971
1038
  selector: '',
972
1039
  tag: node.name,
973
- guesses: [],
974
- type: '',
1040
+ guesses: null,
1041
+ type: node.attribs.type || ``,
975
1042
  trigger: trigger,
976
1043
  };
977
- if ( node.attribs.type ) { field_data.type = node.attribs.type; }
978
1044
 
979
1045
  // 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 {
1046
+ let encoded_name = $node.attr( `name` );
1047
+
1048
+ if ( field_data.tag === `canvas` ) { // $(`#dasigpage`).length > 0 && $(`#dasigpage`)[0].name ) {
1049
+
1050
+ let question_data = await scope.get_question_data( scope );
1051
+
1052
+ // ⚠️ Cheerio is unable to detect that some `.dasigsave` buttons are
1053
+ // hidden. Ignore buttons here. Take care of it with puppeteer later.
1054
+
1055
+ // Try for guesses that are most likely to be associated with the canvas
1056
+ encoded_name ??= scope.canvas_name.try_for_best_encoded( scope, {
1057
+ existing_guesses: field_data.guesses, $, trigger
1058
+ });
1059
+ if ( !encoded_name && !field_data.guesses ) {
1060
+ field_data.guesses = scope.canvas_name.try_for_mid_guesses( scope, {
1061
+ question_data
1062
+ });
1063
+ }
1064
+
1065
+ // Resort to names that may have proxy vars, such as x and i. See
1066
+ // docassemble generic objects and index vars. They are more
1067
+ // ambiguous since they could match multiple variables.
1068
+
1069
+ if ( !encoded_name && !field_data.guesses ) {
1070
+ field_data.guesses = scope.canvas_name.try_for_worse_guesses( scope, {
1071
+ question_data
1072
+ });
1073
+ }
1074
+ if ( !encoded_name && !field_data.guesses ) {
1075
+ encoded_name = scope.canvas_name.try_for_worse_encoded( scope, {
1076
+ question_data
1077
+ });
1078
+ }
1079
+ // ✏️ var_names handles an undefined encoded_name just fine
1080
+
1081
+ // If we have none of those, we need to tell the user we have too little
1082
+ // information to set the signature field variable We'll fail later
1083
+ // when we can take a proper screenshot.
1084
+ if ( !field_data.guesses && !encoded_name ) {
1002
1085
  reports.addToReport(scope, {
1003
1086
  type: `error`,
1004
1087
  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.`
1088
+ value: `You cannot sign a signature page with this ALKiln Step `
1089
+ + `because the interview's HTML is missing data. Use the "I sign" `
1090
+ + `Step instead. You can also read documentation about `
1091
+ + `how to add the "trigger" to the interview HTML.`
1009
1092
  });
1093
+ // Discuss: Throw here?
1010
1094
  }
1011
- }
1095
+ } // end if signature page
1012
1096
 
1013
1097
  // The selector we'll use to set the DOM node's value later
1014
1098
  if ( !field_data.selector ) {
@@ -1021,19 +1105,19 @@ module.exports = {
1021
1105
  let href = node.attribs.href;
1022
1106
  let match = href.match( /action=([^&]*)/ );
1023
1107
  if ( match ) {
1024
- not_decoded_name = match[1];
1108
+ encoded_name = match[1];
1025
1109
  } else {
1026
1110
  // 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
1111
+ // This means the first guess of the name should be the encoded_name
1028
1112
  // TODO: This only replaces the chars at the start of the string. Is that
1029
1113
  // intentional? Does this `.replace()` need the `g` flag?
1030
- not_decoded_name = href.replace(/^[A-Za-z0-9]*/, '_');
1114
+ encoded_name = href.replace(/^[A-Za-z0-9]*/, '_');
1031
1115
  }
1032
1116
  }
1033
1117
 
1034
- if ( !not_decoded_name ) {
1118
+ if ( !encoded_name ) {
1035
1119
  // Some fields, like comboboxes, have a weird var name setup
1036
- not_decoded_name = $node.attr( `id` );
1120
+ encoded_name = $node.attr( `id` );
1037
1121
  }
1038
1122
 
1039
1123
  // ====================
@@ -1044,12 +1128,13 @@ module.exports = {
1044
1128
  if ( $node.hasClass('danota-checkbox') || $node.parents(`.da-field-container-datatype-object_radio`).name ) {
1045
1129
  is_none_of_the_above = true;
1046
1130
  let first_sib = $(node.parent).find( 'input' )[0];
1047
- not_decoded_name = first_sib.attribs.name;
1131
+ encoded_name = first_sib.attribs.name;
1048
1132
  // TODO: Do we need daaota as well? ("all of the above")
1049
1133
  }
1050
1134
 
1135
+ // Am I doing this wrong? the guesses aren't just the name, right?
1051
1136
  let var_names = await scope.getPossibleDecodedNames( scope, {
1052
- not_decoded: not_decoded_name,
1137
+ not_decoded: encoded_name,
1053
1138
  field_like_names,
1054
1139
  is_none_of_the_above: is_none_of_the_above,
1055
1140
  proxies,
@@ -1070,16 +1155,260 @@ module.exports = {
1070
1155
  values.push( node.attribs.value );
1071
1156
  }
1072
1157
 
1073
- field_data.guesses = await scope.getPossibleTableRowMatchers( scope, { var_names, values });
1074
- fields.push( field_data );
1158
+ field_data.guesses ??= await scope.add_values_if_needed( scope, { var_names, values });
1159
+ if ( scope.is_button_ish( scope, { field: field_data }) ) {
1160
+ // Discuss: This makes sure buttons are matched with table rows last. Is
1161
+ // that useful? Appropriate?
1162
+ all_buttons.push( field_data );
1163
+ } else {
1164
+ fields.push( field_data );
1165
+ }
1166
+
1075
1167
  } // ends for every node
1076
1168
 
1169
+ let on_page = [ ...fields, ...all_buttons ];
1170
+
1077
1171
  log.debug({ code: `ALK0124`, context: `page fields`, },
1078
- fields
1172
+ JSON.stringify( on_page, null, 2 )
1079
1173
  );
1080
- return fields;
1174
+ return on_page;
1081
1175
  }, // Ends scope.getAllFields()
1082
1176
 
1177
+ get_question_data: async function ( scope ) {
1178
+ /**
1179
+ * Return server data about the current question.
1180
+ *
1181
+ * da v1.10.n or greater removes some info from the DOM, such as on the
1182
+ * signature page, so we need to supplement our info when possible. We can
1183
+ * do this by making a GET request for the current page data in the
1184
+ * browser. See https://docassemble.org/docs/api.html#session_question. If
1185
+ * we had an API key for the user or an admin, we could use the API, but
1186
+ * we cannot guarantee having that API key without making a lot more work
1187
+ * for authors.
1188
+ *
1189
+ * Discuss: Do we need to wait longer for a server response, as we do for a
1190
+ * page response?
1191
+ *
1192
+ * @param {Object} scope - Tests state object
1193
+ *
1194
+ * @returns {Object} - the question & field data from the current page
1195
+ * */
1196
+ let question_data = { ok: false, status: -1 };
1197
+ let question_response = null;
1198
+ let err_msg = `Docassemble couldn't process this page's JSON. ALKiln will`
1199
+ + ` do its best, but may fail. We would love a report for this.`;
1200
+
1201
+ try {
1202
+ question_response = await scope?.page?.evaluate?.( async ( err_msg ) => {
1203
+ /**
1204
+ * @returns {object} -
1205
+ * { ok: false }; // ok -> response status or false
1206
+ * | { ok, status, ...data, ...( await response.json() ) }
1207
+ * | { ok, status, alk_msg: err_msg, ...data, html: await response.text() }
1208
+ * | { ok, status, alk_msg: err_msg }
1209
+ * | { ok: false, alk_msg: err_msg, error: response_err.message }
1210
+ * */
1211
+ let data = { ok: false, alk_msg: null }; // ok -> did we get JSON or not
1212
+ try {
1213
+ let response = await fetch(
1214
+ window.location.href.replace(`interview?`, `interview?json=1&`)
1215
+ );
1216
+
1217
+ data.ok = response.ok;
1218
+ data.status = response.status;
1219
+
1220
+ // ‼️ On system error caused by `GET` request, AL's da v1.9.n first
1221
+ // detects the error, but it stays invisible to the DOM since the
1222
+ // page hasn't been reloaded. Then, when the test presses to
1223
+ // continue, a new error appears. Unfortunately, the old error is
1224
+ // lost, so we won't be able to retrieve it through the DOM. We may
1225
+ // want to switch to catching errors during the field-getting part
1226
+ // of the life-cycle if we want to be able to handle this robustly.
1227
+ // I'm not sure what other issues will arise at that point.
1228
+
1229
+ let content_type = response?.headers?.get?.(`content-type`);
1230
+ if ( content_type?.startsWith?.(`application/json`) ) {
1231
+ return { ...data, ...( await response.json() ) };
1232
+
1233
+ } else if ( content_type?.startsWith?.(`text/plain`) ) {
1234
+ // NOTE: Assembly Line will obscure docassemble error content it
1235
+ // doesn't handle, making its error page unhelpful. In future, we
1236
+ // do more with the actual page response.
1237
+ return { ...data, alk_msg: err_msg, html: await response.text() };
1238
+
1239
+ } else {
1240
+ // Research: How can we get in here? What other content-types that we
1241
+ // could get give response ok?
1242
+ return { ...data, alk_msg: err_msg, content_type };
1243
+ } // ends content-type check
1244
+
1245
+ } catch ( response_err ) {
1246
+ // Error might be caused by non-da page author navigated to when, for
1247
+ // example, testing an "Exit" destination.
1248
+ return { ok: false, alk_msg: err_msg, error: response_err.message, }
1249
+ } // ends fetch try
1250
+
1251
+ return data;
1252
+ }, err_msg ); // ends page.evaluate() call
1253
+ } catch ( question_data_error ) {
1254
+ question_response = { ok: false, alk_msg: err_msg, error: question_data_error.message, }
1255
+ }
1256
+
1257
+ question_data = question_response || question_data;
1258
+ // If we didn't get JSON response, we're assuming fetch ran into a problem.
1259
+ // This isn't critical info. Other ALKiln code will throw if warranted.
1260
+ if ( !question_data.ok ) {
1261
+ scope.log_question_data_not_ok( scope, { data: question_data });
1262
+ return { ok: false };
1263
+ }
1264
+
1265
+ return question_data;
1266
+ }, // Ends get_question_data
1267
+
1268
+ log_question_data_not_ok: function ( scope, { data={} }) {
1269
+ /**
1270
+ * Log question_data response props appropriately.
1271
+ *
1272
+ * @param {Object} scope - Tests state object
1273
+ * @param {Object} obj - Named args
1274
+ * @param {Object} obj.data - Data for a non-JSON response to fetching
1275
+ * docassemble current question data
1276
+ *
1277
+ * Possible data shapes:
1278
+ * { ok: false }; // ok -> response.ok or false
1279
+ * | { ok, status, ...data, ...( await response.json() ) }
1280
+ * | { ok, status, alk_msg: err_msg, ...data, html: await response.text() }
1281
+ * | { ok, status, alk_msg: err_msg, content_type }
1282
+ * | { ok: false, alk_msg: err_msg, error: response_err.message }
1283
+ *
1284
+ * @returns {string} - Log text
1285
+ * */
1286
+ let default_msg = `ALKlin skipped processing the current page's server data.`;
1287
+ let qst_msgs = [ data.alk_msg || default_msg, ]
1288
+
1289
+ if ( data.status || data.html || data.error || data.content_type ) { qst_msgs.push(``); }
1290
+
1291
+ if ( data.status ) { qst_msgs.push(`Response status: ${ data.status }`); }
1292
+ if ( data.content_type ) { qst_msgs.push(`Content type: ${ data.content_type }`) }
1293
+ if ( data.html ) {
1294
+ let $ = cheerio.load( data.html );
1295
+ qst_msgs.push( $('#damain')?.text?.() || data.html );
1296
+ }
1297
+ if ( data.error ) { qst_msgs.push( data.error ); }
1298
+
1299
+ return log.debug({ level: `warn`, code: `ALK0282`, context: `question_data`,
1300
+ }, qst_msgs.join(`\n`));
1301
+ },
1302
+
1303
+ canvas_name: {
1304
+ try_for_best_encoded: function( scope, { existing_guesses, $, trigger }) {
1305
+ /**
1306
+ * Returns the canvas' encoded var instance name if possible. Otherwise
1307
+ * returns null.
1308
+ *
1309
+ * @param {Object} scope - Tests state object
1310
+ * @param {Object} obj - Named arguments
1311
+ * @param {Object} obj.existing_guesses - List of existing guesses
1312
+ * @param {Object} obj.$ - cheerio
1313
+ * @param {Object} obj.trigger - Name of the var the interview is seeking
1314
+ *
1315
+ * @returns {string} - Encoded variable instance name
1316
+ *
1317
+ * @example
1318
+ * scope.canvas_name.try_for_best_encoded( scope, { existing_guesses, $, trigger }
1319
+ * // cHJveHlfbGlzdFswXS5zaWduYXR1cmU // decoded: people[0]...
1320
+ * */
1321
+ if ( existing_guesses?.length > 0 ) { return null; }
1322
+ return (
1323
+ // v1.9.n: If da server's config value of `restrict input
1324
+ // variables` set to True, `_save_as` should work to get the var
1325
+ // name. v 1.10.n, unknown
1326
+ $(`input[name="_save_as"]`)?.attr?.(`value`)
1327
+ || trigger // From ALKiln custom HTML author may or may not be using
1328
+ || null
1329
+ );
1330
+ },
1331
+
1332
+ try_for_mid_guesses: function( scope, { question_data }) {
1333
+ /**
1334
+ * If possible, returns a list of one string - a decoded instance name
1335
+ * that likely belongs to the canvas var. Otherwise returns null.
1336
+ * Example: people[0].signature.
1337
+ *
1338
+ * @param {Object} scope - Tests state object
1339
+ * @param {Object} obj - Named arguments
1340
+ * @param {Object} obj.question_data - Docassemble server data about the
1341
+ * current question
1342
+ *
1343
+ * @returns {array} - List of one decoded canvas element name
1344
+ *
1345
+ * @example
1346
+ * scope.canvas_name.try_for_mid_guesses( scope, { field_data, question_data })
1347
+ * // people[0].signature // If it's the first one in the list, that is
1348
+ * */
1349
+ // `event_list` contains decoded instance names of variables. Sadly, this
1350
+ // code assumes the first item of `event_list` is the canvas name. Note,
1351
+ // though, that I've been unable to trigger an event_list with multiple
1352
+ // items.
1353
+ if ( question_data?.event_list?.[0] ) {
1354
+ return [ question_data.event_list[0] ];
1355
+ }
1356
+ return null;
1357
+ },
1358
+
1359
+ try_for_worse_guesses: function( scope, { question_data }) {
1360
+ /**
1361
+ * If possible, returns a list of one string - a decoded var name that
1362
+ * likely belongs to the canvas, but may contain x, i, or other such
1363
+ * proxy variable names instead of the intrinsic name. Otherwise returns
1364
+ * null. Example: x[i].signature. Note that I've been unable to trigger
1365
+ * a `fields` list with multiple items.
1366
+ *
1367
+ * @param {Object} scope - Tests state object
1368
+ * @param {Object} obj - Named arguments
1369
+ * @param {Object} obj.question_data - Docassemble server data about the
1370
+ * current question
1371
+ *
1372
+ * @returns {[string]} - List of one decoded canvas, maybe proxy, var name
1373
+ *
1374
+ * @example
1375
+ * scope.canvas_name.try_for_mid_guesses( scope, { question_data })
1376
+ * // x[i].signature
1377
+ * */
1378
+ // The canvas should be the only field on the page, so the assumption that
1379
+ // the canvas's name is the first item of `fields` should be reasonable.
1380
+ if ( question_data?.fields?.[0]?.variable_name ) {
1381
+ return [ question_data.fields[0].variable_name ];
1382
+ }
1383
+ return null;
1384
+ },
1385
+
1386
+ try_for_worse_encoded: function( scope, { question_data }) {
1387
+ /**
1388
+ * If possible, returns an encoded var name that likely belongs to the
1389
+ * canvas, but may contain x, i, or other such proxy variable names
1390
+ * instead of the intrinsic name. Otherwise returns null. Example:
1391
+ * x[i].signature. Note that I've been unable to trigger a `fields` list
1392
+ * with multiple items.
1393
+ *
1394
+ * @param {Object} scope - Tests state object
1395
+ * @param {Object} obj - Named arguments
1396
+ * @param {Object} obj.question_data - Docassemble server data about the
1397
+ * current question
1398
+ *
1399
+ * @returns {string} - Encoded canvas, maybe proxy, var name
1400
+ *
1401
+ * @example
1402
+ * scope.canvas_name.try_for_mid_guesses( scope, { question_data })
1403
+ * // eFtpXS5zaWduYXR1cmU // Decodes to x[i].signature
1404
+ * */
1405
+ // The canvas should be the only field on the page, so assuming the
1406
+ // canvas's name the first item of `fields` should be reasonable.
1407
+ // Last resort, encoded name. Example: eFtpXS5zaWduYXR1cmU which decodes to (x[i]...).
1408
+ return question_data?.fields?.[0]?.variable_name_encoded;
1409
+ },
1410
+ },
1411
+
1083
1412
  getPageJSON: async function ( scope ) {
1084
1413
  /* Get the JSON data of the current page. */
1085
1414
  return await scope.page.evaluate(async function ( elem ) {
@@ -1103,6 +1432,7 @@ module.exports = {
1103
1432
  if ( node.attribs.value ) { selector += `[value="${ node.attribs.value }"]`; }
1104
1433
  if ( node.attribs.id ) { selector += `[id="${ node.attribs.id }"]`; }
1105
1434
  if ( node.attribs.class ) { selector += `[class="${ node.attribs.class }"]`; }
1435
+ if ( node.attribs.role ) { selector += `[role="${ node.attribs.role }"]`; }
1106
1436
  if ( $( node ).attr( 'data-linknum' )) { selector += `[data-linknum="${ $( node ).attr( 'data-linknum' ) }"]`; }
1107
1437
  // Everything should be in #daquestion and that's also how our unit tests and fixtures work.
1108
1438
  selector = '#daquestion ' + selector;
@@ -1122,12 +1452,12 @@ module.exports = {
1122
1452
  return selector;
1123
1453
  }, // Ends scope.getSelector()
1124
1454
 
1125
- getPossibleTableRowMatchers: async function ( scope, { var_names, values }) {
1455
+ add_values_if_needed: async function ( scope, { var_names, values }) {
1126
1456
  /* Return variable names and values combined in ways that can
1127
1457
  * try to match table rows. Tables can have two properties:
1128
- * variable name and value. */
1458
+ * variable name and value. Despite it's name, non-mutating. */
1129
1459
  log.debug({ code: `ALK0126`, context: `matching`, },
1130
- `scope.getPossibleTableRowMatchers() var_names:`, var_names, `values:`, values
1460
+ `scope.add_values_if_needed() var_names:`, var_names, `values:`, values
1131
1461
  );
1132
1462
  let possible_table_rows = [];
1133
1463
  for ( let var_name of var_names ) {
@@ -1156,7 +1486,7 @@ module.exports = {
1156
1486
  } // end iterate through row possibilities
1157
1487
 
1158
1488
  return possible_table_rows;
1159
- }, // Ends scope.getPossibleTableRowMatchers()
1489
+ }, // Ends scope.add_values_if_needed()
1160
1490
 
1161
1491
  getFieldNamesDict: async function ( scope, { $ }) {
1162
1492
  /* Returns an object containing a map of the `_field_n` names and
@@ -1397,7 +1727,7 @@ module.exports = {
1397
1727
  if ( opt.attribs.value !== undefined ) {
1398
1728
  options.push( opt.attribs.value );
1399
1729
  }
1400
- // Dropdowns created w/ objects are base64 encoded; but it's handled in getPossibleTableRowMatchers
1730
+ // Dropdowns created w/ objects are base64 encoded; but it's handled in add_values_if_needed
1401
1731
  }
1402
1732
  return options;
1403
1733
  }, // Ends scope.getOptionValues()
@@ -1716,6 +2046,7 @@ module.exports = {
1716
2046
  // Pass in the selector as an argument
1717
2047
  }, field.selector );
1718
2048
 
2049
+
1719
2050
  return handle;
1720
2051
  }, // Ends scope.get_handle_from_field()
1721
2052
 
@@ -1852,6 +2183,10 @@ module.exports = {
1852
2183
  await scope.tapElementAndNavigate( scope, { elem: handle });
1853
2184
  return true;
1854
2185
  },
2186
+ a: async function ( scope, { handle, answer }) {
2187
+ await scope.tapElementAndNavigate( scope, { elem: handle });
2188
+ return true;
2189
+ },
1855
2190
  textarea: async function ( scope, { handle, answer }) {
1856
2191
  await scope.setText( scope, { handle, answer });
1857
2192
  await scope.afterStep(scope); // No showifs for this one?
@@ -1895,7 +2230,7 @@ module.exports = {
1895
2230
  /* Set value of some `input` element to the given value. */
1896
2231
  let row_was_used = true;
1897
2232
 
1898
- let type = await handle.evaluate( elem => {
2233
+ let type = await handle.evaluate( elem => {
1899
2234
  if (elem) {
1900
2235
  return elem.getAttribute('type');
1901
2236
  } else {
@@ -2076,28 +2411,112 @@ module.exports = {
2076
2411
  return false;
2077
2412
  }
2078
2413
 
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])`;
2414
+ // ✏️ The code below is a great indication that Log and `reports`
2415
+ // architecture may be less than ideal, though js doesn't help.
2416
+
2417
+ // TODO: Pass in a promise aborter to page.evaluate() if possible, though
2418
+ // it's not completely necessary. If the gobal `setTimeout` fires then the
2419
+ // `evaluate` won't be far behind. They both use about the same timeout
2420
+ // duration.
2421
+ let finish_uploading = handle.evaluate( async function detect_finished_upload(
2422
+ elem, timeout, answer
2423
+ ) {
2424
+ return new Promise (( resolve, reject ) => {
2425
+ /**
2426
+ * Resolve when upload completes. Logging in here will log to the hidden
2427
+ * browser. Must instead pass information out with `resolve`.
2428
+ * */
2429
+ // Warn about uploading the same file multiple times
2430
+ // Won't protect against accidentally uploading same file to diff field
2431
+
2432
+ // If elem.files, the author has previously set this field. Warn them.
2433
+ // Example prev_filenames: "C:\\fakepath\\name_of_file.png"
2434
+ let prev_filenames = [];
2435
+ let shared_names_list = [];
2436
+ let all_matched = true;
2437
+ for ( let file_obj of elem.files ) {
2438
+ let filename = file_obj.name.slice( 12, file_obj.name.length );
2439
+ prev_filenames.push( filename );
2440
+ // May over-match with names that end the same way
2441
+ if ( answer.includes( filename ) ) {
2442
+ shared_names_list.push( filename );
2443
+ } else {
2444
+ all_matched = false;
2445
+ }
2446
+ }
2447
+
2448
+ let result = {
2449
+ ok: false, prev_filenames, type: null,
2450
+ code: `ALK0283`, level: `debug`, msgs: [], error: null,
2451
+ };
2084
2452
 
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 );
2453
+ if ( prev_filenames ) {
2454
+ result.level = `warn`; // log level
2455
+ result.type = `warning`; // report type
2456
+ shared_names = shared_names_list.join(`", "`);
2457
+
2458
+ if ( shared_names_list.length > 0 ) {
2459
+ if ( all_matched ) {
2460
+ result.code = `ALK0284`;
2461
+ 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?`);
2462
+ } else {
2463
+ result.code = `ALK0285`;
2464
+ 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?`);
2465
+ }
2466
+ } else {
2467
+ result.code = `ALK0286`;
2468
+ result.msgs.push(`You already uploaded some files to the current field. Did you mean to do that?`);
2469
+ }
2470
+ }
2471
+
2472
+ let timeout_id = null;
2473
+ let resolve_on_change = function() {
2474
+ result.ok = true; // May contain warnings
2475
+ clearTimeout( timeout_id );
2476
+ result.msgs.push(`Finished uploading these files: "${ answer }"`);
2477
+ resolve( result );
2478
+ };
2479
+
2480
+ timeout_id = setTimeout(() => {
2481
+ elem.removeEventListener(`change`, resolve_on_change );
2482
+ result.level = `throw`;
2483
+ result.type = `error`;
2484
+ result.code = `ALK0281`;
2485
+ // Discuss: Will the trace be confusing from here?
2486
+ result.msgs.push(`It took more than ${( timeout - 100 )/1000 } seconds to try to upload the file(s) "${ answer }".`);
2487
+ // ⚠️ Unable to pass Error out of page.evaluate. Must use string.
2488
+ result.error = `Timeout error: It took too long to upload the files.`;
2489
+ resolve( result );
2490
+ }, timeout );
2491
+
2492
+ elem.value = null;
2493
+ elem.addEventListener(`change`, resolve_on_change );
2494
+ }); // ends Promise
2495
+
2496
+ }, scope.timeout - 100, answer ); // ends page.evaluate()
2089
2497
 
2090
- // Upload
2091
2498
  await handle.uploadFile(...paths);
2092
2499
 
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 );
2500
+ // Wait for upload to finish. If already done, execution will continue
2501
+ let { ok, type, level, code, error, msgs } = await finish_uploading;
2502
+
2503
+ let report_vals = msgs;
2504
+ if ( error ) {
2505
+ // ⚠️ Unable to get Error out of page.evaluate. Must create it here.
2506
+ error = new Error( error );
2507
+ report_vals.push( error );
2508
+ }
2509
+ if ( type !== null ) {
2510
+ reports.addToReport(scope, { type, code, value: report_vals.join(`\n`) });
2511
+ }
2098
2512
 
2099
- // Return that the row was used
2100
- return true;
2513
+ // The ternary calls, but the need for it smells like bad code to me.
2514
+ let log_type = `debug`;
2515
+ if ( level === `throw` ) { log_type = `throw`; }
2516
+ log[ log_type ]({ code, level, error, context: `file_upload`}, ...msgs );
2517
+
2518
+ // Return whether successfully used row
2519
+ return ok;
2101
2520
  }, // Ends scope.uploadFiles()
2102
2521
 
2103
2522
  setCheckbox: async function ( scope, { label, answer }) {
@@ -2150,7 +2569,7 @@ module.exports = {
2150
2569
  let canvas = await scope.page.waitForSelector( `#dasigcanvas` );
2151
2570
  let msg = `Could not find a signature field.`;
2152
2571
  if ( !canvas ) { reports.addToReport(scope, { type: `error`, code: `ALK0137`, value: msg }); }
2153
- expect( canvas, msg ).to.exist;
2572
+ expect( canvas, msg ).to.not.be.null;
2154
2573
 
2155
2574
  // Provides functionality to draw name arg on canvas
2156
2575
  if (name !== undefined) {
@@ -2163,7 +2582,9 @@ module.exports = {
2163
2582
  let bounding_box = await canvas.boundingBox();
2164
2583
  // We draw the dot because it allows the developer to tap continue
2165
2584
  // 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);
2585
+ let pen_x = (bounding_box.x + bounding_box.width / 2) + Math.random() * 50;
2586
+ let pen_y = (bounding_box.y + bounding_box.height / 2) + Math.random() * 50;
2587
+ await scope.page.mouse.move( pen_x, pen_y );
2167
2588
  await scope.page.mouse.down();
2168
2589
  // await scope.page.mouse.move(1, 1); // Too big? Wait a while before drawing?
2169
2590
  await scope.page.mouse.up();
@@ -2253,6 +2674,8 @@ module.exports = {
2253
2674
  `scope.setFields()`
2254
2675
  );
2255
2676
 
2677
+ let error_data = null;
2678
+
2256
2679
  // Only record the page id once for each page to which we navigate
2257
2680
  let { id } = await scope.examinePageID( scope, 'none to match' );
2258
2681
  if ( scope.page_id !== id ) {
@@ -2278,7 +2701,9 @@ module.exports = {
2278
2701
  if ( field.was_set ) { continue; }
2279
2702
  // Avoid navigating in this `while`. Will do this later so we get to
2280
2703
  // set all fields in here first. TODO: Can links (<a>) set vars?
2281
- if ( field.tag === 'button' && field.type === 'submit' ) { continue; }
2704
+ if (
2705
+ scope.is_button_ish( scope, { field }) && field.type === `submit`
2706
+ ) { continue; }
2282
2707
 
2283
2708
  // Try to set other types of fields
2284
2709
  let row_used = await scope.setVariable( scope, { field, var_data });
@@ -2307,7 +2732,7 @@ module.exports = {
2307
2732
  let error = {};
2308
2733
  for ( let field of fields ) {
2309
2734
  // All other fields should have been set
2310
- if ( field.tag === 'button' && field.type === 'submit' ) {
2735
+ if ( scope.is_button_ish( scope, { field }) && field.type === 'submit' ) {
2311
2736
  let page_url = await scope.page.url();
2312
2737
 
2313
2738
  let row_used = await scope.setVariable( scope, { field, var_data, });
@@ -2334,10 +2759,7 @@ module.exports = {
2334
2759
  // Make sure no system or user error messages appear
2335
2760
  // Discuss moving this out of here. Having to pass in `id` just for
2336
2761
  // 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
- // }
2762
+ error_data = await scope.waitUntilContinued( scope, { url: page_url, id });
2341
2763
 
2342
2764
  did_navigate = true;
2343
2765
  log.stdout({}, `\x1b[36m${ 'v' }\x1b[0m`); // pressed button successfully
@@ -2351,17 +2773,16 @@ module.exports = {
2351
2773
  if ( ensure_all_vars_used ) {
2352
2774
  for ( let row of var_data ) {
2353
2775
  if ( row.times_used === 0 ) {
2354
- let values_used_status = 'The page is missing a value';
2355
2776
  let source_row = row;
2356
2777
  // TODO: Make the row object flatter so we don't need to dig in this way
2357
2778
  if ( row.source ) { source_row = row.source; }
2358
2779
  let original_row = source_row.original;
2359
2780
  let { id } = await scope.examinePageID( scope, 'none to match' );
2360
2781
  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 `
2782
+ + `to find a field on the page with the "${ id }" for the variable "${ original_row.var }" that `
2362
2783
  + `could be set to "${ original_row.value }". See this Scenario's HTML file for details.`;
2363
2784
  reports.addToReport(scope, { type: `error`, code: `ALK0141`, value: msg });
2364
- expect( values_used_status, msg ).to.equal( '' );
2785
+ expect( 'The page is missing a field', msg ).to.equal( '' );
2365
2786
  }
2366
2787
  }
2367
2788
  }
@@ -2380,11 +2801,7 @@ module.exports = {
2380
2801
  // Discuss moving this out of here. Having to pass in `id` just for
2381
2802
  // these messages smells bad. Con: Already a lot of code out there.
2382
2803
 
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
- // }
2804
+ error_data = await scope.waitUntilContinued( scope, { url: page_url, id });
2388
2805
 
2389
2806
  did_navigate = true;
2390
2807
  log.stdout({}, `\x1b[36m${ '>' }\x1b[0m`); // continued successfully
@@ -2400,10 +2817,29 @@ module.exports = {
2400
2817
  // TODO: Since we now continue only once no matter what, we can put
2401
2818
  // the error in the caller of the function, though we may want
2402
2819
  // to pass the symbol to print as well.
2403
- return error;
2820
+ return error_data;
2404
2821
  // TODO: Maybe we should return something else or something in addition about used vars
2405
2822
  }, // Ends scope.setFields()
2406
2823
 
2824
+ is_button_ish: function ( scope, { field = null }) {
2825
+ /**
2826
+ * Return true if the field is similar enough to a button, else false. This
2827
+ * does NOT determine whether the field submits the form.
2828
+ *
2829
+ * Discuss: Use "btn" class as well?
2830
+ *
2831
+ * @param {Object} scope - Tests state object
2832
+ * @param {Object} field - One field from `.getAllFields()`
2833
+ *
2834
+ * @returns bool
2835
+ * */
2836
+ if ( field.tag === `button`
2837
+ || field.selector.includes(`role="button"`)
2838
+ || field.selector.includes(`.dasigsave`)
2839
+ ) { return true; }
2840
+ return false;
2841
+ },
2842
+
2407
2843
  increment_row_use: async function ( scope, { row }) {
2408
2844
  /** Accumulate number of times this row was used. Esp important for `.target_number`.
2409
2845
  * Mutates `row` and its props. */
@@ -2641,16 +3077,22 @@ module.exports = {
2641
3077
  is found. Return the error object no matter what. */
2642
3078
  let prev_url = url;
2643
3079
  let curr_url = await scope.page.url();
2644
- let error = await scope.checkForError( scope );
3080
+ let error_data = await scope.checkForError( scope );
2645
3081
 
2646
3082
  let timedOut = false; // Lazy timeout?
2647
3083
  let cont_timeout = setTimeout(function(){ timedOut = true }, scope.timeout);
2648
3084
 
2649
3085
  // Attempt to solve weird race condition issues where page has not properly continued
2650
- while ( curr_url === prev_url && !error.was_found && !timedOut ) {
3086
+ while ( curr_url === prev_url && error_data.ok && !timedOut ) {
2651
3087
  await time.waitForTimeout( 50 );
2652
3088
  curr_url = await scope.page.url();
2653
- error = await scope.checkForError( scope );
3089
+ error_data = await scope.checkForError( scope );
3090
+ }
3091
+
3092
+ if ( !error_data.ok ) {
3093
+ await scope.throw_system_error( scope, { error_data, id: id });
3094
+ // If trying to continue, shouldn't be running into user errors either
3095
+ await scope.throw_invalid_answer( scope, { error_data, id: id });
2654
3096
  }
2655
3097
 
2656
3098
  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.`
@@ -2659,7 +3101,7 @@ module.exports = {
2659
3101
 
2660
3102
  clearTimeout( cont_timeout );
2661
3103
 
2662
- return error;
3104
+ return error_data;
2663
3105
  }, // Ends scope.waitUntilContinued()
2664
3106
 
2665
3107
  checkForError: async function ( scope ) {
@@ -2667,20 +3109,22 @@ module.exports = {
2667
3109
  * returns those elements. The error elements are probably exclusive, but
2668
3110
  * we're not sure. */
2669
3111
 
2670
- // All results are lists to match `$x`
2671
- let possible_errors = {
2672
- complex_user_error: await scope.page.$$(`.alert-danger`),
3112
+ // All results are lists to match puppeteerjs `$x`
3113
+ let possible_error_types = [
3114
+ // TODO: Be more specific. Author can also use a `.alert-danger` call out.
3115
+ { type: `user`, handlers: await scope.page.$$(`.alert-danger`) },
2673
3116
  // 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"]` ),
3117
+ { type: `user`, handlers: await scope.page.$$(`xpath/.//*[contains(@class, "da-has-error")][not(contains(@style,"display: none"))]`) },
3118
+ { type: `system`, handlers: await scope.page.$$(`xpath/.//meta[@content="docassemble: Error"]` ) },
2676
3119
  // 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
- };
3120
+ { type: `system`, handlers: await scope.page.$$('.question-custom-error-action .daattributions #trigger[data-variable="YWxfY3VzdG9tX2Vycm9yX2FjdGlvbg"]') },
3121
+ ];
2679
3122
 
2680
- let error_handlers = {};
3123
+ let error_handlers = []; // TODO: Refactor to be single item
2681
3124
  let was_found = false;
2682
- for ( let error_key in possible_errors ) {
2683
- if ( possible_errors[ error_key ][0] ) {
3125
+ let type = null;
3126
+ for ( let one_err_type of possible_error_types ) {
3127
+ if ( one_err_type.handlers[0] ) {
2684
3128
  // If there's a page visible (locally), give us as much time to examine it as timeout allows.
2685
3129
  // Because of previous actions, this may still be a bit too long.
2686
3130
  // Also, if you're debugging and you close the window early, the process will still
@@ -2689,36 +3133,53 @@ module.exports = {
2689
3133
  await time.waitForTimeout( ( scope.timeout - (1 * 1000)) );
2690
3134
  }
2691
3135
  was_found = true;
2692
- error_handlers[ error_key ] = possible_errors[ error_key ][0];
3136
+ type = one_err_type.type
3137
+ error_handlers.push( one_err_type.handlers[0] );
3138
+ break;
2693
3139
  }
2694
3140
  }
2695
3141
 
2696
- return { was_found, error_handlers };
3142
+ return { was_found, ok: !was_found, type, error_handlers };
2697
3143
  }, // Ends scope.checkForError()
2698
3144
 
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;
3145
+ throw_system_error: async function ( scope, {
3146
+ error_data = null, error_handlers = null, id = ``, error_text = ``
3147
+ }) {
3148
+ /** Throw a cucumber error with the error that appeared on the page. */
3149
+ let was_system_error = error_data?.type === `system`
3150
+ || error_handlers?.system_error !== undefined
3151
+ || error_handlers?.al_custom_error !== undefined;
3152
+
3153
+ if ( was_system_error ) {
3154
+ let err_msg = `The test ran into a system error when it tried to continue to the next page after "${ id }". Also check the picture of this page.`;
3155
+ let report_msg = reports.addToReport(scope, { type: `error`, code: `ALK0147`, value: err_msg });
3156
+ expect( was_system_error, report_msg ).to.be.false;
3157
+ }
3158
+
3159
+ return scope;
3160
+ }, // Ends scope.throw_system_error()
2705
3161
 
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;
3162
+ throw_invalid_answer: async function ( scope, {
3163
+ error_data = {}, id = ''
3164
+ }) {
3165
+ // It was a user error, which the author may be expecting
3166
+ if ( !error_data.ok && error_data.type === `user` ) {
3167
+ let err_msg = `There was an invalid answer when the test tried to continue to the next page after ${ id }. Check the picture of this page.`;
3168
+ let report_msg = reports.addToReport(scope, { type: `warning`, code: `ALK0148`, value: err_msg });
3169
+ expect( error_data.type === `user`, report_msg ).to.be.false;
2711
3170
  }
2712
3171
 
2713
3172
  return scope;
2714
- }, // Ends scope.throwPageError()
3173
+ }, // Ends scope.throw_invalid_answer()
2715
3174
 
2716
3175
  take_a_screenshot: async ( scope,{ path }) => {
2717
3176
  /* Takes a jpeg screenshot. Avoids destroying signatures. */
2718
3177
 
2719
3178
  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);
3179
+ // Use only $ as opposed to $$ - just checking for presence of any element
3180
+ let signature_elem = await scope.page.$(scope.signature_cont_btn_selector_da_v_1_9_n_and_earlier, { visible: true })
3181
+ || await scope.page.$(scope.signature_cont_btn_selector_backup_da_v_1_9_n_and_earlier, { visible: true })
3182
+ || await scope.page.$(scope.signature_cont_btn_selector_da_v_1_10_n_and_later, { visible: true });
2722
3183
  if ( signature_elem !== null ) {
2723
3184
  fullPage = false;
2724
3185
  }
@@ -2948,8 +3409,6 @@ module.exports = {
2948
3409
  }
2949
3410
 
2950
3411
  // TODO: Add debug log here
2951
- // console.log(`filename:`, filename);
2952
-
2953
3412
  // TODO: Add to the report if we had to use the default name
2954
3413
 
2955
3414
  return filename;
@@ -3236,7 +3695,12 @@ module.exports = {
3236
3695
  },
3237
3696
 
3238
3697
  set_random_page_vars: async ( scope ) => {
3239
- /** Answer inputs randomly. */
3698
+ /**
3699
+ * Give random answers to every field.
3700
+ *
3701
+ * ⚠️ Keep hidden fields! With some customdatatypes, we set their values.
3702
+ *
3703
+ * */
3240
3704
 
3241
3705
  let html = await scope.page.content();
3242
3706
  // These are the fields on the current page. Their handles should all exist.
@@ -3247,8 +3711,11 @@ module.exports = {
3247
3711
  for ( let field of fields ) {
3248
3712
  // fields[n].tag is HTML tag of node, as a string
3249
3713
 
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` ) {
3714
+ // ⚠️ Keep hidden fields! With some customdatatypes, we set their vals.
3715
+
3716
+ // If the field is a button, save it in a list of buttons that we'll
3717
+ // deal with later
3718
+ if ( scope.is_button_ish( scope, { field }) ) {
3252
3719
  buttons.push( field );
3253
3720
  } else {
3254
3721
  // Try to give a random answer for that element tag
@@ -3265,15 +3732,24 @@ module.exports = {
3265
3732
  }, // ends scope.steps
3266
3733
 
3267
3734
  set_random_input_for: {
3735
+ /**
3736
+ * ‼️ We call these methods with `set_random_input_for[ tag ]`. You will not
3737
+ * find them called explicitly.
3738
+ * */
3268
3739
  a: async function (scope, { fields }) {
3269
3740
  // We don't do anything with links for random input tests
3270
3741
  },
3271
3742
  buttons: async function (scope, { fields }) {
3272
3743
  // Get a random button
3273
3744
  let back_btn = null;
3274
- fields = fields.filter((ff) => {
3275
- if (ff.selector.includes("daquestionbackbutton")) {
3276
- back_btn = ff;
3745
+ fields = fields.filter( async( field ) => {
3746
+ if ( field.selector.includes("daquestionbackbutton") ) {
3747
+ back_btn = field;
3748
+ return false;
3749
+ }
3750
+ let handle = await scope.page.$( field.selector );
3751
+ let bounding_box = await handle?.boundingBox();
3752
+ if ( bounding_box?.height === 0 || bounding_box?.width === 0) {
3277
3753
  return false;
3278
3754
  }
3279
3755
  return true;
@@ -3330,10 +3806,10 @@ module.exports = {
3330
3806
 
3331
3807
  },
3332
3808
  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` });
3809
+ let handle = await scope.page.waitForSelector(`#dasigcanvas`);
3810
+ // We cannot be sure of knowing the name of the signer and so want to
3811
+ // indicate it's random, but we do want something unique
3812
+ await scope.funnel_the_answer( scope, { field, handle, answer: `Random ${ faker.name.lastName() }` });
3337
3813
  },
3338
3814
  input: async function (scope, { field }) {
3339
3815
  /** Handles various `type`s of input element fields, except:
@@ -3361,8 +3837,7 @@ module.exports = {
3361
3837
 
3362
3838
  // If this is a custom datatype
3363
3839
  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 ) {
3840
+ if ( scope.get_random_input_for[ custom_datatype ] !== undefined ) {
3366
3841
  // And the custom datatype has a hidden field (which will be the original field)
3367
3842
  let hidden_field = await scope.get_custom_datatype_hidden_field( scope, { handle });
3368
3843
  if ( hidden_field !== null ) {