@suffolklitlab/alkiln 5.17.1 → 5.17.2-deps

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?
289
+
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 });
259
294
 
260
- let start_url = await scope.page.url(); // so we can later check for navigation
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;
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
+ }));
274
309
 
275
- // Might be able to handle this in `scope.afterStep`
276
- if ( navigated ) {
277
-
278
- // TODO: Can we remove this?
279
- // Save the id that comes next
280
- let { question_has_id, id, id_matches } = await scope.examinePageID( scope, 'none to match' );
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,16 +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
- // TODO: Be more specific. Authors may use `.alert-danger` themselves.
351
- 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 }),
352
384
  scope.page.waitForSelector('.da-has-error', { visible: true }),
353
385
  ]);
354
386
  } catch ( error ) {
355
387
  if ( error.name === `TimeoutError` ) {
356
- 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.`;
357
390
  await scope.handle_page_timeout_error( scope, { non_reload_report_data: {
358
391
  code: `ALK0103`,
359
392
  message: non_reload_report_msg,
@@ -361,8 +394,8 @@ module.exports = {
361
394
  } else {
362
395
  // Throw any non-timeout error
363
396
  let err_msg = `An error occurred when ALKiln tried to tap "${ tapperText }".`
364
- reports.addToReport( scope, { type: `error`, code: `ALK0104`, value: err_msg });
365
- throw error;
397
+ let rpt_msg = reports.addToReport( scope, { type: `error`, code: `ALK0104`, value: [ err_msg, error ].join(`\n`) });
398
+ throw Error( rpt_msg );
366
399
  } // ends if error is timeout error
367
400
 
368
401
  } // ends try/catch
@@ -557,25 +590,39 @@ module.exports = {
557
590
  return false;
558
591
  },
559
592
 
593
+ // ⚠️ Any selectors I find seem somewhat precarious.
560
594
  continue_button_selector_da_v_1_9_n_and_earlier: `fieldset.da-field-buttons button[type="submit"].btn-primary`,
561
595
  continue_button_selector_da_v_1_10_n_and_later: `button[type="submit"]#da-continue-button`,
562
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?
563
598
  signature_cont_btn_selector_da_v_1_9_n_and_earlier: `fieldset .dasigsave`,
564
-
565
599
  signature_cont_btn_selector_backup_da_v_1_9_n_and_earlier: `.da-fieldset .dasigsave`,
566
600
  // ⚠️ docassemble >= 1.10.n, 2 signature page continue buttons. One visible on
567
601
  // wide screen, one visible on narrow screen. We need to get the visible
568
602
  // one, but puppeteer v22.15.0 doesn't recognize an element with a hidden
569
603
  // ancestor as invisible, so we need to get both `.dasigsave` and test for
570
- // visibility our own way for now.
604
+ // visibility with the bounding box for now.
571
605
  signature_cont_btn_selector_da_v_1_10_n_and_later: `.dasigsave`,
572
- continue: async function ( scope ) {
606
+ continue: async function ( scope, { post_nav_handlers=null }={}) {
573
607
  /**
574
608
  * Presses whatever button it finds that might lead to the next page.
575
609
  *
576
- * @param {object} scope - Tests state object
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.
577
623
  * */
578
- // ⚠️ Any selectors I find seem somewhat precarious.
624
+ await scope.log_and_update_if_new_page_id( scope );
625
+
579
626
  let elem_lists = [
580
627
  // non-signature pages (these are the most consistent ways)
581
628
  await scope.page.$$(scope.continue_button_selector_da_v_1_9_n_and_earlier, { visible: true }),
@@ -606,7 +653,10 @@ module.exports = {
606
653
  }
607
654
 
608
655
  // Waits for navigation or user error
609
- await scope.tapElementAndNavigate( scope, { elem: continue_button });
656
+ let error_data = await scope.tapElementAndNavigate( scope, {
657
+ elem: continue_button, post_nav_handlers
658
+ });
659
+ return error_data;
610
660
  }, // Ends scope.continue()
611
661
 
612
662
  continue_exists: async function ( scope ) {
@@ -632,7 +682,6 @@ module.exports = {
632
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 });
633
683
 
634
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;
635
-
636
685
  }, // Ends scope.continue_exists()
637
686
 
638
687
  tapTab: async function ( scope, tab_id ) {
@@ -646,8 +695,9 @@ module.exports = {
646
695
  await scope.waitForTab( scope, { tab_id });
647
696
  },
648
697
 
649
- checkForA11y: async function (scope ) {
698
+ checkForA11y: async function ( scope ) {
650
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?
651
701
  let { id } = await scope.examinePageID( scope, 'none to match' );
652
702
  if ( scope.page_id !== id ) {
653
703
  reports.addToReport(scope, { type: `page_id info`, code: `ALK0108`, value: `${ id }`, });
@@ -682,13 +732,30 @@ module.exports = {
682
732
  },
683
733
 
684
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
+ * */
685
751
  let elem = await scope.page.$(`${ selector }`);
686
- 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?`;
687
753
  if ( !elem ) {
688
754
  reports.addToReport(scope, { type: `error`, code: `ALK0111`, value: error_msg });
689
755
  }
690
756
  expect( elem, error_msg ).to.exist;
691
- await scope.tapElementAndNavigate( scope, { elem, waitForTimeout });
757
+ let error_data = await scope.tapElementAndNavigate( scope, { elem, waitForTimeout });
758
+ return error_data;
692
759
  },
693
760
 
694
761
  tapSelectorAndStay: async function ( scope, { selector }) {
@@ -1132,7 +1199,6 @@ module.exports = {
1132
1199
  // TODO: Do we need daaota as well? ("all of the above")
1133
1200
  }
1134
1201
 
1135
- // Am I doing this wrong? the guesses aren't just the name, right?
1136
1202
  let var_names = await scope.getPossibleDecodedNames( scope, {
1137
1203
  not_decoded: encoded_name,
1138
1204
  field_like_names,
@@ -1155,6 +1221,8 @@ module.exports = {
1155
1221
  values.push( node.attribs.value );
1156
1222
  }
1157
1223
 
1224
+ // TODO: Try replacing null coalescing with using the guesses as var
1225
+ // names.
1158
1226
  field_data.guesses ??= await scope.add_values_if_needed( scope, { var_names, values });
1159
1227
  if ( scope.is_button_ish( scope, { field: field_data }) ) {
1160
1228
  // Discuss: This makes sure buttons are matched with table rows last. Is
@@ -1195,12 +1263,21 @@ module.exports = {
1195
1263
  * */
1196
1264
  let question_data = { ok: false, status: -1 };
1197
1265
  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
1266
 
1201
1267
  try {
1202
- question_response = await scope?.page?.evaluate?.( async ( err_msg ) => {
1268
+ question_response = await scope?.page?.evaluate?.( async () => {
1203
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
+ *
1204
1281
  * @returns {object} -
1205
1282
  * { ok: false }; // ok -> response status or false
1206
1283
  * | { ok, status, ...data, ...( await response.json() ) }
@@ -1208,7 +1285,9 @@ module.exports = {
1208
1285
  * | { ok, status, alk_msg: err_msg }
1209
1286
  * | { ok: false, alk_msg: err_msg, error: response_err.message }
1210
1287
  * */
1211
- let data = { ok: false, alk_msg: null }; // ok -> did we get JSON or not
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 };
1212
1291
  try {
1213
1292
  let response = await fetch(
1214
1293
  window.location.href.replace(`interview?`, `interview?json=1&`)
@@ -1217,41 +1296,29 @@ module.exports = {
1217
1296
  data.ok = response.ok;
1218
1297
  data.status = response.status;
1219
1298
 
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
1299
  let content_type = response?.headers?.get?.(`content-type`);
1230
1300
  if ( content_type?.startsWith?.(`application/json`) ) {
1231
1301
  return { ...data, ...( await response.json() ) };
1232
1302
 
1233
1303
  } 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() };
1304
+ return { ...data, alk_msg: q_resp_err_msg, html: await response.text() };
1238
1305
 
1239
1306
  } 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 };
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 };
1243
1310
  } // ends content-type check
1244
1311
 
1245
1312
  } catch ( response_err ) {
1246
1313
  // Error might be caused by non-da page author navigated to when, for
1247
1314
  // example, testing an "Exit" destination.
1248
- return { ok: false, alk_msg: err_msg, error: response_err.message, }
1315
+ return { ok: false, alk_msg: q_resp_err_msg, error: response_err.message, }
1249
1316
  } // ends fetch try
1250
1317
 
1251
1318
  return data;
1252
- }, err_msg ); // ends page.evaluate() call
1319
+ } ); // ends page.evaluate() call
1253
1320
  } catch ( question_data_error ) {
1254
- question_response = { ok: false, alk_msg: err_msg, error: question_data_error.message, }
1321
+ question_response = { ok: false, alk_msg: q_resp_err_msg, error: question_data_error.message, }
1255
1322
  }
1256
1323
 
1257
1324
  question_data = question_response || question_data;
@@ -1444,7 +1511,13 @@ module.exports = {
1444
1511
  // Really need everything to be unique here if at all possible. Shall we resort
1445
1512
  // to parents and n-th child?
1446
1513
  // Exceptions: Some specific fields can be duplicates without being dangerous
1447
- 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
+ ) {
1448
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.`;
1449
1522
  reports.addToReport(scope, { type: `warning`, code: `ALK0125`, value: msg });
1450
1523
  }
@@ -1946,7 +2019,7 @@ module.exports = {
1946
2019
  );
1947
2020
  if ( final_matching_rows.length == 0) {
1948
2021
  log.debug({ code: `ALK0132`, context: `matching`, },
1949
- `0 matching rows. Field:`, field
2022
+ `0 matching rows for this field on the page:`, field
1950
2023
  );
1951
2024
  }
1952
2025
 
@@ -2046,7 +2119,6 @@ module.exports = {
2046
2119
  // Pass in the selector as an argument
2047
2120
  }, field.selector );
2048
2121
 
2049
-
2050
2122
  return handle;
2051
2123
  }, // Ends scope.get_handle_from_field()
2052
2124
 
@@ -2180,10 +2252,12 @@ module.exports = {
2180
2252
 
2181
2253
  enter_answer: {
2182
2254
  button: async function ( scope, { handle, answer }) {
2255
+ // For ambiguous tap discussion, see https://github.com/SuffolkLITLab/ALKiln/issues/1127
2183
2256
  await scope.tapElementAndNavigate( scope, { elem: handle });
2184
2257
  return true;
2185
2258
  },
2186
2259
  a: async function ( scope, { handle, answer }) {
2260
+ // For ambiguous tap discussion, see https://github.com/SuffolkLITLab/ALKiln/issues/1127
2187
2261
  await scope.tapElementAndNavigate( scope, { elem: handle });
2188
2262
  return true;
2189
2263
  },
@@ -2256,7 +2330,7 @@ module.exports = {
2256
2330
  await scope.afterStep(scope, { waitForShowIf: true });
2257
2331
 
2258
2332
  } else if ( type === `file` ) {
2259
- row_was_used = await scope.uploadFiles( scope, { handle, answer });
2333
+ row_was_used = await scope.upload_files( scope, { handle, answer });
2260
2334
  await scope.afterStep(scope, { waitForShowIf: true });
2261
2335
 
2262
2336
  } else if (type == `date`) {
@@ -2336,7 +2410,7 @@ module.exports = {
2336
2410
  return valid_date;
2337
2411
  }, // Ends scope.today_to_date()
2338
2412
 
2339
- findFiles: async function( scope, {to_find_names}) {
2413
+ find_files: async function( scope, {to_find_names}) {
2340
2414
  /** Returns a list of paths to the sources folder associated
2341
2415
  * with the given list of file names.
2342
2416
  *
@@ -2393,9 +2467,9 @@ module.exports = {
2393
2467
  return [];
2394
2468
  }
2395
2469
  return paths;
2396
- }, // Ends scope.findFiles()
2470
+ }, // Ends scope.find_files()
2397
2471
 
2398
- uploadFiles: async function ( scope, { handle, answer }) {
2472
+ upload_files: async function ( scope, { handle, answer }) {
2399
2473
  /* Try to upload one or more files to a file-type input field.
2400
2474
  * Does not submit, so no danger of timeout with server reload.
2401
2475
  *
@@ -2405,8 +2479,8 @@ module.exports = {
2405
2479
  // First make sure the list of files was defined. Don't remove spaces yet -
2406
2480
  // allow a typo where someone forgot to include a space after a comma.
2407
2481
  let to_find_names = answer.split(`,`);
2408
- let paths = await scope.findFiles(scope, {to_find_names});
2409
- if (paths.length == 0) {
2482
+ let paths = await scope.find_files(scope, {to_find_names});
2483
+ if ( paths.length == 0 ) {
2410
2484
  // The row wasn't used
2411
2485
  return false;
2412
2486
  }
@@ -2418,16 +2492,17 @@ module.exports = {
2418
2492
  // it's not completely necessary. If the gobal `setTimeout` fires then the
2419
2493
  // `evaluate` won't be far behind. They both use about the same timeout
2420
2494
  // duration.
2421
- let finish_uploading = handle.evaluate( async function detect_finished_upload(
2495
+ let finish_uploading = handle.evaluate( async function (
2422
2496
  elem, timeout, answer
2423
2497
  ) {
2424
2498
  return new Promise (( resolve, reject ) => {
2425
2499
  /**
2426
2500
  * Resolve when upload completes. Logging in here will log to the hidden
2427
2501
  * browser. Must instead pass information out with `resolve`.
2502
+ *
2503
+ * Warn about uploading the same file multiple times. It won't protect
2504
+ * against accidentally uploading same file to a different field.
2428
2505
  * */
2429
- // Warn about uploading the same file multiple times
2430
- // Won't protect against accidentally uploading same file to diff field
2431
2506
 
2432
2507
  // If elem.files, the author has previously set this field. Warn them.
2433
2508
  // Example prev_filenames: "C:\\fakepath\\name_of_file.png"
@@ -2447,13 +2522,13 @@ module.exports = {
2447
2522
 
2448
2523
  let result = {
2449
2524
  ok: false, prev_filenames, type: null,
2450
- code: `ALK0283`, level: `debug`, msgs: [], error: null,
2525
+ code: `ALK0283`, level: `info`, msgs: [], error: null,
2451
2526
  };
2452
2527
 
2453
- if ( prev_filenames ) {
2528
+ if (( prev_filenames?.length || 0) > 0 ) {
2454
2529
  result.level = `warn`; // log level
2455
2530
  result.type = `warning`; // report type
2456
- shared_names = shared_names_list.join(`", "`);
2531
+ let shared_names = shared_names_list.join(`", "`);
2457
2532
 
2458
2533
  if ( shared_names_list.length > 0 ) {
2459
2534
  if ( all_matched ) {
@@ -2471,9 +2546,10 @@ module.exports = {
2471
2546
 
2472
2547
  let timeout_id = null;
2473
2548
  let resolve_on_change = function() {
2549
+ // bootstrap-fileinput starts reading
2474
2550
  result.ok = true; // May contain warnings
2475
2551
  clearTimeout( timeout_id );
2476
- result.msgs.push(`Finished uploading these files: "${ answer }"`);
2552
+ result.msgs.push(`Started uploading these files: "${ answer }"`);
2477
2553
  resolve( result );
2478
2554
  };
2479
2555
 
@@ -2494,7 +2570,7 @@ module.exports = {
2494
2570
  }); // ends Promise
2495
2571
 
2496
2572
  }, scope.timeout - 100, answer ); // ends page.evaluate()
2497
-
2573
+
2498
2574
  await handle.uploadFile(...paths);
2499
2575
 
2500
2576
  // Wait for upload to finish. If already done, execution will continue
@@ -2515,9 +2591,31 @@ module.exports = {
2515
2591
  if ( level === `throw` ) { log_type = `throw`; }
2516
2592
  log[ log_type ]({ code, level, error, context: `file_upload`}, ...msgs );
2517
2593
 
2594
+ // LLM Claude (version?) description: While the bootstrap-fileinput is
2595
+ // reading, it disables the input, adds `is-locked` to its container
2596
+ // (lock()), and adds `file-thumb-loading` (_toggleLoading("show")). We'll
2597
+ // wait till these are gone. See GitHub #1125. @plocket tested these
2598
+ // classes out and said they seem legit.
2599
+ await scope.page.waitForFunction(
2600
+ ( el ) => {
2601
+ const container = el.closest(`.file-input`);
2602
+ return (
2603
+ !el.disabled
2604
+ && !( container?.classList?.contains?.(`is-locked`)
2605
+ || container?.classList?.contains?.(`file-thumb-loading`)
2606
+ ));
2607
+ },
2608
+ { timeout: scope.timeout - 100 },
2609
+ handle,
2610
+ );
2611
+
2612
+ log.debug({ code: `ALK0297`, context: `file_upload`, level: `info`, },
2613
+ `Finished uploading files`
2614
+ );
2615
+
2518
2616
  // Return whether successfully used row
2519
2617
  return ok;
2520
- }, // Ends scope.uploadFiles()
2618
+ }, // Ends scope.upload_files()
2521
2619
 
2522
2620
  setCheckbox: async function ( scope, { label, answer }) {
2523
2621
  // Depending on the current value/status of a checkbox and the desired
@@ -2622,32 +2720,28 @@ module.exports = {
2622
2720
  return risky_id.toLowerCase().replace(/[^A-Za-z0-9]+/g, '-');
2623
2721
  },
2624
2722
 
2625
- examinePageID: async function ( scope, question_id = '' ) {
2626
- /* Looks for a sanitized version of the question id as it's written
2627
- * in the .yml. docassemble's way in python:
2628
- * re.sub(r'[^A-Za-z0-9]+', '-', interview_status.question.id.lower())
2629
- *
2630
- * @returns { question_has_id: bool, id: str, id_matches: bool }
2631
- */
2632
- let id_as_class = question_id.toLowerCase().replace(/[^A-Za-z0-9]+/g, '-');
2633
- // `.className` is the node's list of classes as a string, separated by spaces
2634
- let classes_str = await scope.page.$eval(`body`, function (elem) { return elem.className });
2635
- let ids_found = classes_str.match(/question-([^ ]+)/);
2636
-
2637
- // Data that we've decided is useful for our purposes
2638
- let found_any_question_id = ids_found !== null;
2639
- // If there is an id, see if it matches the class
2640
- let id = null;
2641
- let ids_match = false;
2642
- if ( found_any_question_id ) {
2643
- id = ids_found[1]
2644
- ids_match = id === id_as_class;
2645
- }
2646
-
2647
- // TODO: What are good names for these properties?
2648
- // question_has_id? found_any_id?
2649
- // class_matches_id? class_and_id_do_match? id_and_page_id_match?
2650
- return { question_has_id: found_any_question_id, id: id, id_matches: ids_match };
2723
+ examinePageID: async function ( scope, page_id = `` ) {
2724
+ /** Looks for a sanitized version of the question id as it's written
2725
+ * in the .yml. docassemble's way in python:
2726
+ * re.sub(r'[^A-Za-z0-9]+', '-', interview_status.question.id.lower())
2727
+ *
2728
+ * TODO: Replace use of this method with refactored functionality in
2729
+ * `current_id_matches`, `id_as_class_name`, and
2730
+ * `current_page_id_class_name`
2731
+ *
2732
+ * @param {Object} scope - Tests state object
2733
+ * @param {Object} obj - Named arguments
2734
+ * @param {str} obj.id_to_match - A docassemble page id, possibly in .yml
2735
+ * format
2736
+ *
2737
+ * @returns { question_has_id: bool, id: str, id_matches: bool }
2738
+ * */
2739
+ // let id_to_match = page_id.toLowerCase().replace(/[^A-Za-z0-9]+/g, '-');
2740
+ let id_to_match = await scope.id_as_class_name( scope, { page_id });
2741
+ let current_id = await scope.current_page_id_class_name( scope );
2742
+ let question_has_id = !!current_id || false;
2743
+ let id_matches = id_to_match === current_id;
2744
+ return { id: current_id, question_has_id, id_matches };
2651
2745
  }, // Ends scope.examinePageID()
2652
2746
 
2653
2747
  // TODO: Rename var_data to input_data?
@@ -2659,7 +2753,7 @@ module.exports = {
2659
2753
  ensure_navigation=true,
2660
2754
  ensure_all_vars_used=false
2661
2755
  }) {
2662
- /* Non-recursive function to set as many variables as possible before
2756
+ /** Non-recursive function to set as many variables as possible before
2663
2757
  * continuing to the next page. Important to avoid recursion because of async ops.
2664
2758
  * If it's not data in a table, the code can be more permissive about some input.
2665
2759
  *
@@ -2669,19 +2763,23 @@ module.exports = {
2669
2763
  * if navigation is not triggered another way.
2670
2764
  * @param ensure_all_vars_used {bool} [false] = Default false. If true, errors if any
2671
2765
  * object in var_data is not used during running of this function.
2672
- */
2673
- log.debug({ code: `ALK0221`, before: `~~~~~~~~~~~~~~\n`, },
2674
- `scope.setFields()`
2766
+ *
2767
+ * @returns {Object} obj - Named return values
2768
+ * @returns {bool} obj.was_found - True if the check found any errors
2769
+ * @returns {bool} obj.ok - False if the check found any errors. Discuss:
2770
+ * Should we only use `ok` for internal errors?
2771
+ * @param {user|system} obj.type - "user" with invalid answers, "system"
2772
+ * with da or AL server errors
2773
+ * @param {[ElementHandle]} obj.error_handles - List of puppeteer element
2774
+ * handles the check found. There can be multiple invalid answer elems.
2775
+ * */
2776
+ log.debug({ code: `ALK0221`, before: `\n~~~~~~~~~~~~~~\n`, },
2777
+ `Starting scope.setFields()`
2675
2778
  );
2676
2779
 
2677
2780
  let error_data = null;
2678
2781
 
2679
- // Only record the page id once for each page to which we navigate
2680
- let { id } = await scope.examinePageID( scope, 'none to match' );
2681
- if ( scope.page_id !== id ) {
2682
- reports.addToReport(scope, { type: `page_id info`, code: `ALK0138`, value: `${ id }`, });
2683
- scope.page_id = id;
2684
- }
2782
+ let id = await scope.log_and_update_if_new_page_id( scope );
2685
2783
 
2686
2784
  let html = await scope.page.content();
2687
2785
  // These are the fields on the current page. Their handles should all exist.
@@ -2692,7 +2790,6 @@ module.exports = {
2692
2790
  // again until nothing more can be set.
2693
2791
  let something_new_was_set = true; // So loop can start
2694
2792
  while ( something_new_was_set ) {
2695
-
2696
2793
  something_new_was_set = false; // start fresh
2697
2794
  // Go through each field on the page, seeing if we can set its variable
2698
2795
  for ( let field of fields ) {
@@ -2728,13 +2825,11 @@ module.exports = {
2728
2825
  if ( session_vars.get_debug() ) { await time.waitForTimeout(2000); }
2729
2826
 
2730
2827
  // Press a continue button with a story table variable name if it exists
2731
- let did_navigate = false;
2732
- let error = {};
2828
+ let navigated = false;
2733
2829
  for ( let field of fields ) {
2734
2830
  // All other fields should have been set
2735
2831
  if ( scope.is_button_ish( scope, { field }) && field.type === 'submit' ) {
2736
2832
  let page_url = await scope.page.url();
2737
-
2738
2833
  let row_used = await scope.setVariable( scope, { field, var_data, });
2739
2834
  if ( row_used ) {
2740
2835
 
@@ -2746,23 +2841,12 @@ module.exports = {
2746
2841
 
2747
2842
  // Esp important for `.target_number`
2748
2843
  await scope.increment_row_use( scope, { row: row_used });
2844
+ ({ error_data, navigated } = await scope.wait_until_continued( scope, {
2845
+ url: page_url, id
2846
+ }));
2749
2847
 
2750
- // TODO: special continue/navigation button function that handles url and all?
2751
-
2752
- // Have to catch errors right now or may get stuck in an infinite loop?
2753
- // TODO: Don't catch errors - maybe the developer expected/wanted an error
2754
- // For now they'll have to stop the story table early and do the rest the slow way
2755
- // TODO: Discuss: add the option of throwing when an error happens? Per page?
2756
-
2757
- // The developer said to continue by defining this row.
2758
- // For now, that means this page should not cause any kind of error.
2759
- // Make sure no system or user error messages appear
2760
- // Discuss moving this out of here. Having to pass in `id` just for
2761
- // these messages smells bad. Con: Already a lot of code out there.
2762
- error_data = await scope.waitUntilContinued( scope, { url: page_url, id });
2763
-
2764
- did_navigate = true;
2765
- log.stdout({}, `\x1b[36m${ 'v' }\x1b[0m`); // pressed button successfully
2848
+ // pressed button to set var successfully
2849
+ log.stdout({}, `\x1b[36m${ 'v' }\x1b[0m`);
2766
2850
  break;
2767
2851
 
2768
2852
  } // ends if row_used
@@ -2780,43 +2864,35 @@ module.exports = {
2780
2864
  let { id } = await scope.examinePageID( scope, 'none to match' );
2781
2865
  let msg = `In a linear Step all the values you give MUST be filled in, but ALKiln was unable `
2782
2866
  + `to find a field on the page with the "${ id }" for the variable "${ original_row.var }" that `
2783
- + `could be set to "${ original_row.value }". See this Scenario's HTML file for details.`;
2784
- reports.addToReport(scope, { type: `error`, code: `ALK0141`, value: msg });
2785
- expect( 'The page is missing a field', msg ).to.equal( '' );
2867
+ + `could be set to "${ original_row.value }". Alternatively, you`
2868
+ + ` may have duplicate rows in your table. See this Scenario's HTML`
2869
+ + ` file for details.`;
2870
+ let err_msg = reports.addToReport(scope, { type: `error`, code: `ALK0141`, value: msg });
2871
+ throw err_msg;
2786
2872
  }
2787
2873
  }
2788
2874
  }
2789
2875
 
2790
2876
  // If didn't already press a continue button, press one now.
2791
- if ( !did_navigate && ensure_navigation ) {
2792
- // All fields on this page should be taken care of
2877
+ if ( !navigated && ensure_navigation ) {
2878
+ // All fields on this page should have been taken care of already
2793
2879
  let page_url = await scope.page.url();
2794
- await scope.continue( scope );
2795
- // Have to catch errors right now or may get stuck in an infinite loop?
2796
- // TODO: Don't catch errors - maybe the developer expected/wanted an error
2797
- // For now they'll have to stop the story table early and do the rest the slow way
2798
- // TODO: Discuss: add the option of throwing when an error happens? Per page?
2799
-
2800
- // Make sure no system or user error messages appear
2801
- // Discuss moving this out of here. Having to pass in `id` just for
2802
- // these messages smells bad. Con: Already a lot of code out there.
2880
+ error_data = await scope.continue( scope );
2803
2881
 
2804
- error_data = await scope.waitUntilContinued( scope, { url: page_url, id });
2882
+ navigated = true;
2883
+ log.stdout({}, `\x1b[36m${ '>' }\x1b[0m`); // continued successfully automatically
2884
+ } // ends if !navigated && ensure_navigation
2805
2885
 
2806
- did_navigate = true;
2807
- log.stdout({}, `\x1b[36m${ '>' }\x1b[0m`); // continued successfully
2808
- } // ends if !did_navigate && ensure_navigation
2809
-
2810
- if ( did_navigate && scope.check_all_for_a11y ) {
2886
+ if ( navigated && scope.check_all_for_a11y ) {
2811
2887
  let { passed } = await scope.checkForA11y(scope);
2812
2888
  if (!passed) {
2813
2889
  scope.passed_all_for_a11y = false;
2814
2890
  }
2815
2891
  }
2816
2892
 
2817
- // TODO: Since we now continue only once no matter what, we can put
2818
- // the error in the caller of the function, though we may want
2819
- // to pass the symbol to print as well.
2893
+ log.debug({ code: `ALK0288`, context: `scenario`, level: `note`,
2894
+ before: `\n~~~~~~~~~~~~~~\n` }, `Finished scope.setFields()`
2895
+ );
2820
2896
  return error_data;
2821
2897
  // TODO: Maybe we should return something else or something in addition about used vars
2822
2898
  }, // Ends scope.setFields()
@@ -2824,7 +2900,8 @@ module.exports = {
2824
2900
  is_button_ish: function ( scope, { field = null }) {
2825
2901
  /**
2826
2902
  * Return true if the field is similar enough to a button, else false. This
2827
- * does NOT determine whether the field submits the form.
2903
+ * does NOT determine whether the field submits the form, but does
2904
+ * assume the intention is navigation.
2828
2905
  *
2829
2906
  * Discuss: Use "btn" class as well?
2830
2907
  *
@@ -2836,10 +2913,87 @@ module.exports = {
2836
2913
  if ( field.tag === `button`
2837
2914
  || field.selector.includes(`role="button"`)
2838
2915
  || field.selector.includes(`.dasigsave`)
2916
+ || field.selector.includes(`.daquestionactionbutton`)
2839
2917
  ) { return true; }
2840
2918
  return false;
2841
2919
  },
2842
2920
 
2921
+ log_and_update_if_new_page_id: async function ( scope ) {
2922
+ /**
2923
+ * Log the page id if we're on a new page. Abstracting this avoids adding
2924
+ * multiple log codes.
2925
+ *
2926
+ * Discuss: This triggers 2 puppeteer evals. Only 1 is really necessary. How
2927
+ * many pages id checks would make that speed hit matter? Consider copying
2928
+ * `.current_id_matches()` code in here to get the id just once.
2929
+ *
2930
+ * @param {Object} scope - Tests state object
2931
+ *
2932
+ * @returns {str} - the current page .yml id as an HTML class
2933
+ * */
2934
+ let ids_match = await scope.current_id_matches( scope, {
2935
+ id_to_match: scope.page_id
2936
+ });
2937
+ if ( !ids_match ) {
2938
+ let new_id = await scope.current_page_id_class_name( scope );
2939
+ reports.addToReport(scope, { type: `page_id info`, code: `ALK0138`, value: `${ new_id }`, });
2940
+ scope.page_id = new_id;
2941
+ }
2942
+ return scope.page_id;
2943
+ },
2944
+
2945
+ current_id_matches: async function ( scope, { id_to_match=`` }) {
2946
+ /**
2947
+ * Returns true if the given docassemble page id matches the current page id
2948
+ *
2949
+ * @param {Object} scope - Tests state object
2950
+ * @param {Object} obj - Named arguments
2951
+ * @param {str} obj.id_to_match - A docassemble page id, possibly in .yml
2952
+ * format
2953
+ *
2954
+ * @returns {bool} - true if the ids match, otherwise false. Missing ids will match.
2955
+ * */
2956
+ let class_to_match = scope.id_as_class_name( scope, { page_id: id_to_match });
2957
+ let current_id_class = await scope.current_page_id_class_name( scope );
2958
+ return class_to_match === current_id_class;
2959
+ },
2960
+
2961
+ id_as_class_name: function ( scope, { page_id=`` }) {
2962
+ /**
2963
+ * Converts a given page id string (possibly originating from the interview
2964
+ * .yml) as a class name as docassemble does, then returns that value.
2965
+ * This is useful because the .yml page id is included in the DOM as a
2966
+ * class name. docassemble's python code:
2967
+ *
2968
+ * re.sub(r'[^A-Za-z0-9]+', '-', interview_status.question.id.lower())
2969
+ *
2970
+ * @param {Object} scope - Tests state object
2971
+ * @param {Object} obj - Named arguments
2972
+ * @param {str} obj.page_id - A docassemble page id, possibly in .yml format
2973
+ *
2974
+ * @returns {str | null} - the given str as a docassemble-formatted
2975
+ * HTML class name or `null` if `page_id` is not a string.
2976
+ * */
2977
+ return page_id?.toLowerCase?.().replace(/[^A-Za-z0-9]+/g, `-`) || null;
2978
+ },
2979
+
2980
+ current_page_id_class_name: async function ( scope ) {
2981
+ /**
2982
+ * Returns the current page id as an HTML class name or null if missing
2983
+ *
2984
+ * @param {Object} scope - Tests state object
2985
+ *
2986
+ * @returns {str | null} - Current page id as a class name or `null` if none
2987
+ * exists
2988
+ * */
2989
+ // `.className` is a space-sparated list of the node's classes
2990
+ let possible_id_classes = await scope.page.$eval(`body`, function (elem) {
2991
+ return elem.className
2992
+ });
2993
+ let id_matches = possible_id_classes?.match(/question-([^ ]+)/);
2994
+ return id_matches?.[1] || null;
2995
+ },
2996
+
2843
2997
  increment_row_use: async function ( scope, { row }) {
2844
2998
  /** Accumulate number of times this row was used. Esp important for `.target_number`.
2845
2999
  * Mutates `row` and its props. */
@@ -3072,105 +3226,369 @@ module.exports = {
3072
3226
  }
3073
3227
  }, // Ends scope.has_interview_content()
3074
3228
 
3075
- waitUntilContinued: async function ( scope, { url = '', id = '' }) {
3076
- /** Given a url, attempt to continue until the page url changes or an error
3077
- is found. Return the error object no matter what. */
3078
- let prev_url = url;
3079
- let curr_url = await scope.page.url();
3080
- let error_data = await scope.checkForError( scope );
3229
+ // ⚠️ Keep text of `id=...` unique from others to identify problems better
3230
+ wait_until_continued: async function ( scope, {
3231
+ url=``, id=`Unknown id`, post_nav_handlers=null
3232
+ }) {
3233
+ /**
3234
+ * Given a url and id of an initial page, attempt to detect navigation.
3235
+ * After the results of the navigation attempt, run the post_nav_handlers.
3236
+ *
3237
+ * ‼️ ALWAYS include required critical error handlers in `post_nav_handlers`
3238
+ * that will throw errors. We need the post-navigation handlers in this
3239
+ * order:
3240
+ *
3241
+ * 1.(required) A page with a visible system error is a critical error and always
3242
+ * trumps other errors, so it comes first
3243
+ * 2. (optional) Invalid answers and other mmore specific handlers trump unspecified
3244
+ * nav errs
3245
+ * 3. (required) Navigation attempt that timed out is a critical error that lacks
3246
+ * further information
3247
+ * 4. (optional) Unspecified non-navigation catchall comes last
3248
+ *
3249
+ * See docs/decisions/017_post_navigation_custom_behavior_2026_10_26.md for
3250
+ * a deeper discussion.
3251
+ *
3252
+ * Unfortunately, our navigation detection game is weaksauce. Navigation
3253
+ * detection thoughts: https://github.com/SuffolkLITLab/ALKiln/issues/229
3254
+ *
3255
+ * @param {Object} scope - Tests state object
3256
+ * @param {Object} kwargs - Named arguments
3257
+ * @param {str} kwargs.url - Assumed to be the url of the initial page
3258
+ * @param {str} kwargs.id - ID of the initial page
3259
+ * @param {[function]} [kwargs.post_nav_handlers] - Functions to handle page
3260
+ * errors like system errors, user errors (invalid answers), and
3261
+ * navigation failure
3262
+ *
3263
+ * @returns {Object} kwrvs - Named return values
3264
+ * @returns {bool} kwrvs.navigated - True if the interview got to a new page
3265
+ * @returns {Object} kwrvs.error_data - Data for any errors found
3266
+ * @returns {bool} kwrvs.error_data.was_found - True if the check found any
3267
+ * errors
3268
+ * @returns {bool} kwrvs.error_data.ok - False if the check found any errors.
3269
+ * Discuss: Should we only use `ok` for internal errors?
3270
+ * @returns {`user`|`system`} kwrvs.error_data.type - "user" with invalid
3271
+ * answers, "system" with da or AL server errors
3272
+ * @returns {[ElementHandle]} kwrvs.error_data.error_handles - List of
3273
+ * puppeteer element handles the check found. There can be multiple
3274
+ * invalid answer elements.
3275
+ * */
3276
+ await scope.log_and_update_if_new_page_id( scope );
3081
3277
 
3082
- let timedOut = false; // Lazy timeout?
3083
- let cont_timeout = setTimeout(function(){ timedOut = true }, scope.timeout);
3278
+ post_nav_handlers ??= [
3279
+ scope.throw_on_system_error,
3280
+ scope.throw_on_invalid_answers,
3281
+ scope.throw_on_non_navigation_timeout,
3282
+ scope.throw_on_non_navigation_catchall,
3283
+ ];
3284
+ scope.throw_if_missing_required_post_nav_handlers( scope, { handlers: post_nav_handlers })
3084
3285
 
3085
- // Attempt to solve weird race condition issues where page has not properly continued
3086
- while ( curr_url === prev_url && error_data.ok && !timedOut ) {
3286
+ let initial_id = id;
3287
+ let initial_url = url;
3288
+ let curr_url = await scope.page.url();
3289
+ let error_data = await scope.checkForErrors( scope );
3290
+
3291
+ // Attempt to solve weird race condition issues where page has not properly
3292
+ // continued. May exceed actual current timeout.
3293
+ // Discuss: Use a global clock to properly track of remaining time to avoid
3294
+ // scope.timeout - 100 and such? Any such cucumber or puppeteer feature?
3295
+ let timed_out = false; // Lazy timeout?
3296
+ let nav_timeout = setTimeout(function(){ timed_out = true }, scope.timeout - 100);
3297
+ while ( curr_url === initial_url && error_data.ok && !timed_out ) {
3087
3298
  await time.waitForTimeout( 50 );
3299
+ error_data = await scope.checkForErrors( scope );
3088
3300
  curr_url = await scope.page.url();
3089
- error_data = await scope.checkForError( scope );
3090
3301
  }
3302
+ clearTimeout( nav_timeout );
3303
+
3304
+ // TODO: Review architecture - can we refactor nav out of global state?
3305
+ scope.navigated = curr_url !== initial_url;
3091
3306
 
3307
+ error_data = await scope.checkForErrors( scope );
3092
3308
  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 });
3309
+ for ( let handler of post_nav_handlers ) {
3310
+ await handler( scope, { id: initial_id, error_data, timed_out });
3311
+ }
3096
3312
  }
3097
3313
 
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.`
3099
- if ( timedOut ) { reports.addToReport(scope, { type: `error`, code: `ALK0146`, value: timeout_err_message }); }
3100
- expect( timedOut, timeout_err_message ).to.be.false;
3314
+ return { error_data, navigated: curr_url !== initial_url };
3315
+ }, // Ends scope.wait_until_continued()
3101
3316
 
3102
- clearTimeout( cont_timeout );
3317
+ throw_if_missing_required_post_nav_handlers: function( scope, { handlers=[] }={}) {
3318
+ /**
3319
+ * Return true if the list of functions contains the required handlers,
3320
+ * otherwise throw an internal error.
3321
+ *
3322
+ * @param {Object} scope - Tests state object
3323
+ * @param {Object} kwargs - Named arguments
3324
+ * @param {[func]} kwargs.handlers - List of functions
3325
+ *
3326
+ * @returns {bool} - True if function completes successfully
3327
+ * */
3328
+ let missing_handler_errors = [];
3329
+ if ( !handlers.includes( scope.throw_on_system_error ) ) {
3330
+ let missing_system_msg = log.debug({ code: `ALK0295`, context: `internal nav_handlers`, level: `error` },
3331
+ `ALKiln's internal list of post-navigation handlers must include "scope.throw_on_system_error()"`
3332
+ );
3333
+ missing_handler_errors.push( missing_system_msg );
3334
+ }
3335
+ if ( !handlers.includes( scope.throw_on_non_navigation_timeout ) ) {
3336
+ let missing_timeout_msg = log.debug({ code: `ALK0296`, context: `internal nav_handlers`, level: `error` },
3337
+ `ALKiln's internal list of post-navigation handlers must include "scope.throw_on_non_navigation_timeout()"`
3338
+ );
3339
+ missing_handler_errors.push( missing_timeout_msg );
3340
+ }
3103
3341
 
3104
- return error_data;
3105
- }, // Ends scope.waitUntilContinued()
3342
+ if ( missing_handler_errors.length > 0 ) {
3343
+ throw ReferenceError( missing_handler_errors.join(`\n`) );
3344
+ }
3106
3345
 
3107
- checkForError: async function ( scope ) {
3108
- /* Returns whether any system or user error elements were found and, if so,
3109
- * returns those elements. The error elements are probably exclusive, but
3110
- * we're not sure. */
3346
+ return true;
3347
+ },
3111
3348
 
3112
- // All results are lists to match puppeteerjs `$x`
3349
+ validation_err_selector: `#daflash .alert-danger`,
3350
+ // Discuss: `validation code` message can be on a page even after user fixed
3351
+ // values. Always dismiss `validation code` before trying to continue? Or
3352
+ // just detect navigation through requests and responses, which we need to
3353
+ // switch to anyway.
3354
+ // ⚠️ 'display:' and 'none' MUST be separated by a space
3355
+ invalid_field_selector: `xpath/.//*[contains(@class, "da-has-error")][not(contains(@style,"display: none"))]`,
3356
+ // ⚠️ Assumptions: ": Error" stays in the metadata of the page and #damain
3357
+ // only appears on da error pages
3358
+ sys_err_selector: `xpath/.//meta[contains(@content, ": Error")]|.//*[@id="damain"]//h1[contains(text(), "Error")]`,
3359
+ // `al_custom_error_action` in base64. Replaces system error pages, but acts
3360
+ // like a question screen. Shows "The error details are no longer
3361
+ // available..." message when GET requests error (default da message?)
3362
+ al_err_selector: `.question-custom-error-action .daattributions #trigger[data-variable="YWxfY3VzdG9tX2Vycm9yX2FjdGlvbg"]`,
3363
+ checkForErrors: async function ( scope ) {
3364
+ /**
3365
+ * Return any elements from invalid user answers or system errors & other
3366
+ * data. Avoid throwing. Invalid answers may be the author's goal.
3367
+ *
3368
+ * Note: May later want the actual text of the invalid answer messages.
3369
+ *
3370
+ * @param {Object} scope - Tests state object
3371
+ *
3372
+ * @returns {Object} obj - Named return values
3373
+ * @returns {bool} obj.was_found - True if the check found any errors
3374
+ * @returns {bool} obj.ok - False if the check found any errors. Discuss:
3375
+ * Should we only use `ok` for internal errors?
3376
+ * @param {user|system} obj.type - "user" with invalid answers, "system"
3377
+ * with da or AL server errors
3378
+ * @param {[ElementHandle]} obj.error_handles - List of puppeteer element
3379
+ * handles the check found. There can be multiple invalid answer elems.
3380
+ * */
3381
+ // ⚠️ Keep these error objs in this order. For now, the first item on the
3382
+ // list is the one we later use, and `system` errors have priority.
3113
3383
  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`) },
3116
- // The below does not work if there's not a space between ':' and 'none'
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"]` ) },
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.
3120
- { type: `system`, handlers: await scope.page.$$('.question-custom-error-action .daattributions #trigger[data-variable="YWxfY3VzdG9tX2Vycm9yX2FjdGlvbg"]') },
3384
+ { type: `system`, handles: await scope.page.$$( scope.sys_err_selector ) },
3385
+ { type: `system`, handles: await scope.page.$$( scope.al_err_selector ) },
3386
+ { type: `user`, handles: await scope.page.$$( scope.validation_err_selector ) },
3387
+ { type: `user`, handles: await scope.page.$$( scope.invalid_field_selector ) },
3121
3388
  ];
3122
3389
 
3123
- let error_handlers = []; // TODO: Refactor to be single item
3390
+ // Can have multiple user errors which we may get the text of in future
3391
+ let error_handles = [];
3124
3392
  let was_found = false;
3125
3393
  let type = null;
3126
3394
  for ( let one_err_type of possible_error_types ) {
3127
- if ( one_err_type.handlers[0] ) {
3128
- // If there's a page visible (locally), give us as much time to examine it as timeout allows.
3129
- // Because of previous actions, this may still be a bit too long.
3130
- // Also, if you're debugging and you close the window early, the process will still
3131
- // take this long to exit after the tests finish. Not sure how to account for that.
3395
+ if ( one_err_type.handles?.[0] ) {
3396
+ // If there's a page visible (locally), give us almost as much time to
3397
+ // examine it as timeout allows. Because of previous actions, this may
3398
+ // still be a bit too long.
3399
+ // Also, if you're debugging and you close the window early, the process
3400
+ // will still take this long to exit after the tests finish. May fix
3401
+ // in future, but it's just during debugging, so yolo for now!
3132
3402
  if ( session_vars.get_debug() ) {
3133
3403
  await time.waitForTimeout( ( scope.timeout - (1 * 1000)) );
3134
3404
  }
3405
+
3135
3406
  was_found = true;
3136
- type = one_err_type.type
3137
- error_handlers.push( one_err_type.handlers[0] );
3138
- break;
3139
- }
3140
- }
3407
+ error_handles = error_handles.concat( one_err_type.handles )
3408
+ if ( !type || one_err_type.type == `system` ) {
3409
+ type = one_err_type.type
3410
+ }
3411
+ } // ends if first handler
3412
+ } // ends for each error type
3141
3413
 
3142
- return { was_found, ok: !was_found, type, error_handlers };
3143
- }, // Ends scope.checkForError()
3414
+ return { was_found, ok: !was_found, type, error_handles };
3415
+ }, // Ends scope.checkForErrors()
3144
3416
 
3145
- throw_system_error: async function ( scope, {
3146
- error_data = null, error_handlers = null, id = ``, error_text = ``
3417
+ throw_on_system_error: async function ( scope, {
3418
+ error_data=null, id=`Unknown ID`
3147
3419
  }) {
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;
3420
+ /**
3421
+ * Throw if there is a system/server error. Include some page text.
3422
+ *
3423
+ * @param {Object} scope - Tests state object
3424
+ * */
3425
+ if ( error_data?.type === `system` ) {
3426
+ let alk_msg = [`The test ran into a system error before or on the page with the id "${ id }". The picture of this page, if available, may show more.`];
3427
+ let report_msg = reports.addToReport(scope, {
3428
+ type: `error`, code: `ALK0147`,
3429
+ value: [ alk_msg, ``, await scope.get_system_err( scope ) ].join(`\n`),
3430
+ });
3431
+ throw Error( report_msg );
3157
3432
  }
3158
3433
 
3159
3434
  return scope;
3160
- }, // Ends scope.throw_system_error()
3435
+ }, // Ends scope.throw_on_system_error()
3161
3436
 
3162
- throw_invalid_answer: async function ( scope, {
3163
- error_data = {}, id = ''
3164
- }) {
3165
- // It was a user error, which the author may be expecting
3437
+ get_system_err: async function ( scope, { num_lines=8 }={}) {
3438
+ /**
3439
+ * Returns a sample of error-related text on a docassemble or Assembly Line
3440
+ * system error page
3441
+ *
3442
+ * Note: YWxfZm9ybWF0dGVkX2Vycm9yX21lc3NhZ2U is "al_formatted_error_message"
3443
+ *
3444
+ * @param {Object} scope - Tests state object
3445
+ * @param {Object} obj - Named arguments
3446
+ * @param {int} obj.num_lines - Number of lines of HTML text to return
3447
+ *
3448
+ * @returns {str} - A sample of the HTML related to the system error
3449
+ * */
3450
+ let truncated_text = `Unable to get the text on this page. Check the picture of this page if possible.`
3451
+ try {
3452
+
3453
+ let page_err_text = await scope.page.evaluate(() => {
3454
+ return (
3455
+ ( document.querySelector(`#YWxfZm9ybWF0dGVkX2Vycm9yX21lc3NhZ2U`)
3456
+ || document.querySelector(`.dabody`)
3457
+ ).textContent
3458
+ );
3459
+ });
3460
+
3461
+ log.debug({ code: `ALK0287`, context: `page_error`, level: `info` },
3462
+ `Full text of page error:`, page_err_text
3463
+ );
3464
+
3465
+ truncated_text = page_err_text.split(`\n`)
3466
+ .slice( 0, num_lines )
3467
+ .join(`\n`)
3468
+ .trim()
3469
+ + `\n...`;
3470
+
3471
+ } catch ( pupp_err ) {
3472
+ log.debug({ code: `ALK0292`, context: `internal`, level: `note` },
3473
+ `Skipped getting system error screen text with puppeteerjs:`, pupp_err,
3474
+ );
3475
+ }
3476
+
3477
+ return truncated_text;
3478
+ }, // Ends scope.get_system_err()
3479
+
3480
+ throw_on_invalid_answers: async function ( scope, {
3481
+ error_data={}, id=`Unknown ID`
3482
+ }={}) {
3483
+ /**
3484
+ * Throw an error if the user has given invalid answers. If the author
3485
+ * simply wants to detect invalid answers and avoid throwing, use
3486
+ * `scope.checkForErrors()`.
3487
+ *
3488
+ * Discuss: Change our architecture to handle throwing and reporting in some
3489
+ * central location down/up-stream. Here, for example, I want to add an
3490
+ * error to the report, but "I can't continue" is catching this error and
3491
+ * letting the test pass. How can we handle that? Allow editing the report
3492
+ * after the fact? Throwing the report message with log code and all and
3493
+ * count on the caller to report it?
3494
+ *
3495
+ * @param {Object} scope - Tests state object
3496
+ * @param {Object} [obj] - Named arguments
3497
+ * @param {Object} [obj.error_data] - Maybe data for invalid answers' markers
3498
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3499
+ *
3500
+ * @returns {Object} - Error data given
3501
+ * */
3166
3502
  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;
3503
+ let err_msg = `The user got an invalid answer on the page with the id "${ id }". You can check the picture of this page unless you used secrets.`;
3504
+ let report_msg = reports.addToReport(scope, { type: `error`, code: `ALK0148`, value: err_msg });
3505
+ throw Error( report_msg );
3170
3506
  }
3171
3507
 
3172
- return scope;
3173
- }, // Ends scope.throw_invalid_answer()
3508
+ return error_data;
3509
+ }, // Ends scope.throw_on_invalid_answers()
3510
+
3511
+ warn_on_invalid_answers: async function ( scope, {
3512
+ error_data={}, id=`Unknown ID`
3513
+ }={}) {
3514
+ /**
3515
+ * Add a warning to the report if the page has invalid answer indicators.
3516
+ *
3517
+ * @param {Object} scope - Tests state object
3518
+ * @param {Object} [obj] - Named arguments
3519
+ * @param {Object} [obj.error_data] - Maybe data for invalid answers' markers
3520
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3521
+ *
3522
+ * @returns {Object} - Error data given
3523
+ * */
3524
+ let initial_id = id;
3525
+ if ( !error_data.ok && error_data.type === `user` ) {
3526
+ // TOOD: Add a deprecation message to Steps that call this
3527
+ let report_msg = reports.addToReport(scope, {
3528
+ type: `warning`, code: `ALK0290`, value: `The user got an invalid answer on the page with the id "${ initial_id }".`
3529
+ });
3530
+ }
3531
+
3532
+ return error_data;
3533
+ }, // Ends scope.warn_on_invalid_answers()
3534
+
3535
+ throw_on_non_navigation_timeout: async function ( scope, {
3536
+ timed_out=false, id=`Unknown ID`
3537
+ }={}) {
3538
+ /**
3539
+ * Throw an error if the page hasn't navigated and the nav check timed out.
3540
+ *
3541
+ * @param {Object} scope - Tests state object
3542
+ * @param {Object} [obj] - Named arguments
3543
+ * @param {Object} [obj.timed_out] - Whether navigation timed out
3544
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3545
+ *
3546
+ * @returns {undefined}
3547
+ * */
3548
+ let initial_id = id;
3549
+ let timeout_err_message = `The test tried to navigate away from the page with the id "${ initial_id }", but it took longer than ${ (scope.timeout/1000)/60 } min. Unless you used a secret, you can check the picture of this page.`
3550
+ if ( !scope.navigated && timed_out ) {
3551
+ reports.addToReport(scope, { type: `error`, code: `ALK0146`, value: timeout_err_message });
3552
+ throw Error( timeout_err_message );
3553
+ }
3554
+ },
3555
+
3556
+ throw_on_non_navigation_catchall: async function ( scope, { id=`Unknown ID` }={}) {
3557
+ /**
3558
+ * Throw an error if the page hasn't navigated. Used in cases where we
3559
+ * haven't identified why the interaction didn't navigate.
3560
+ *
3561
+ * @param {Object} scope - Tests state object
3562
+ * @param {Object} [obj] - Named arguments
3563
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3564
+ *
3565
+ * @returns {undefined}
3566
+ * */
3567
+ let initial_id = id;
3568
+ if ( !scope.navigated ) {
3569
+ let non_nav_msg = `Was unable to navigate away from page with the id "${ initial_id }".`
3570
+ reports.addToReport(scope, { type: `error`, code: `ALK0291`, value: non_nav_msg });
3571
+ throw Error( non_nav_msg );
3572
+ }
3573
+ },
3574
+
3575
+ warn_on_non_navigation_catchall: async function ( scope, { id=`Unknown ID` }={}) {
3576
+ /**
3577
+ * Warn if the page hasn't navigated. Used in cases where we haven't
3578
+ * identified why the interaction didn't navigate.
3579
+ *
3580
+ * @param {Object} scope - Tests state object
3581
+ * @param {Object} [obj] - Named arguments
3582
+ * @param {str} [obj.id] - ID of the page that generated errors, if they exist
3583
+ *
3584
+ * @returns {undefined}
3585
+ * */
3586
+ let initial_id = id;
3587
+ if ( !scope.navigated ) {
3588
+ let non_nav_msg = `Was unable to navigate away from page with the id "${ initial_id }".`
3589
+ reports.addToReport(scope, { type: `warning`, code: `ALK0293`, value: non_nav_msg });
3590
+ }
3591
+ },
3174
3592
 
3175
3593
  take_a_screenshot: async ( scope,{ path }) => {
3176
3594
  /* Takes a jpeg screenshot. Avoids destroying signatures. */
@@ -3393,7 +3811,7 @@ module.exports = {
3393
3811
  /** Given a fetch response headers' Content-Disposition str, return the
3394
3812
  * filename of the response's file.
3395
3813
  *
3396
- * @return { string | null } - Name of fetched file
3814
+ * @returns { string | null } - Name of fetched file
3397
3815
  * */
3398
3816
  let filename = default_name;
3399
3817
  if ( disposition ) {
@@ -3415,7 +3833,7 @@ module.exports = {
3415
3833
  }, // Ends scope.steps.get_response_filename()
3416
3834
 
3417
3835
  compare_pdfs: async function (scope, {existing_pdf_path, new_pdf_path}) {
3418
- let existing_paths = await scope.findFiles(scope, {to_find_names: [existing_pdf_path]});
3836
+ let existing_paths = await scope.find_files(scope, {to_find_names: [existing_pdf_path]});
3419
3837
  if (existing_paths.length == 0) {
3420
3838
  // The row wasn't used
3421
3839
  let msg = `Could not find the existing PDF at ${ existing_pdf_path }`;
@@ -3809,7 +4227,7 @@ module.exports = {
3809
4227
  let handle = await scope.page.waitForSelector(`#dasigcanvas`);
3810
4228
  // We cannot be sure of knowing the name of the signer and so want to
3811
4229
  // indicate it's random, but we do want something unique
3812
- await scope.funnel_the_answer( scope, { field, handle, answer: `Random ${ faker.name.lastName() }` });
4230
+ await scope.funnel_the_answer( scope, { field, handle, answer: `Random ${ faker.person.lastName() }` });
3813
4231
  },
3814
4232
  input: async function (scope, { field }) {
3815
4233
  /** Handles various `type`s of input element fields, except:
@@ -3912,7 +4330,7 @@ module.exports = {
3912
4330
  return `${ Math.random() > .5 }`;
3913
4331
  },
3914
4332
  text: async function ( scope ) {
3915
- return faker.random.word();
4333
+ return faker.word.sample();
3916
4334
  },
3917
4335
  number: async function ( scope ) {
3918
4336
  let answer = faker.helpers.arrayElement( [`1`, `2`, `3`], 1 );
@@ -3938,7 +4356,7 @@ module.exports = {
3938
4356
  return `${ Math.random() * 100 }`;
3939
4357
  },
3940
4358
  email: async function ( scope ) {
3941
- return faker.internet.email( faker.random.word(), faker.random.word(), `example.com` );
4359
+ return faker.internet.email( faker.word.adjective(), `-`, faker.word.noun(), `example.com` );
3942
4360
  },
3943
4361
  password: async function ( scope ) {
3944
4362
  return faker.internet.password();