@suffolklitlab/alkiln 5.2.1 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/scope.js CHANGED
@@ -9,6 +9,7 @@ const safe_filename = require("sanitize-filename");
9
9
  const faker = require('@faker-js/faker/locale/en').faker;
10
10
 
11
11
  // Ours
12
+ const pdf_reader = require('./utils/pdf_reader');
12
13
  const session_vars = require('./utils/session_vars');
13
14
  const get_base_interview_url = require(`./docassemble/get_base_interview_url`);
14
15
  const time = require('./utils/time');
@@ -17,6 +18,7 @@ const { waitForTimeout } = require('./utils/time');
17
18
  const files = require('./utils/files' );
18
19
  const server = require('./docassemble/docassemble_api_interface');
19
20
  const wrapPromiseWithTimeout = require('./cucumber_8_shim');
21
+ const reports = require(`./utils/reports`);
20
22
 
21
23
 
22
24
  let ordinal_to_integer = {
@@ -82,152 +84,10 @@ let toBase64 = function ( utf8_str, exclude_padding=true) {
82
84
 
83
85
  let proxies_regex = /(\bx\.)|(\bx\[)|(\[[ijklmn]\])/g;
84
86
  let da_base64_regex = /^[A-Za-z0-9+/]+$/;
85
- let misc_artifacts_dir = `_alkiln-misc_artifacts`;
86
87
 
87
88
  module.exports = {
88
89
  trigger_not_needed_flag: `ALKiln: no trigger variable needed`,
89
90
 
90
- addToReport: async function( scope, { type, value }) {
91
- /* Add an item to a specific list in a scenario's report.
92
- * Create report if necessary.
93
- *
94
- * The report has a key for each scenario and each of those
95
- * keys should correspond to a list containing the relevant data. */
96
- if ( session_vars.get_debug() ) { console.log(`Report:\ntype: ${ type }, value: ${ value }\n`) }
97
- let scenario = await scope.makeSureReportPartsExist( scope );
98
- scenario.lines.push({ type, value });
99
-
100
- // TODO: Not sure how to use .add_to_debug_log() here considering possibly dynamic filepath
101
- // log.add_to_debug_log(`Report:\ntype: ${ type }, value: ${ value }\n`);
102
-
103
- // Ensure filepath for saving local unit test logs
104
- if ( !scope.paths ) {
105
- // TODO: would it be useful to put in existing artifacts folder if possible.
106
- // Can this be usefully abstracted?
107
- scope.paths = { debug_log: `${ misc_artifacts_dir }/unit_tests_logs-${ files.readable_date() }.txt` };
108
- }
109
-
110
- try {
111
- fs.appendFileSync(scope.paths.debug_log, `\nReport:\ntype: ${ type }, value: ${ value }`);
112
- } catch (err) {
113
- fs.mkdirSync( misc_artifacts_dir );
114
- fs.appendFileSync(scope.paths.debug_log, `\nReport:\ntype: ${ type }, value: ${ value }`);
115
- }
116
- return scenario;
117
-
118
- }, // Ends scope.addToReport()
119
-
120
- makeSureReportPartsExist: async function( scope ) {
121
- /* If a part of the report needs to exist right now, but doesn't exist,
122
- * create it. */
123
- if ( !scope.report ) { scope.report = new Map(); }
124
- if ( !scope.scenario_id ) {
125
- scope.scenario_id = `_report_${ Date.now() }`;
126
- }
127
- if ( !scope.report.get( scope.scenario_id )) {
128
- scope.report.set( scope.scenario_id, {} )
129
- scope.report.get( scope.scenario_id ).status = `in progress`;
130
- }
131
-
132
- let scenario = scope.report.get( scope.scenario_id );
133
- if ( !scenario.lines ) { scenario.lines = []; }
134
-
135
- return scenario;
136
- }, // Ends scope.makeSureReportPartsExist()
137
-
138
- setReportScenarioStatus: async function( scope, { status=`PASSED` }) {
139
- /* Add final status of scenario to scenario report object - passed, failed, etc. */
140
- scope.report.get( scope.scenario_id ).status = status;
141
- }, // Ends scope.setReportScenarioStatus()
142
-
143
- convertToOriginalStoryTableRow: async function( scope, { row_data }) {
144
- /* Returns the original data of row into a string formatted as a story table row.
145
- * Should this show the actual row data that was used instead? That could
146
- * be pretty confusing to most devs. */
147
- let source_row = row_data.source || row_data;
148
- let original = source_row.original;
149
- let var_str = await scope.toTrimmedJSON( scope, { str: original.var });
150
- let value_str = await scope.toTrimmedJSON( scope, { str: original.value });
151
- let trigger_str = await scope.toTrimmedJSON( scope, { str: original.trigger });
152
- if ( trigger_str === scope.trigger_not_needed_flag ) {
153
- // This text was added by ALKiln itself. It's not part of the user's data
154
- trigger_str = ``;
155
- }
156
- return `${ ' '.repeat(6) }| ${ var_str } | ${ value_str } | ${ trigger_str } |`;
157
- }, // Ends scope.convertToOriginalStoryTableRows()
158
-
159
- toTrimmedJSON: async function( scope, { str='' }) {
160
- /* Turn into JSON string then trim quotes from the start and end. */
161
- let json = JSON.stringify( str );
162
- let trimmed = json.replace( /^"/, '' ).replace( /"$/, '' ).replace( /\\\\/g, `\\`);
163
- return trimmed;
164
- }, // Ends scope.toTrimmedJSON()
165
-
166
- addReportHeading: async function (scope, { scenario }) {
167
- /** Return printable heading based on Scenario name and tags.
168
- * Allow characters from other languages.
169
- */
170
- if ( session_vars.get_debug() ) { console.log( scenario.pickle.name ); }
171
-
172
- // Collect all tag names
173
- let tag_names = [];
174
- for ( let tag of scenario.pickle.tags ) {
175
- tag_names.push( tag.name );
176
- }
177
-
178
- let heading = `\n---------------\n`
179
- + `Scenario: ${ scenario.pickle.name }`;
180
-
181
- if ( tag_names.length > 0 ) {
182
- heading += `\nTags: ${ tag_names.join(' ') }`;
183
- }
184
-
185
- heading += `\n---------------`;
186
-
187
- await scope.addToReport( scope, {
188
- type: `heading`,
189
- value: heading,
190
- });
191
- }, // Ends scope.addReportHeading()
192
-
193
- getPrintableReport: async function( scope ) {
194
- /* Return a string generated from test report data. */
195
- let report = `Assembly Line Kiln Automated Testing Report - ${ (new Date()).toUTCString() }\n`;
196
-
197
- // First list failed scenarios, then non-passed scenarios, then passed scenarios
198
- let failed_reports = ``;
199
- let passed_reports = ``;
200
- let other_reports = ``;
201
- for ( let scenario of scope.report ) {
202
-
203
- let report = await scope.getPrintableScenario( scope, { scenario });
204
- let [ scenario_id, data ] = scenario;
205
- if ( data.status === `FAILED` ) { failed_reports += report; }
206
- else if ( data.status === `PASSED` ) { passed_reports += report; }
207
- else { other_reports += report; }
208
- } // ends for every scenario
209
-
210
- if ( failed_reports !== `` ) { report += `\n\n===============================\n===============================\nFailed scenarios:\n${ failed_reports }`; }
211
- if ( other_reports !== `` ) { report += `\n\n===============================\n===============================\nScenarios that did not pass:\n${ other_reports }`; }
212
- if ( passed_reports !== `` ) { report += `\n\n===============================\n===============================\nPassed scenarios:\n${ passed_reports }`; }
213
-
214
- return report;
215
- }, // Ends scope.getPrintableReport()
216
-
217
- getPrintableScenario: async function( scope, { scenario }) {
218
- /* Return a string generated from scenario list of report items. */
219
- let [ scenario_id, data ] = scenario;
220
- let report = ``;
221
- for ( let line of data.lines ) {
222
- if ( line.type === `page_id` ) { report += `screen id: `; }
223
- else if ( line.type === `outcome` ) { report += `\n`; }
224
- else if ( line.type === `warning` ) { report += `WARNING: `; }
225
- else if ( line.type === `error` ) { report += `\nERROR: ` }
226
- report += `${ line.value }\n`;
227
- }
228
- return report;
229
- }, // Ends scope.getPrintableScenario()
230
-
231
91
  getSafeScenarioBaseFilename: async function( scope, { scenario }) {
232
92
  /** Return a string that's safe to be a filename. Include the Scenario
233
93
  * description and tags.
@@ -275,6 +135,15 @@ module.exports = {
275
135
  return safe_name;
276
136
  }, // Ends scope.getSafeScenarioFilename()
277
137
 
138
+ getA11yPath: async function( scope ) {
139
+ // Get a unique path for a json file
140
+ let { id } = await scope.examinePageID( scope, 'none to match' );
141
+ let short_id = `${ id }`.substring(0, 20);
142
+ // Need timestamp in case of a loop where the same page gets assessed for a11y multiple times
143
+ let a11y_path = path.join( scope.paths.scenario, `aXe_failure-${ short_id }-${ Date.now() }.json` );
144
+ return a11y_path;
145
+ },
146
+
278
147
  getJSONFilename: async function( scope ) {
279
148
  // Get a unique path for a json file
280
149
  let { id } = await scope.examinePageID( scope, 'none to match' );
@@ -356,7 +225,7 @@ module.exports = {
356
225
  let error_text_prop = await error_elem.getProperty( 'textContent' );
357
226
  let system_error_text = await error_text_prop.jsonValue();
358
227
 
359
- await scope.addToReport(scope, { type: `error`, value: system_error_text });
228
+ reports.addToReport(scope, { type: `error`, value: system_error_text });
360
229
  throw system_error_text;
361
230
  };
362
231
 
@@ -386,7 +255,7 @@ module.exports = {
386
255
  // first in user-triggered cases - users used to be able to access this almost
387
256
  // directly, but they can't anymore. Our functions should know to only pass good things.
388
257
  let msg = `Cannot find the element to tap.`;
389
- if ( !elem ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
258
+ if ( !elem ) { reports.addToReport(scope, { type: `error`, value: msg }); }
390
259
 
391
260
  expect( elem, msg ).to.exist;
392
261
 
@@ -425,7 +294,7 @@ module.exports = {
425
294
  } else {
426
295
  // Throw any non-timeout error
427
296
  let err_msg = `Error occurred when ALKiln tried to tap "${ tapperText }".`
428
- scope.addToReport( scope, { type: `error`, value: err_msg });
297
+ reports.addToReport( scope, { type: `error`, value: err_msg });
429
298
  throw error;
430
299
  } // ends if error is timeout error
431
300
 
@@ -517,7 +386,7 @@ module.exports = {
517
386
  await scope.get_server_reload_promise( scope );
518
387
  } else {
519
388
  // Otherwise just throw the error
520
- await scope.addToReport( scope, {
389
+ reports.addToReport( scope, {
521
390
  type: `error`,
522
391
  value: non_reload_report_msg });
523
392
  throw error;
@@ -579,7 +448,7 @@ module.exports = {
579
448
  + `${ Math.round( ms_till_server_timeout/1000 ) } seconds to respond. `
580
449
  + `If the server was just reloading and needed more time, try setting the `
581
450
  + `"SERVER_RELOAD_TIMEOUT_SECONDS" GitHub secret to a longer amount of time.`
582
- await scope.addToReport( scope, { type: `error`, value: timeout_message });
451
+ reports.addToReport( scope, { type: `error`, value: timeout_message });
583
452
  }
584
453
 
585
454
  // Once server has responded, throw an error to end the test. After the first
@@ -599,8 +468,8 @@ module.exports = {
599
468
  let reload_failure_msg = `The test was unable to continue because the `
600
469
  + `server was reloading. ALKiln will try this Scenario a total of 2 times. An `
601
470
  + `error will still be printed for this attempt.`;
602
- await scope.addToReport( scope, { type: `error`, value: reload_failure_msg });
603
- // await scope.addToReport( scope, { type: `error`, value: timeout_message });
471
+ reports.addToReport( scope, { type: `error`, value: reload_failure_msg });
472
+ // reports.addToReport( scope, { type: `error`, value: timeout_message });
604
473
  // clearTimeout( server_timeout_id );
605
474
  throw( reload_failure_msg );
606
475
  }, // Ends scope.throw_server_was_reloading_error()
@@ -673,7 +542,7 @@ module.exports = {
673
542
  let elem = await scope.page.$(`#${tab_id}`);
674
543
  let error_msg = `Couldn't find the tab with id "#${tab_id}" on the page, is there a typo?`;
675
544
  if ( !elem ) {
676
- await scope.addToReport(scope, { type: `error`, value: error_msg });
545
+ reports.addToReport(scope, { type: `error`, value: error_msg });
677
546
  }
678
547
  expect( elem, error_msg ).to.exist;
679
548
  await scope.tapElement( scope, elem, tab_id );
@@ -683,7 +552,7 @@ module.exports = {
683
552
  /** Return if there were any violations and the file containing aXe output with more details of the checks */
684
553
  let { id } = await scope.examinePageID( scope, 'none to match' );
685
554
  if ( scope.page_id !== id ) {
686
- await scope.addToReport(scope, { type: `page_id`, value: `${ id }`, });
555
+ reports.addToReport(scope, { type: `page_id`, value: `${ id }`, });
687
556
  scope.page_id = id;
688
557
  }
689
558
  const axe = new AxePuppeteer(scope.page);
@@ -701,16 +570,15 @@ module.exports = {
701
570
  let axe_path = null;
702
571
  const passed = axe_result.violations.length == 0;
703
572
  if (!passed) {
704
- let axe_filename = await scope.getSafeScenarioFilename( scope, {prefix: `aXe_failure`}) + '.json';
705
- axe_path = `${ scope.paths.scenario }/${ axe_filename }`;
573
+ axe_path = await scope.getA11yPath( scope );
706
574
  fs.writeFileSync( axe_path, JSON.stringify( axe_result, null, 2 ));
707
575
  let msg = `Found potential accessibility issues on the ${ scope.page_id } screen. Details in ${ axe_path }.`;
708
- await scope.addToReport( scope, {
576
+ reports.addToReport( scope, {
709
577
  type: `error`,
710
578
  value: msg
711
579
  });
712
580
  } else {
713
- await scope.addToReport(scope, { type: `info`, value: `Accessibility on ${ scope.page_id } passed!`})
581
+ reports.addToReport(scope, { type: `info`, value: `Accessibility on ${ scope.page_id } passed!`})
714
582
  }
715
583
  return {
716
584
  passed: axe_result.violations.length == 0,
@@ -722,7 +590,7 @@ module.exports = {
722
590
  let elem = await scope.page.$(`${selector}`);
723
591
  let error_msg = `Couldn't find "${selector}" on the page, is there a typo?`;
724
592
  if ( !elem ) {
725
- await scope.addToReport(scope, { type: `error`, value: error_msg });
593
+ reports.addToReport(scope, { type: `error`, value: error_msg });
726
594
  }
727
595
  expect( elem, error_msg ).to.exist;
728
596
  await scope.tapElement( scope, elem, '', waitForTimeout );
@@ -748,7 +616,7 @@ module.exports = {
748
616
  interview_url = `${ base_url }${ file_name }`;
749
617
  }
750
618
 
751
- await scope.addToReport(scope,{
619
+ reports.addToReport(scope,{
752
620
  type: `row`,
753
621
  value: `Trying to load the interview at "${interview_url}"`
754
622
  });
@@ -772,7 +640,7 @@ module.exports = {
772
640
  } else {
773
641
 
774
642
  // Throw any non-timeout error
775
- scope.addToReport( scope, {
643
+ reports.addToReport( scope, {
776
644
  type: `error`,
777
645
  value: `Error occurred when ALKiln tried to go to the interview url`
778
646
  });
@@ -784,7 +652,7 @@ module.exports = {
784
652
  let load_result = await scope.getLoadData( scope );
785
653
  // Errors, or reaching timeout, causes an exception and is shown to the developer at the end
786
654
  if ( load_result.error ) {
787
- await scope.addToReport(scope, { type: `error`, value: `On final attempt to load interview, got "${ load_result.error }"` });
655
+ reports.addToReport(scope, { type: `error`, value: `On final attempt to load interview, got "${ load_result.error }"` });
788
656
  expect( load_result.error ).to.equal( '' );
789
657
  }
790
658
 
@@ -854,7 +722,7 @@ module.exports = {
854
722
  if (!var_data && ! raw_var_data) {
855
723
  let no_data_msg = `ALKiln Internal error: normalizeTable called w/o var_data or raw_var_data:` +
856
724
  ` open an issue at https://github.com/SuffolkLITLab/ALKiln/issues/new if you see this!`;
857
- await scope.addToReport( scope, { type: `error`, value: no_data_msg });
725
+ reports.addToReport( scope, { type: `error`, value: no_data_msg });
858
726
  throw Error(no_data_msg);
859
727
  }
860
728
 
@@ -869,7 +737,7 @@ module.exports = {
869
737
  let bad_table_msg = `Your Story Table definition needs to be changed. The best practice`
870
738
  + ` is to make your table 3 columns wide with a first row in this format: "| var | value | trigger |".`
871
739
  + ` The first row of your table is actually "${ raw_array[0] }"`;
872
- await scope.addToReport( scope, { type: `error`, value: bad_table_msg})
740
+ reports.addToReport( scope, { type: `error`, value: bad_table_msg})
873
741
  throw Error(bad_table_msg);
874
742
  } else if (row_raw.length == 2) {
875
743
  var_data.push({var: row_raw[0], value: row_raw[1]});
@@ -889,7 +757,7 @@ module.exports = {
889
757
  actual_var_value = process.env[row.flags.secret_name];
890
758
  if ( actual_var_value === undefined ) {
891
759
  let not_in_env_msg = `The GitHub SECRET "${ row.flags.secret_name }" doesn't exist. Check for a typo and check your workflow file.`;
892
- await scope.addToReport( scope, { type: `error`, value: not_in_env_msg })
760
+ reports.addToReport( scope, { type: `error`, value: not_in_env_msg })
893
761
  throw ReferenceError(`Could not find "${row.flags.secret_name }": ${not_in_env_msg}.`)
894
762
  }
895
763
  }
@@ -941,7 +809,7 @@ module.exports = {
941
809
  let encoded_trigger_var = $( trigger_elem ).data( `variable` ); // This should come out to 'None' at the very least
942
810
  if ( !encoded_trigger_var ) {
943
811
  // TODO: Add documentation and then link to it in this message.
944
- await scope.addToReport(scope, { type: `warning`, value: `You are missing an element containing the data for the page's trigger variable. Some fields, like loops with index variables, may not work correctly in these tests. See https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/automated_integrated_testing/#a-missing-trigger-variable.` });
812
+ reports.addToReport(scope, { type: `warning`, value: `You are missing an element containing the data for the page's trigger variable. Some fields, like loops with index variables, may not work correctly in these tests. See https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/automated_integrated_testing/#a-missing-trigger-variable.` });
945
813
  encoded_trigger_var = toBase64( `None` );
946
814
  }
947
815
  let trigger = fromBase64( encoded_trigger_var );
@@ -1080,7 +948,7 @@ module.exports = {
1080
948
  // Exceptions: Some specific fields can be duplicates without being dangerous
1081
949
  if ( matching_nodes.length > 1 && !selector.includes(`[class="file-caption-name"]`) ) {
1082
950
  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.`;
1083
- await scope.addToReport(scope, { type: `warning`, value: msg });
951
+ reports.addToReport(scope, { type: `warning`, value: msg });
1084
952
  }
1085
953
 
1086
954
  return selector;
@@ -1359,7 +1227,7 @@ module.exports = {
1359
1227
  + `variable or a generic object, but the page does not `
1360
1228
  + `contain the trigger variable's name in its HTML. See `
1361
1229
  + `https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/automated_integrated_testing/#a-missing-trigger-variable.`;
1362
- await scope.addToReport(scope, { type: `warning`, value: page_trigger_warning });
1230
+ reports.addToReport(scope, { type: `warning`, value: page_trigger_warning });
1363
1231
  }
1364
1232
 
1365
1233
  for ( let row_that_matches_this_field of rows_that_match_field_names ) {
@@ -1373,13 +1241,13 @@ module.exports = {
1373
1241
  // a useful message.
1374
1242
 
1375
1243
  // First, make the row data more readable to humans.
1376
- let row_as_story_table = await scope.convertToOriginalStoryTableRow( scope, { row_data: row_that_matches_this_field });
1244
+ let row_as_story_table = reports.convertToOriginalStoryTableRow( scope, { row_data: row_that_matches_this_field });
1377
1245
  let row_trigger_warning = `This story table row needs a value in the "trigger" `
1378
1246
  + `column, but it is empty. The test might get unexpected results. See `
1379
1247
  + `https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/automated_integrated_testing/#trigger.`
1380
1248
  + `\nThe row's data:\n${ row_as_story_table }`
1381
1249
  + `\nThis field's data:\n${ JSON.stringify( field )}`;
1382
- await scope.addToReport(scope, { type: `warning`, value: row_trigger_warning });
1250
+ reports.addToReport(scope, { type: `warning`, value: row_trigger_warning });
1383
1251
  rows_without_a_trigger_name.push( row_that_matches_this_field );
1384
1252
  } else {
1385
1253
  let triggers_match = await scope.matchNeutralizedQuotes( scope, { value1: page_trigger_name, value2: table_row_trigger_name });
@@ -1405,7 +1273,7 @@ module.exports = {
1405
1273
  // First, make the row data more readable to humans.
1406
1274
  let conflicts_as_story_table_rows = [];
1407
1275
  for ( let row of rows_with_conflicting_trigger_names ) {
1408
- let table_str = await scope.convertToOriginalStoryTableRow( scope, { row_data: row });
1276
+ let table_str = reports.convertToOriginalStoryTableRow( scope, { row_data: row });
1409
1277
  conflicts_as_story_table_rows.push( table_str );
1410
1278
  }
1411
1279
 
@@ -1416,7 +1284,7 @@ module.exports = {
1416
1284
  + `https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/automated_integrated_testing/#trigger.`
1417
1285
  + `\nThe data of row(s) with conflicting trigger name(s):\n${ conflicts_as_story_table_rows.join( `\n` )}`
1418
1286
  + `\nThis field's data:\n${ JSON.stringify( field )}`;
1419
- await scope.addToReport(scope, {
1287
+ reports.addToReport(scope, {
1420
1288
  type: `warning`,
1421
1289
  value: all_trigger_names_conflict_msg
1422
1290
  });
@@ -1433,7 +1301,7 @@ module.exports = {
1433
1301
  // First, make the row data more readable to humans.
1434
1302
  let rows_as_story_table = [];
1435
1303
  for ( let row of final_matching_rows ) {
1436
- let table_str = await scope.convertToOriginalStoryTableRow( scope, { row_data: row });
1304
+ let table_str = reports.convertToOriginalStoryTableRow( scope, { row_data: row });
1437
1305
  rows_as_story_table.push( table_str );
1438
1306
  }
1439
1307
 
@@ -1443,7 +1311,7 @@ module.exports = {
1443
1311
  + `https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/automated_integrated_testing/#trigger.`
1444
1312
  + `\nThe matching rows:\n${ rows_as_story_table.join(`\n`) }`
1445
1313
  + `\nThis field's data:\n${ JSON.stringify( field )}`;
1446
- await scope.addToReport(scope, { type: `warning`, value: multiple_matches_msg });
1314
+ reports.addToReport(scope, { type: `warning`, value: multiple_matches_msg });
1447
1315
  }
1448
1316
 
1449
1317
  if ( session_vars.get_debug() ) {
@@ -1617,7 +1485,7 @@ module.exports = {
1617
1485
  // If it doesn't have the right format
1618
1486
  if ( answer === null || !answer.includes(`/`) ) {
1619
1487
  // Return field unused.
1620
- await scope.addToReport( scope, {
1488
+ reports.addToReport( scope, {
1621
1489
  type: `warning`,
1622
1490
  value: `Invalid date. Value given: "${ answer }".`
1623
1491
  });
@@ -1812,22 +1680,15 @@ module.exports = {
1812
1680
  return valid_date;
1813
1681
  }, // Ends scope.today_to_date()
1814
1682
 
1815
- uploadFiles: async function ( scope, { handle, answer }) {
1816
- /* Try to upload one or more files to a file-type input field.
1817
- * Does not submit, so no danger of timeout with server reload.
1818
- *
1819
- * Returns whether the row was used or not.
1820
- */
1821
-
1683
+ findFiles: async function( scope, {to_find_names}) {
1822
1684
  // First make sure the list of files was defined. Don't remove spaces yet -
1823
1685
  // allow a typo where someone forgot to include a space after a comma.
1824
- let uploads_names = answer.split(`,`);
1825
- if ( uploads_names.length === 0 ) {
1826
- await scope.addToReport( scope, {
1686
+ if ( to_find_names.length === 0 ) {
1687
+ reports.addToReport( scope, {
1827
1688
  type: `error`,
1828
- value: `Your list of files to upload is empty.`
1689
+ value: `Your list of files to find is empty.`
1829
1690
  });
1830
- return false; // The row wasn't used
1691
+ return [];
1831
1692
  }
1832
1693
 
1833
1694
  let dirs = [];
@@ -1850,24 +1711,23 @@ module.exports = {
1850
1711
  }
1851
1712
 
1852
1713
  if ( session_vars.get_debug() ) {
1853
- console.log( `All discovered dirs to upload from:`, dirs );
1714
+ console.log( `All discovered dirs to find files from:`, dirs );
1854
1715
  }
1855
1716
 
1856
1717
  // Make sure sources directory exists and has files in it
1857
1718
  if ( dirs.length === 0 ) {
1858
- await scope.addToReport( scope, {
1719
+ reports.addToReport( scope, {
1859
1720
  type: `warning`,
1860
1721
  value: `The test files need to be in the "sources" directory, `
1861
1722
  + `but ALKiln couldn't find the directory or it was empty.`
1862
1723
  });
1863
- return false; // The row wasn't used
1724
+ return [];
1864
1725
  }
1865
1726
 
1866
1727
  // Get the full paths of all the files
1867
- let dir = null;
1868
1728
  let paths = [];
1869
1729
  let missing_files = []; // For any user warnings
1870
- for ( let name of uploads_names ) {
1730
+ for ( let name of to_find_names) {
1871
1731
 
1872
1732
  let trimmed_name = name.trim();
1873
1733
  let matching_paths = [];
@@ -1887,15 +1747,33 @@ module.exports = {
1887
1747
  } // ends for each file
1888
1748
 
1889
1749
  if ( missing_files.length > 0 ) {
1890
- await scope.addToReport( scope, {
1750
+ reports.addToReport( scope, {
1891
1751
  type: `warning`,
1892
1752
  value: `ALKiln could not find "${ missing_files.join('", "') }" in any of these folders: "${ dirs.join('", "') }".`
1893
1753
  });
1894
1754
  }
1895
1755
 
1896
- // If all files missing, return that the row wasn't used
1897
- if ( missing_files.length === uploads_names.length ) {
1898
- return false; // The row wasn't used
1756
+ // If all files missing, return an empty array
1757
+ if ( missing_files.length === to_find_names.length ) {
1758
+ return [];
1759
+ }
1760
+ return paths;
1761
+ },
1762
+
1763
+ uploadFiles: async function ( scope, { handle, answer }) {
1764
+ /* Try to upload one or more files to a file-type input field.
1765
+ * Does not submit, so no danger of timeout with server reload.
1766
+ *
1767
+ * Returns whether the row was used or not.
1768
+ */
1769
+
1770
+ // First make sure the list of files was defined. Don't remove spaces yet -
1771
+ // allow a typo where someone forgot to include a space after a comma.
1772
+ let to_find_names = answer.split(`,`);
1773
+ let paths = await scope.findFiles(scope, {to_find_names});
1774
+ if (paths.length == 0) {
1775
+ // The row wasn't used
1776
+ return false;
1899
1777
  }
1900
1778
 
1901
1779
  // Prepare to wait for upload to complete
@@ -1944,7 +1822,7 @@ module.exports = {
1944
1822
  * takes a while to load. */
1945
1823
  let canvas = await scope.page.waitForSelector( `#dasigcanvas` );
1946
1824
  let msg = `Could not find a signature field.`;
1947
- if ( !canvas ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
1825
+ if ( !canvas ) { reports.addToReport(scope, { type: `error`, value: msg }); }
1948
1826
  expect( canvas, msg ).to.exist;
1949
1827
 
1950
1828
  // Provides functionality to draw name arg on canvas
@@ -2017,7 +1895,7 @@ module.exports = {
2017
1895
  // Only record the page id once for each page to which we navigate
2018
1896
  let { id } = await scope.examinePageID( scope, 'none to match' );
2019
1897
  if ( scope.page_id !== id ) {
2020
- await scope.addToReport(scope, { type: `page_id`, value: `${ id }`, });
1898
+ reports.addToReport(scope, { type: `page_id`, value: `${ id }`, });
2021
1899
  scope.page_id = id;
2022
1900
  }
2023
1901
 
@@ -2047,9 +1925,9 @@ module.exports = {
2047
1925
 
2048
1926
  something_new_was_set = true;
2049
1927
  field.was_set = true;
2050
- await scope.addToReport(scope, {
1928
+ reports.addToReport(scope, {
2051
1929
  type: `row`,
2052
- value: `${ await scope.convertToOriginalStoryTableRow(scope, { row_data: row_used }) }`
1930
+ value: `${ reports.convertToOriginalStoryTableRow(scope, { row_data: row_used }) }`
2053
1931
  });
2054
1932
 
2055
1933
  // Accumulate number of times this row was used. Esp important for `.target_number`
@@ -2072,9 +1950,9 @@ module.exports = {
2072
1950
  let row_used = await scope.setVariable( scope, { field, var_data, });
2073
1951
  if ( row_used ) {
2074
1952
 
2075
- await scope.addToReport(scope, {
1953
+ reports.addToReport(scope, {
2076
1954
  type: `row`,
2077
- value: `${ await scope.convertToOriginalStoryTableRow(scope, { row_data: row_used }) }`
1955
+ value: `${ reports.convertToOriginalStoryTableRow(scope, { row_data: row_used }) }`
2078
1956
  });
2079
1957
 
2080
1958
  // Esp important for `.target_number`
@@ -2116,13 +1994,13 @@ module.exports = {
2116
1994
  if ( row.source ) { source_row = row.source; }
2117
1995
  let original_row = source_row.original;
2118
1996
  let msg = `Did not find a field on this page for the variable "${ original_row.var }" that could be set to "${ original_row.value }"`;
2119
- await scope.addToReport(scope, { type: `warning`, value: msg });
1997
+ reports.addToReport(scope, { type: `warning`, value: msg });
2120
1998
  }
2121
1999
  }
2122
2000
  if ( num_unused_rows > 0 ) {
2123
2001
  let { id } = await scope.examinePageID( scope, 'none to match' );
2124
2002
  let msg = `Missing variable or variables on page "${id}"`;
2125
- await scope.addToReport(scope, { type: `error`, value: msg });
2003
+ reports.addToReport(scope, { type: `error`, value: msg });
2126
2004
  expect( num_unused_rows, msg ).to.equal( 0 );
2127
2005
  }
2128
2006
  }
@@ -2312,7 +2190,7 @@ module.exports = {
2312
2190
  // probably add a confusing message to the report, so mutating seems, sadly, more useful.
2313
2191
  var_datum.value = `False`; // The original value is still preserved
2314
2192
  // Warn the developer not to use `.there_is_another`.
2315
- await scope.addToReport( scope, { type: `warning`,
2193
+ reports.addToReport( scope, { type: `warning`,
2316
2194
  value: 'The attribute `.there_is_another` is invalid in story table tests. Replace it with '
2317
2195
  + '`.target_number` in your `var` and `trigger` columns. Set the `value` to the number of items in '
2318
2196
  + `that list. This test will now set this row's \`value\` to \`False\`. See `
@@ -2346,10 +2224,10 @@ module.exports = {
2346
2224
  } else if ( value.match( /(true|yes|checked|check|selected|select)/i )) {
2347
2225
  if ( from_story_table && !row.artificial ) {
2348
2226
  if ( row.var.match( /\.there_is_another$/ ) ) {
2349
- await scope.addToReport( scope, { type: `warning`, value: `The attribute \`.there_is_another\` is invalid in story table tests. Replace it with \`.target_number\` in your \`var\` column. Set the \`value\` to the number of items in that list. This test will now set this row's \`value\` to \`False\`. See https://suffolklitlab.github.io/docassemble-AssemblyLine-documentation/docs/automated_integrated_testing#there_is_another-loop. The row data is\n${ JSON.stringify( row.original )}` });
2227
+ reports.addToReport( scope, { type: `warning`, value: `The attribute \`.there_is_another\` is invalid in story table tests. Replace it with \`.target_number\` in your \`var\` column. Set the \`value\` to the number of items in that list. This test will now set this row's \`value\` to \`False\`. See https://suffolklitlab.github.io/docassemble-AssemblyLine-documentation/docs/automated_integrated_testing#there_is_another-loop. The row data is\n${ JSON.stringify( row.original )}` });
2350
2228
  } else {
2351
2229
  // If `target_number` is set to True instead of a number
2352
- await scope.addToReport( scope, { type: `warning`, value: `${ val_to_print } value is not a valid value for ${ row.var } here. This test will default to \`False\` to avoid problems.` });
2230
+ reports.addToReport( scope, { type: `warning`, value: `${ val_to_print } value is not a valid value for ${ row.var } here. This test will default to \`False\` to avoid problems.` });
2353
2231
  }
2354
2232
  return `False`;
2355
2233
  } else { return `True`; }
@@ -2357,7 +2235,7 @@ module.exports = {
2357
2235
  } else if ( isNaN( desired_num )) { // Story table loop will end
2358
2236
  // I can't figure out how to trigger this warning, so maybe we're
2359
2237
  // already handling everything we need to
2360
- await scope.addToReport( scope, { type: `warning`, value: `${ val_to_print } value is not a valid value for ${ row.var } here. This test will default to \`False\` to avoid problems.` });
2238
+ reports.addToReport( scope, { type: `warning`, value: `${ val_to_print } value is not a valid value for ${ row.var } here. This test will default to \`False\` to avoid problems.` });
2361
2239
  return `False`;
2362
2240
 
2363
2241
  // Check that the number of elements in the list hits our desired number.
@@ -2408,7 +2286,7 @@ module.exports = {
2408
2286
  }
2409
2287
 
2410
2288
  let timeout_err_message = `The test tried to continue to the next page, but it took longer than ${ (scope.timeout/1000)/60 } min. Check the picture of this page. If a secret variable was used, there will be no picture.`
2411
- if ( timedOut ) { await scope.addToReport(scope, { type: `error`, value: timeout_err_message }); }
2289
+ if ( timedOut ) { reports.addToReport(scope, { type: `error`, value: timeout_err_message }); }
2412
2290
  expect( timedOut, timeout_err_message ).to.be.false;
2413
2291
 
2414
2292
  clearTimeout( cont_timeout );
@@ -2452,13 +2330,13 @@ module.exports = {
2452
2330
  /* Throw a cucumber error with the error that appeared on the page. */
2453
2331
  if ( error_handlers.system_error !== undefined ) {
2454
2332
  let sys_err_message = `The test tried to continue to the next page after ${ id }, but there was a system error. Check the picture of this page.`
2455
- await scope.addToReport(scope, { type: `error`, value: sys_err_message });
2333
+ reports.addToReport(scope, { type: `error`, value: sys_err_message });
2456
2334
  expect( was_system_error, sys_err_message ).to.be.false;
2457
2335
 
2458
2336
  } else {
2459
2337
  // If it wasn't a system error, it was a user error
2460
2338
  let user_err_message = `The test tried to continue to the next page after ${ id }, but the user got an error. Check the picture of this page.`
2461
- await scope.addToReport(scope, { type: `error`, value: user_err_message });
2339
+ reports.addToReport(scope, { type: `error`, value: user_err_message });
2462
2340
  expect( true, user_err_message ).to.be.false;
2463
2341
  }
2464
2342
 
@@ -2480,6 +2358,22 @@ module.exports = {
2480
2358
  fullPage: fullPage,
2481
2359
  });
2482
2360
 
2361
+ let html_path = path;
2362
+ if (path.endsWith(".jpg")) {
2363
+ html_path = html_path.substring(0, html_path.length - 4) + ".html";
2364
+ } else {
2365
+ html_path = html_path + ".html";
2366
+ }
2367
+
2368
+ // Also save the HTML of the page
2369
+ await scope.page.content().then(content => {
2370
+ let server_url = session_vars.get_da_server_url();
2371
+ content = content.replaceAll(/"(\/static\/.*\.css\?v=[^"]+")/g, server_url + "$1");
2372
+ content = content.replaceAll(/"(\/static\/.*\.js\?v=[^"]+")/g, server_url + "$1");
2373
+ content = content.replaceAll(/"(\/packagestatic\/.*\.css\?v=[^"]+")/g, server_url + "$1");
2374
+ content = content.replaceAll(/"(\/packagestatic\/.*\.js\?v=[^"]+")/g, server_url + "$1");
2375
+ fs.writeFileSync(html_path, content)
2376
+ });
2483
2377
  }, // Ends scope.take_a_screenshot()
2484
2378
 
2485
2379
 
@@ -2519,7 +2413,7 @@ module.exports = {
2519
2413
  });
2520
2414
 
2521
2415
  // In future, may be saved to artifact file
2522
- await scope.addToReport( scope, {
2416
+ reports.addToReport( scope, {
2523
2417
  type: `json`,
2524
2418
  value: JSON.stringify( data, null, 2 )
2525
2419
  });
@@ -2535,7 +2429,7 @@ module.exports = {
2535
2429
  let response = await linkPage.goto( actual_url, { waitUntil: 'domcontentloaded' });
2536
2430
 
2537
2431
  let msg = `The "${ linkText }" link is broken.`;
2538
- if ( !response.ok() ) { await scope.addToReport( scope, { type: `error`, value: msg }); }
2432
+ if ( !response.ok() ) { reports.addToReport( scope, { type: `error`, value: msg }); }
2539
2433
  expect( response.ok(), msg ).to.be.true;
2540
2434
 
2541
2435
  // This may cause problems if the linked page is the interview page
@@ -2584,7 +2478,7 @@ module.exports = {
2584
2478
  const [link] = await scope.page.$x(`//a[contains(text(), "${phrase}")]`);
2585
2479
 
2586
2480
  let msg = `The term "${ phrase }" seems to be missing.`;
2587
- if ( !link ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
2481
+ if ( !link ) { reports.addToReport(scope, { type: `error`, value: msg }); }
2588
2482
  expect( link, msg ).to.exist;
2589
2483
 
2590
2484
  await scope.tapElement( scope, link );
@@ -2611,7 +2505,7 @@ module.exports = {
2611
2505
  let [elem] = await scope.page.$x(`//a[contains(@href, "${ filename }")]`);
2612
2506
 
2613
2507
  let msg = `"${ filename }" seems to be missing. Cannot find a link to that document.`;
2614
- if ( !elem ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
2508
+ if ( !elem ) { reports.addToReport(scope, { type: `error`, value: msg }); }
2615
2509
  expect( elem, msg ).to.exist;
2616
2510
 
2617
2511
  scope.toDownload = filename;
@@ -2620,6 +2514,32 @@ module.exports = {
2620
2514
  await scope.detectDownloadComplete( scope );
2621
2515
  }, // Ends scope.steps.download()
2622
2516
 
2517
+ compare_pdfs: async function (scope, {existing_pdf_path, new_pdf_path}) {
2518
+ let existing_paths = await scope.findFiles(scope, {to_find_names: [existing_pdf_path]});
2519
+ if (existing_paths.length == 0) {
2520
+ // The row wasn't used
2521
+ let msg = `Could not find the existing PDF at ${ existing_pdf_path }`;
2522
+ reports.addToReport(scope, { type: `error`, value: msg });
2523
+ scope.failed_pdf_compares.push(msg);
2524
+ // Return early since we couldn't find the PDF
2525
+ return;
2526
+ }
2527
+ let full_existing_path = existing_paths[0];
2528
+ let full_new_pdf_path = `${scope.paths.scenario}/${new_pdf_path}`;
2529
+ let diffs = await pdf_reader.compare_pdfs(full_existing_path, full_new_pdf_path);
2530
+ if (diffs) {
2531
+ let added_str = diffs.filter(part => part.added).reduce((err_str, part) => {
2532
+ return err_str + `- ${ part.value }\n`
2533
+ }, 'The new PDF added: \n');
2534
+ let removed_str = diffs.filter(part => part.removed).reduce((err_str, part) => {
2535
+ return err_str + `- ${ part.value }\n`
2536
+ }, 'The new PDF removed: \n');
2537
+ let msg = `The PDFs were not the same.\n${ added_str }\n${removed_str}\n\n You can see the full PDFs at ${ full_existing_path } and ${ full_new_pdf_path}`;
2538
+ reports.addToReport(scope, { type: `error`, value: msg });
2539
+ scope.failed_pdf_compares.push(msg);
2540
+ }
2541
+ },
2542
+
2623
2543
  set_al_name: async ( scope, var_base, name_str ) => {
2624
2544
  /** Given a variable name and a space-separated string, fills the
2625
2545
  * appropriate fields on an Assembly Line name question.
@@ -2647,7 +2567,7 @@ module.exports = {
2647
2567
  name_parts_slice.push(name_parts_last);
2648
2568
  name_parts = name_parts_slice;
2649
2569
 
2650
- await scope.addToReport(scope, {
2570
+ reports.addToReport(scope, {
2651
2571
  type: `warning`,
2652
2572
  value: `The name "${name_str}" has more than 4 parts, but 4 is the maximum allowed. The test will set the name to "${name_parts.join(" ")}".`
2653
2573
  });
@@ -2733,7 +2653,7 @@ module.exports = {
2733
2653
  await scope.handle_page_timeout_error( scope, { non_reload_report_msg, error });
2734
2654
  } else {
2735
2655
  // Throw any non-timeout error
2736
- scope.addToReport( scope, { type: `error`, value: err_msg });
2656
+ reports.addToReport( scope, { type: `error`, value: err_msg });
2737
2657
  throw error;
2738
2658
  } // ends if error is timeout error
2739
2659
 
@@ -2747,11 +2667,11 @@ module.exports = {
2747
2667
  // this is complex
2748
2668
  let email_msg = `The email address GitHub SECRET "${ email_secret_name }" doesn't exist. Check for a typo and check your workflow file.`
2749
2669
  if ( email === undefined ) {
2750
- await scope.addToReport( scope, { type: `error`, value: email_msg })
2670
+ reports.addToReport( scope, { type: `error`, value: email_msg })
2751
2671
  }
2752
2672
  let password_msg = `The password GitHub SECRET "${ password_secret_name }" doesn't exist. Check for a typo and check your workflow file.`
2753
2673
  if ( password === undefined ) {
2754
- await scope.addToReport( scope, { type: `error`, value: password_msg })
2674
+ reports.addToReport( scope, { type: `error`, value: password_msg })
2755
2675
  }
2756
2676
 
2757
2677
  expect( email, email_msg ).to.not.equal( undefined );
@@ -2768,7 +2688,7 @@ module.exports = {
2768
2688
  // TODO: use a Promise.race to fail faster.
2769
2689
  await scope.page.waitForSelector( `.datopcenter .alert-success` );
2770
2690
 
2771
- await scope.addToReport( scope, {
2691
+ reports.addToReport( scope, {
2772
2692
  type: `row`,
2773
2693
  value: `Signed into ${ session_vars.get_da_server_url() }/user/sign-in successfully.`
2774
2694
  });
@@ -2910,7 +2830,7 @@ module.exports = {
2910
2830
  if ( type !== `` && type !== `hidden` ) {
2911
2831
  // Field types that we can't yet handle, but plan to in future
2912
2832
  if ( type === `file` ) {
2913
- await scope.addToReport( scope, {
2833
+ reports.addToReport( scope, {
2914
2834
  type: `warning`,
2915
2835
  value: `Sorry, ALKiln's random input tests cannot yet handle "${ type }" fields.`
2916
2836
  });
@@ -3016,7 +2936,7 @@ module.exports = {
3016
2936
  */
3017
2937
  let current_report_obj = scope.report.get( scope.scenario_id );
3018
2938
  let scenario = [ scope.scenario_id, current_report_obj ]; // Mimic how loop works with `Map`
3019
- let report = await scope.getPrintableScenario( scope, { scenario });
2939
+ let report = reports.getPrintableScenario( scenario );
3020
2940
  let all_are_included = true;
3021
2941
  for ( let one_expectation of expected ) {
3022
2942
  if ( !report.includes( one_expectation )) {
@@ -3036,7 +2956,7 @@ module.exports = {
3036
2956
  */
3037
2957
  let current_report_obj = scope.report.get( scope.scenario_id );
3038
2958
  let scenario = [ scope.scenario_id, current_report_obj ]; // Mimic how loop works with `Map`
3039
- let report = await scope.getPrintableScenario( scope, { scenario });
2959
+ let report = reports.getPrintableScenario( scenario );
3040
2960
  let none_included = true;
3041
2961
  for ( let one_expectation of not_expected ) {
3042
2962
  if ( report.includes( one_expectation )) {
@@ -3047,4 +2967,5 @@ module.exports = {
3047
2967
 
3048
2968
  return none_included;
3049
2969
  },
2970
+
3050
2971
  };