@suffolklitlab/alkiln 4.7.0 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -40,12 +40,25 @@ Format:
40
40
  -->
41
41
  <!-- ## [Unreleased] -->
42
42
 
43
+ ## [4.7.1] - 2022-08-07
44
+ ### Changed
45
+ - Ensure unique folder names for random input screenshots in case developer uses multiple random steps in one Scenario.
46
+ - Ensure random input detects unexpected interview errors.
47
+
48
+ ### Fixed
49
+ - Fix failure when signature page is the first screen.
50
+ - Internal tests failing even when failure status is as expected.
51
+ - Double printing in report of scenario failure, one of them even when Scenario was possibly non-failure, like skipped.
52
+
43
53
  ## [4.7.0] - 2022-07-27
44
54
  ### Added
45
- - Generate random input to fill out a simple form automatically
55
+ - Generate random input to fill out a simple form automatically. Does not yet ensure that random screenshot folder names are unique, or that error screens are detected as errors. 4.7.1 implements those.
46
56
 
47
57
  ### Changed
48
58
  - Add page id to the "Missing Variable or variables on page" error report
59
+ - Allows Story Tables to not include a header row, as long as they have 2 or 3 columns.
60
+ This isn't the suggested way at the moment, since including headers is more readable,
61
+ but can prevent confusing and unnecessary errors.
49
62
 
50
63
  ## [4.6.2] - 2022-06-28
51
64
  ### Changed
package/lib/scope.js CHANGED
@@ -191,6 +191,7 @@ module.exports = {
191
191
  let report = ``;
192
192
  for ( let line of data.lines ) {
193
193
  if ( line.type === `page_id` ) { report += `screen id: `; }
194
+ else if ( line.type === `outcome` ) { report += `\n`; }
194
195
  else if ( line.type === `warning` ) { report += `WARNING: `; }
195
196
  else if ( line.type === `error` ) { report += `\nERROR: ` }
196
197
  report += `${ line.value }\n`;
@@ -356,7 +357,7 @@ module.exports = {
356
357
  // This check might never be needed now as we try to match fields on the page
357
358
  // first in user-triggered cases - users used to be able to access this almost
358
359
  // directly, but they can't anymore. Our functions should know to only pass good things.
359
- let msg = `Cannot find that element.`;
360
+ let msg = `Cannot find the element to tap.`;
360
361
  if ( !elem ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
361
362
 
362
363
  expect( elem, msg ).to.exist;
@@ -617,6 +618,9 @@ module.exports = {
617
618
  let result = { error: ``, elem: null };
618
619
 
619
620
  let winner = await Promise.race([
621
+ // Signature page
622
+ scope.page.waitForSelector( `#dasigpage`, { timeout: scope.timeout }),
623
+ // Any other valid page
620
624
  scope.page.waitForSelector( `#daMainQuestion`, { timeout: scope.timeout }),
621
625
  scope.page.waitForSelector( `#da-retry`, { timeout: scope.timeout }),
622
626
  ]);
@@ -626,10 +630,14 @@ module.exports = {
626
630
  let error_id_elem = await scope.page.$( `#da-retry` );
627
631
  if ( error_id_elem ) {
628
632
  let error_elem = await scope.page.$( `blockquote` );
629
- let error_handle = await error_elem.getProperty( `textContent` );
630
- let system_error_text = await error_handle.jsonValue();
633
+ if (error_elem !== null) {
634
+ let error_handle = await error_elem.getProperty( `textContent` );
635
+ let system_error_text = await error_handle.jsonValue();
631
636
 
632
- result.error = system_error_text;
637
+ result.error = system_error_text;
638
+ } else {
639
+ result.error = "Docassemble ran into an error on the page, but ALKiln does not know what the error is. If a screenshot was taken, check the screenshot";
640
+ }
633
641
  }
634
642
 
635
643
  return result;
@@ -650,9 +658,40 @@ module.exports = {
650
658
  }
651
659
  }, // Ends scope.setLanguage()
652
660
 
653
- normalizeTable: async function ( scope, { var_data, from_story_table=true }) {
654
- /* Return data derived from cucumber variable-setting data.
655
- * Table must have headers. Arrays are not supported. */
661
+ normalizeTable: async function ( scope, { var_data=null, raw_var_data=null }) {
662
+ /* Return data derived from cucumber variable-setting data, from either an object format
663
+ * (var_data) or an array format (raw_var_data). `var_data` only is used when calling
664
+ * normalizeTable internally.
665
+ */
666
+
667
+ if (!var_data && ! raw_var_data) {
668
+ let no_data_msg = `ALKiln Internal error: normalizeTable called w/o var_data or raw_var_data:` +
669
+ ` open an issue at https://github.com/SuffolkLITLab/ALKiln/issues/new if you see this!`;
670
+ await scope.addToReport( scope, { type: `error`, value: no_data_msg });
671
+ throw Error(no_data_msg);
672
+ }
673
+
674
+ // Always use the raw var data if present; it has the full first row
675
+ if (raw_var_data) {
676
+ var_data = raw_var_data.hashes();
677
+ if (!('var' in var_data[0]) || !('value' in var_data[0])) {
678
+ let raw_array = raw_var_data.raw();
679
+ var_data = [];
680
+ for (let row_raw of raw_array) {
681
+ if (row_raw.length < 2) {
682
+ let bad_table_msg = `Your Story Table definition needs to be changed. The best practice`
683
+ + ` is to make your table 3 columns wide with a first row in this format: "| var | value | trigger |".`
684
+ + ` The first row of your table is actually "${ raw_array[0] }"`;
685
+ await scope.addToReport( scope, { type: `error`, value: bad_table_msg})
686
+ throw Error(bad_table_msg);
687
+ } else if (row_raw.length == 2) {
688
+ var_data.push({var: row_raw[0], value: row_raw[1]});
689
+ } else {
690
+ var_data.push({var: row_raw[0], value: row_raw[1], trigger: row_raw[2]});
691
+ }
692
+ }
693
+ }
694
+ }
656
695
 
657
696
  // Support tables with no 'trigger' column
658
697
  let supported_table = [];
@@ -1934,6 +1973,20 @@ module.exports = {
1934
1973
  }
1935
1974
  }, // Ends scope.getThereIsAnotherValue()
1936
1975
 
1976
+ // throw_if_no_interview_content()?
1977
+ has_interview_content: async function ( scope ) {
1978
+ /** Return false if ther's no element indicating a da interview question,
1979
+ * like a signature page, other fields, or interview text. Assumes that page has already
1980
+ * finished navigating. */
1981
+ let elem_da_question = await scope.page.$( `#daMainQuestion` );
1982
+ let elem_da_signature = await scope.page.$( `#dasigpage` );
1983
+ if ( elem_da_question === null && elem_da_signature === null ) {
1984
+ return false;
1985
+ } else {
1986
+ return true;
1987
+ }
1988
+ }, // Ends scope.has_interview_content()
1989
+
1937
1990
  waitUntilContinued: async function ( scope, { url = '', id = '' }) {
1938
1991
  /** Given a url, attempt to continue until the page url changes or an error
1939
1992
  is found. Return the error object no matter what. */
@@ -2279,7 +2332,6 @@ module.exports = {
2279
2332
 
2280
2333
  // For every field on the page
2281
2334
  for ( let field of fields ) {
2282
-
2283
2335
  // fields[n].tag is HTML tag of node, as a string
2284
2336
 
2285
2337
  // If the field is a button, save it in a list of buttons that we'll deal with later
@@ -2406,7 +2458,7 @@ module.exports = {
2406
2458
  if ( type === `file` ) {
2407
2459
  await scope.addToReport( scope, {
2408
2460
  type: `warning`,
2409
- value: `Sorry, ALKiln's random-input cannot yet handle "${ type }" fields.`
2461
+ value: `Sorry, ALKiln's random input tests cannot yet handle "${ type }" fields.`
2410
2462
  });
2411
2463
  } else {
2412
2464
  let answer = await scope.get_random_input_for[ type ]( scope );
package/lib/steps.js CHANGED
@@ -334,7 +334,8 @@ When(/^(?:the user gets)?(?:I get)? to ?(?:the)?(?: ?question)?(?: id)? "([^"]+)
334
334
  // It also seems harsh, but more reliable.
335
335
  // Ask developers what they'd like.
336
336
 
337
- let supported_table = await scope.normalizeTable( scope, { var_data: raw_var_data.hashes() });
337
+
338
+ let supported_table = await scope.normalizeTable( scope, { raw_var_data });
338
339
 
339
340
  let { question_has_id, id, id_matches } = await scope.examinePageID( scope, target_id );
340
341
  // Until we reach the final page or hit an error
@@ -641,7 +642,7 @@ Then(/the "([^"]+)" link opens in (a new window|the same window)/, async (linkTe
641
642
  let [link] = await scope.page.$x(`//a[contains(text(), "${linkText}")]`);
642
643
 
643
644
  let prop_obj = await link.getProperty('target');
644
- let target = await await prop_obj.jsonValue();
645
+ let target = await prop_obj.jsonValue();
645
646
 
646
647
  let should_open_a_new_window = which_window === 'a new window';
647
648
  let opens_a_new_window = target === '_blank';
@@ -790,35 +791,61 @@ When(/I answer randomly for at most ([0-9]+) (?:page(?:s)?|screen(?:s)?)/, { tim
790
791
  * I answer randomly for at most 10 pages
791
792
  * I answer randomly for at most 101 screens
792
793
  */
793
- scope.paths.random_screenshots = `${ scope.paths.scenario }/random_answers_screenshots`;
794
+ // Give each screenshots folder a time-stamped name in case the dev gets clever
795
+ // and use the same Scenario to run multiple randomized tests.
796
+ let timestamp = files.readable_date();
797
+ let random_input_path = `${ scope.paths.scenario }/random_answers_screenshots-${ timestamp }`
798
+ scope.paths.random_screenshots = random_input_path;
794
799
  fs.mkdirSync( scope.paths.random_screenshots );
800
+ await scope.addToReport( scope, {
801
+ type: 'row',
802
+ value: `Testing interview with random input (in ${ random_input_path })`
803
+ });
795
804
 
796
805
  // Force the dev to prevent infinite loops
797
806
  let max_pages = parseInt( max_pages_str );
798
807
  let curr_page_num = 0;
799
808
 
800
- // While a continue button is on the current page, fill out
809
+ // While a continue-type button is on the current page, fill out
801
810
  // the fields and then try to continue
802
- // let continue_exists = true;
803
- // while ( continue_exists && curr_page_num < max_pages ) {
804
811
  let buttons_exist = true;
805
812
  while ( buttons_exist && curr_page_num < max_pages ) {
806
813
 
807
814
  curr_page_num = curr_page_num + 1;
808
815
  let { id } = await scope.examinePageID( scope, `no target id` );
809
816
 
817
+ let no_content_err = `The interview seemed to run into an error page`
818
+ + ` after the page with the question id "${ id }".`
819
+ + ` See the screenshot in "${ random_input_path }".`;
820
+
821
+ let has_thrown = false;
810
822
  // Ensure no infinite loop, but each page has a full timeout to run
811
823
  let custom_timeout = new Promise(async function( resolve, reject ) {
812
824
 
813
825
  // Set up timeout to stop infinite loops
814
826
  let page_timeout_id = setTimeout(async function() {
815
827
 
816
- let timeout_message = `The page with an id of "${ id }" took too long `
817
- + `to fill it with random answers (over ${ scope.timeout/1000/60 } min).`
818
- await scope.addToReport( scope, { type: `error`, value: timeout_message });
819
- throw( timeout_message );
828
+ // Make sure to only throw once
829
+ if ( has_thrown ) { return; }
830
+ else { has_thrown = true; }
820
831
 
821
- }, scope.timeout);
832
+ let has_content = await scope.has_interview_content( scope );
833
+
834
+ if ( !has_content ) {
835
+ await scope.addToReport( scope, {
836
+ type: `error`,
837
+ value: no_content_err
838
+ });
839
+ reject( no_content_err );
840
+
841
+ } else {
842
+ let timeout_message = `The page with an id of "${ id }" took too long`
843
+ + ` to fill with random answers (over ${ scope.timeout/1000/60 } min).`
844
+ await scope.addToReport( scope, { type: `error`, value: timeout_message });
845
+ reject( timeout_message );
846
+ }
847
+
848
+ }, scope.timeout );
822
849
 
823
850
  // self-invoke an anonymous async function so we don't have to
824
851
  // abstract the details of resolving and rejecting.
@@ -830,7 +857,7 @@ When(/I answer randomly for at most ([0-9]+) (?:page(?:s)?|screen(?:s)?)/, { tim
830
857
 
831
858
  // Take a screenshot of the page before continuing or ending
832
859
  await scope.page.screenshot({
833
- path: `${ scope.paths.random_screenshots }/${ Date.now() }.jpg`,
860
+ path: `${ scope.paths.random_screenshots }/random-${ Date.now() }.jpg`,
834
861
  type: `jpeg`,
835
862
  fullPage: true
836
863
  });
@@ -848,7 +875,23 @@ When(/I answer randomly for at most ([0-9]+) (?:page(?:s)?|screen(?:s)?)/, { tim
848
875
  if ( buttons_exist ) {
849
876
  await scope.continue( scope );
850
877
  } // ends if continue exists
851
- }
878
+ } // ends if buttons_exist
879
+
880
+ let has_content = await scope.has_interview_content( scope );
881
+ if ( !has_content ) {
882
+
883
+ // Throw an error if there's no interview content
884
+ // Make sure to only throw once
885
+ if ( !has_thrown ) {
886
+ has_thrown = true;
887
+ await scope.addToReport( scope, {
888
+ type: `error`,
889
+ value: no_content_err
890
+ });
891
+ reject( no_content_err );
892
+ }
893
+
894
+ } // ends if !has_content
852
895
 
853
896
  if ( buttons_exist ) {
854
897
  process.stdout.write(`\x1b[36m${ 'v' }\x1b[0m`); // pressed button
@@ -856,11 +899,28 @@ When(/I answer randomly for at most ([0-9]+) (?:page(?:s)?|screen(?:s)?)/, { tim
856
899
 
857
900
  resolve();
858
901
 
859
- } catch ( err ) {
860
- let err_msg = `The page with the "${ id }" errored when trying to fill `
861
- + `it with random answers: ${ err.name }`
862
- await scope.addToReport( scope, { type: `error`, value: err_msg });
863
- throw( err );
902
+ } catch ( error ) {
903
+
904
+ // Make sure to only throw once
905
+ if ( has_thrown ) { return; }
906
+ else { has_thrown = true; }
907
+
908
+ // Throw a specific error if there's no interview content
909
+ let has_content = await scope.has_interview_content( scope );
910
+ if ( !has_content ) {
911
+ await scope.addToReport( scope, {
912
+ type: `error`,
913
+ value: no_content_err
914
+ });
915
+ reject( no_content_err );
916
+
917
+ // Otherwise throw a generic error
918
+ } else {
919
+ let err_msg = `The page with the question id "${ id }" errored when trying to fill`
920
+ + ` it with random answers: ${ error.name }`
921
+ await scope.addToReport( scope, { type: `error`, value: err_msg });
922
+ reject( error );
923
+ } // ends if !has_content
864
924
 
865
925
  } finally {
866
926
  // prevent temporary hang at end of tests
@@ -1051,9 +1111,9 @@ After(async function(scenario) {
1051
1111
  if ( scope.page ) { await scope.page.waitForTimeout( 60 * 1000 ); }
1052
1112
  }
1053
1113
 
1054
- await scope.addToReport(scope, { type: `outcome`, value: `**-- Scenario Failed --**` });
1055
1114
  if ( scope.page ) {
1056
- if ( scope.disable_error_screenshot ) {
1115
+
1116
+ if ( !!scope.disable_error_screenshot ) {
1057
1117
  scope.addToReport(scope, {
1058
1118
  type: `row`,
1059
1119
  value: `For security, ALKiln will not create a screenshot for this error. It's possible a secret is being used on this screen.`
@@ -1079,11 +1139,12 @@ After(async function(scenario) {
1079
1139
  } // ends if scope.disable_error_screenshot
1080
1140
  } // ends if scope.page exists
1081
1141
 
1082
- } // ends if not passed
1142
+ // This has to come after the security message or security message doesn't print. Not sure why.
1143
+ if (scenario.result.status === `FAILED`) {
1144
+ await scope.addToReport(scope, { type: `outcome`, value: `**-- Scenario Failed --**` });
1145
+ }
1083
1146
 
1084
- if (scenario.result.status === `FAILED`) {
1085
- await scope.addToReport(scope, { type: `outcome`, value: `**-- Scenario Failed --**` });
1086
- }
1147
+ } // ends if not passed
1087
1148
 
1088
1149
  let original_scenario_status = scenario.result.status;
1089
1150
 
@@ -1092,13 +1153,17 @@ After(async function(scenario) {
1092
1153
  let expected = scope.expected_status;
1093
1154
  scope.expected_status = null; // reset for next Scenario
1094
1155
  expect( scenario.result.status ).to.equal( expected );
1156
+ // If the status was as expected, pass this criteria
1157
+ scenario.result.status = `PASSED`;
1095
1158
  }
1096
1159
  if ( scope.expected_in_report && scope.expected_in_report.length > 0) {
1097
1160
  let expected = scope.expected_in_report;
1098
1161
  // Reset report values no matter what so they don't mess up future scenarios
1099
1162
  scope.expected_in_report = null;
1100
1163
  let all_were_included = await scope.reportIncludesAllExpected( scope, { expected });
1164
+
1101
1165
  if ( all_were_included ) { scenario.result.status = `PASSED`; }
1166
+ else { scenario.result.status = `FAILED`; }
1102
1167
  }
1103
1168
 
1104
1169
  // If there is a page open, then sign out and close it
@@ -8,6 +8,7 @@ files.readable_date = function() {
8
8
  * match the workflow artifact date format. */
9
9
  let date = new Date(); // now
10
10
  let str = date.toLocaleString('en-GB', { timeZone: 'UTC' });
11
+ // To properly get the year/month/day format, look at https://stackoverflow.com/a/48729396/14144258
11
12
  let fancy_str = str.replace(/\//g, '-').replace(',', ' at').replace(':', 'hrs ').replace(':', 'mins ') + 'secs UTC';
12
13
  return fancy_str;
13
14
  }; // Ends files.readable_date()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suffolklitlab/alkiln",
3
- "version": "4.7.0",
3
+ "version": "4.7.1",
4
4
  "description": "Integrated automated end-to-end testing with docassemble, puppeteer, and cucumber.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -132,8 +132,3 @@ Scenario: tap element with an error
132
132
  """
133
133
  Given I start the interview at "test_taps"
134
134
  And I tap the "Tests-not_there-tab" tab
135
-
136
- @i5 @random
137
- Scenario: I answer randomly till the end
138
- Given I start the interview at "all_tests"
139
- And I answer randomly for at most 50 pages
@@ -3,13 +3,29 @@ Feature: Assembly Line package-specific Steps
3
3
 
4
4
  # In tag names, 'ri' is for 'random input'
5
5
 
6
- @slow @ri1 @random
6
+ @fast @ri1 @random
7
7
  Scenario: I fill in random input till the end
8
8
    Given I start the interview at "test_random_input"
9
9
  And I answer randomly for at most 20 pages
10
10
  Then the question id should be "end"
11
11
 
12
- @slow @ri2 @random
12
+ @fast @ri2 @random
13
13
  Scenario: I fill in random input for 1 page
14
14
    Given I start the interview at "test_random_input"
15
15
  And I answer randomly for at most 1 page
16
+
17
+ # Should create two unique folders for random step screenshots which
18
+ # must be checked manually
19
+ @fast @ri3 @random
20
+ Scenario: I answer randomly till the end twice
21
+ Given I start the interview at "test_random_input"
22
+ And I answer randomly for at most 50 pages
23
+ Given I start the interview at "test_random_input"
24
+ And I answer randomly for at most 50 pages
25
+
26
+ @fast @ri4 @random @error
27
+ Scenario: Fail with error page from random input
28
+ Given the final Scenario status should be "failed"
29
+ Given I start the interview at "test_missing_var_error_screen"
30
+ And the max seconds for each step in this scenario is 10
31
+ And I answer randomly for at most 3 pages
@@ -3,7 +3,6 @@ Feature: Supported story table formats
3
3
 
4
4
  NOTE:
5
5
  Unsupported formats:
6
- - Arrays (see https://github.com/plocket/docassemble-cucumber/issues/257)
7
6
  - Format 1 and 2 tables (permanent) (see https://github.com/plocket/docassemble-cucumber/issues/264)
8
7
 
9
8
  NOTE:
@@ -75,3 +74,52 @@ Scenario: Table MISSING trigger column
75
74
  | single_quote_dict['single_quote_key']['sq_two'] | true |
76
75
  | text_input | Regular text input field value |
77
76
  | textarea | Multiline text\narea value |
77
+
78
+ @fast @stf5
79
+ Scenario: Table has no header row, has trigger column
80
+   Given I start the interview at "all_tests"
81
+   And I get to "showifs" with this data:
82
+ | double_quote_dict["double_quote_key"]['dq_two'] | true | |
83
+ | checkboxes_other['checkbox_other_opt_1'] | true | |
84
+ | checkboxes_other['checkbox_other_opt_2'] | true | |
85
+ | checkboxes_other['checkbox_other_opt_3'] | false | |
86
+ | checkboxes_yesno | True | |
87
+ | dropdown_test | dropdown_opt_2 | |
88
+ | radio_other | radio_other_opt_3 | |
89
+ | radio_yesno | False | |
90
+ | single_quote_dict['single_quote_key']['sq_two'] | true | |
91
+ | text_input | Regular text input field value | |
92
+ | textarea | Multiline text\narea value | |
93
+
94
+ @fast @stf6
95
+ Scenario: Table has no header row, MISSING trigger column
96
+   Given I start the interview at "all_tests"
97
+   And I get to "showifs" with this data:
98
+ | double_quote_dict["double_quote_key"]['dq_two'] | true |
99
+ | checkboxes_other['checkbox_other_opt_2'] | true |
100
+ | checkboxes_yesno | True |
101
+ | dropdown_test | dropdown_opt_2 |
102
+ | radio_other | radio_other_opt_3 |
103
+ | radio_yesno | False |
104
+ | single_quote_dict['single_quote_key']['sq_two'] | true |
105
+ | text_input | Regular text input field value |
106
+ | textarea | Multiline text\narea value |
107
+
108
+ @fast @stf7
109
+ Scenario: Fails when table has no header row and rows have only one column
110
+ Given the final Scenario status should be "failed"
111
+ And the Scenario report should include:
112
+ """
113
+ Your Story Table definition needs to be changed.
114
+ """
115
+   Given I start the interview at "all_tests"
116
+   And I get to "showifs" with this data:
117
+ | double_quote_dict["double_quote_key"]['dq_two'] |
118
+ | checkboxes_other['checkbox_other_opt_2'] |
119
+ | checkboxes_yesno |
120
+ | dropdown_test |
121
+ | radio_other |
122
+ | radio_yesno |
123
+ | single_quote_dict['single_quote_key']['sq_two'] |
124
+ | text_input |
125
+ | textarea |