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