@suffolklitlab/alkiln 4.6.0 → 4.7.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
@@ -4,6 +4,8 @@ const path = require('path');
4
4
  const fs = require('fs');
5
5
  const { AxePuppeteer } = require('@axe-core/puppeteer');
6
6
  const safe_filename = require("sanitize-filename");
7
+ // "en" is faster to import: https://github.com/faker-js/faker/issues/1114#issuecomment-1169532948
8
+ const faker = require('@faker-js/faker/locale/en').faker;
7
9
 
8
10
  // Ours
9
11
  const session_vars = require('./utils/session_vars');
@@ -11,6 +13,7 @@ const get_base_interview_url = require(`./docassemble/get_base_interview_url`);
11
13
  const time = require('./utils/time');
12
14
  const log = require('./utils/log');
13
15
  const { waitForTimeout } = require('./utils/time');
16
+ const files = require('./utils/files' );
14
17
 
15
18
 
16
19
  let ordinal_to_integer = {
@@ -50,6 +53,7 @@ let is_secret_var = function(row) {
50
53
  }
51
54
 
52
55
  let proxies_regex = /(\bx\.)|(\bx\[)|(\[[ijklmn]\])/g;
56
+ let misc_artifacts_dir = `_alkiln_test-misc_artifacts`;
53
57
 
54
58
  module.exports = {
55
59
  trigger_not_needed_flag: `ALKiln: no trigger variable needed`,
@@ -63,7 +67,22 @@ module.exports = {
63
67
  let scenario = await scope.makeSureReportPartsExist( scope );
64
68
  scenario.lines.push({ type, value });
65
69
 
66
- log.add_to_debug_log(`Report:\ntype: ${ type }, value: ${ value }\n`);
70
+ // TODO: Not sure how to use .add_to_debug_log() here considering possibly dynamic filepath
71
+ // log.add_to_debug_log(`Report:\ntype: ${ type }, value: ${ value }\n`);
72
+
73
+ // Ensure filepath for saving local unit test logs
74
+ if ( !scope.paths ) {
75
+ // TODO: would it be useful to put in existing artifacts folder if possible.
76
+ // Can this be usefully abstracted?
77
+ scope.paths = { debug_log: `${ misc_artifacts_dir }/unit_tests_logs-${ files.readable_date() }.txt` };
78
+ }
79
+
80
+ try {
81
+ fs.appendFileSync(scope.paths.debug_log, `\nReport:\ntype: ${ type }, value: ${ value }`);
82
+ } catch (err) {
83
+ fs.mkdirSync( misc_artifacts_dir );
84
+ fs.appendFileSync(scope.paths.debug_log, `\nReport:\ntype: ${ type }, value: ${ value }`);
85
+ }
67
86
  return scenario;
68
87
 
69
88
  }, // Ends scope.addToReport()
@@ -72,7 +91,10 @@ module.exports = {
72
91
  /* If a part of the report needs to exist right now, but doesn't exist,
73
92
  * create it. */
74
93
  if ( !scope.report ) { scope.report = new Map(); }
75
- if ( !scope.scenario_id ) { scope.scenario_id = `_report_${ Date.now() }` }
94
+ if ( !scope.scenario_id ) {
95
+ let date = files.readable_date();
96
+ scope.scenario_id = `_report_${ date }`;
97
+ }
76
98
  if ( !scope.report.get( scope.scenario_id )) {
77
99
  scope.report.set( scope.scenario_id, {} )
78
100
  scope.report.get( scope.scenario_id ).status = `in progress`;
@@ -187,41 +209,49 @@ module.exports = {
187
209
  name_parts.push( scope.language );
188
210
  }
189
211
  name_parts.push( scenario.pickle.name );
190
- for ( let tag of scenario.pickle.tags ) {
191
- name_parts.push( tag.name );
192
- }
193
212
 
194
213
  let filename = name_parts.join(`-`);
214
+ let safe_name = safe_filename( filename, { replacement: `_` });
215
+ // Allow room for extensions, date, etc.
216
+ let safer_name = safe_name.substring(0, (255 - 45));
195
217
 
196
- return safe_filename( filename, { replacement: `_` });
218
+ return safer_name;
197
219
  }, // Ends scope.getSafeScenarioBaseFilename()
198
220
 
199
- getSafeScenarioFilename: async function( scope, options={} ) {
221
+ // TODO: Does this need a name change? getSafeScenarioBasedFilename?
222
+ getSafeScenarioFilename: async function( scope, options={ prefix: '' }) {
200
223
  /* Return a string that's safe to be a filename. Use a timestamp,
201
224
  * a prefix the caller wants, and the base filename. We hope it can
202
225
  * contain non-english characters too. We do not offer a suffix option
203
226
  * because it may be cut off if the filename is too long.
204
227
  */
205
- // Ensure prefix is a string
206
- let prefix = options.prefix || '';
228
+ let name = options.prefix || '';
229
+
207
230
  // As long as it's not an empty string, add a separator between it and the rest of the name
208
- // For now, it needs to be `_` for the action and .gitignore. In future,
209
- // aiming for `-`.
210
- if ( prefix ) {
211
- prefix = `${ prefix }_`;
231
+ if ( name.length > 0 ) {
232
+ name += `-`;
233
+ }
234
+
235
+ if ( scope.page ) {
236
+ let { id } = await scope.examinePageID( scope, 'none to match' );
237
+ name += `${ id }-`;
212
238
  }
213
239
 
214
- let safe_name = safe_filename(`${ prefix }${ Date.now() }-${ scope.base_filename }`, { replacement: `_` });
215
- let safer_name = safe_name.substring(0, (255 - 10)); // Allow room for extensions
240
+ name += scope.base_filename;
241
+ name += `-${ files.readable_date() }`;
242
+
243
+ let safe_name = safe_filename( name, { replacement: `_` });
244
+ // Allow room for extensions, etc.
245
+ let safer_name = safe_name.substring(0, (255 - 10));
216
246
  return safer_name;
217
247
  }, // Ends scope.getSafeScenarioFilename()
218
248
 
219
249
  getJSONFilename: async function( scope ) {
220
- let filename = await scope.getSafeScenarioFilename( scope, {
221
- prefix: `downloads_${ scope.page_id }_JSON`
222
- });
223
- filename += `.json`;
224
- return filename;
250
+ // Get a unique path for a json file
251
+ let { id } = await scope.examinePageID( scope, 'none to match' );
252
+ let date = files.readable_date();
253
+ let json_path = path.join( scope.paths.scenario, `json_for-${ id }-${ date }.json` );
254
+ return json_path;
225
255
  },
226
256
 
227
257
  savePageJSONData: async function( scope ) {
@@ -231,13 +261,12 @@ module.exports = {
231
261
  * @returns {string} info.filename The name of the file that was saved.
232
262
  * @returns {Object} info.json The variables object.
233
263
  */
234
- // Get a unique json file filename
235
- let filename = await scope.getJSONFilename( scope );
264
+ let json_path = await scope.getJSONFilename( scope );
236
265
  // Get the data
237
266
  let json = await scope.getPageJSON( scope );
238
267
  // Save the data
239
- fs.writeFileSync( filename, JSON.stringify( json, null, 2 ));
240
- return { filename, json };
268
+ fs.writeFileSync( json_path, JSON.stringify( json, null, 2 ));
269
+ return { json_path, json };
241
270
  }, // Ends scope.savePageJSONData()
242
271
 
243
272
  waitForShowIf: async function waitForShowIf(scope) {
@@ -386,7 +415,14 @@ module.exports = {
386
415
 
387
416
  detectDownloadComplete: async function detectDownloadComplete(scope, endTime) {
388
417
  /* Resolve if a download flag has changed. Timeout after a bit less than
389
- * the global permitted timeout */
418
+ * the global permitted timeout
419
+ *
420
+ * TODO: Properly wait for download to complete. See
421
+ * https://github.com/SuffolkLITLab/ALKiln/issues/492 and
422
+ * https://stackoverflow.com/a/56951024 and
423
+ * https://github.com/puppeteer/puppeteer/issues/4676 and
424
+ * https://github.com/puppeteer/puppeteer/issues/299 and
425
+ */
390
426
 
391
427
  // Stop/resolve if too much time has passed
392
428
  let expirationTime = scope.timeout - 200; // ms
@@ -454,7 +490,7 @@ module.exports = {
454
490
 
455
491
  // Do your own waiting if you need to in the step
456
492
  return to_manipulate;
457
- }, // Ends setVar()
493
+ }, // Ends scope.setVar()
458
494
 
459
495
  continue: async function ( scope ) {
460
496
  /* Presses whatever button it finds that might lead to the next page. */
@@ -466,7 +502,25 @@ module.exports = {
466
502
  await elem.evaluate( el => { return el.className });
467
503
  // Waits for navigation or user error
468
504
  await scope.tapElement( scope, elem );
469
- },
505
+ }, // Ends scope.continue()
506
+
507
+ continue_exists: async function ( scope ) {
508
+ /** Return true if a button that will allow continuing appears
509
+ * on this page. Otherwise return false. */
510
+
511
+ // Possible to find this on an event screen with `buttons:`, like an exit button
512
+ // <button type="submit" class="btn btn-da btn-danger" name="X211bHRpcGxlX2Nob2ljZQ" value="0">exit</button>
513
+ // An actual continue button that doesn't set a variable
514
+ // <button class="btn btn-da btn-primary" type="submit">Continue</button>
515
+ // One that sets a variable:
516
+ // <button type="submit" class="btn btn-da btn-primary" name="Zm9v" value="True">Continue</button>
517
+ // `buttons:` can be used in question blocks as choices
518
+ let regular = await scope.page.$( `fieldset.da-field-buttons button[type="submit"]` );
519
+ let signature = await scope.page.$( `fieldset .dasigsave` );
520
+
521
+ return regular !== null || signature !== null;
522
+
523
+ }, // Ends scope.continue_exists()
470
524
 
471
525
  tapTab: async function ( scope, tab_id ) {
472
526
  let elem = await scope.page.$(`#${tab_id}`);
@@ -497,12 +551,13 @@ module.exports = {
497
551
  axe_result.passes = [];
498
552
  axe_result.inapplicable = [];
499
553
 
500
- let axe_filename = null;
554
+ let axe_path = null;
501
555
  const passed = axe_result.violations.length == 0;
502
556
  if (!passed) {
503
- axe_filename = await scope.getSafeScenarioFilename( scope, {prefix: `aXe_failure_${ scope.page_id }`}) + '.json';
504
- fs.writeFileSync( axe_filename, JSON.stringify( axe_result, null, 2 ));
505
- let msg = `Found potential accessibility issues on the ${ scope.page_id } screen. Details in ${ axe_filename }.`;
557
+ let axe_filename = await scope.getSafeScenarioFilename( scope, {prefix: `aXe_failure`}) + '.json';
558
+ axe_path = `${ scope.paths.scenario }/${ axe_filename }`;
559
+ fs.writeFileSync( axe_path, JSON.stringify( axe_result, null, 2 ));
560
+ let msg = `Found potential accessibility issues on the ${ scope.page_id } screen. Details in ${ axe_path }.`;
506
561
  await scope.addToReport( scope, {
507
562
  type: `error`,
508
563
  value: msg
@@ -512,7 +567,7 @@ module.exports = {
512
567
  }
513
568
  return {
514
569
  passed: axe_result.violations.length == 0,
515
- axe_filename: axe_filename
570
+ axe_filepath: axe_path
516
571
  };
517
572
  },
518
573
 
@@ -666,7 +721,7 @@ module.exports = {
666
721
  // All the different types of fields
667
722
  // buttons, canvases, inputs of all kinds, selects (dropdowns), textareas. Are there more?
668
723
  // Will deal with `option` once inside `select`
669
- let all_nodes = $( `#dasigpage, fieldset button, .daquestionactionbutton, fieldset input, .da-form-group input, .da-form-group select, .da-form-group textarea` );
724
+ let all_nodes = $( `#dasigpage, fieldset button, .daquestionactionbutton, fieldset input, .da-form-group input, .da-form-group select, form select, .da-form-group textarea` );
670
725
  for ( let node of all_nodes ) {
671
726
  // Decision: Do not abstract the below. There's too much data to pass around for it to make sense
672
727
  let $node = $( node );
@@ -932,6 +987,9 @@ module.exports = {
932
987
  // names.var_names.push( layer_2_decoded );
933
988
  }
934
989
 
990
+ // Detecting valid variable names/characters
991
+ // https://stackoverflow.com/a/23377268
992
+
935
993
  return var_names;
936
994
  }, // Ends scope.getPossibleVarNames()
937
995
 
@@ -1198,13 +1256,7 @@ module.exports = {
1198
1256
  await scope.page.waitForSelector('#dasigcanvas');
1199
1257
  }
1200
1258
 
1201
- let handle = await scope.page.evaluateHandle(( selector, field ) => {
1202
- let elem = document.querySelector( selector );
1203
- // `elem` has never been null
1204
- if ( elem.nextSibling && elem.nextSibling.tag === `label` ) {
1205
- return elem.nextSibling;
1206
- } else { return elem; }
1207
- }, field.selector, field );
1259
+ let handle = await scope.get_handle_from_field( scope, { field: field });
1208
1260
 
1209
1261
  let disabled = await handle.evaluate(( elem )=> {
1210
1262
  // da's header covers the top of the section containing the fields which means
@@ -1225,15 +1277,39 @@ module.exports = {
1225
1277
  await scope.page.waitForTimeout( 400 );
1226
1278
  }
1227
1279
 
1228
- let { tag, type } = field;
1229
1280
  // We use the last match. We wrote a warning for the user if there were
1230
1281
  // multiple matches in the story that the last value will be used. In future,
1231
1282
  // Steps will be able to set this data gradually and we don't want the developer
1232
1283
  // to get confused thinking that an earlier Step was used instead of a later one.
1233
1284
  let row = matching_input_rows[ matching_input_rows.length - 1 ];
1234
- let set_to = row.value;
1285
+ let answer = row.value;
1286
+
1287
+ await scope.funnel_the_answer( scope, { handle, answer, field });
1288
+
1289
+ return row;
1290
+ }, // Ends scope.setVariable()
1291
+
1292
+ get_handle_from_field: async function ( scope, { field }) {
1293
+ /** Get the element's puppeteer handle using the field's selector. */
1294
+
1295
+ let handle = await scope.page.evaluateHandle(( selector ) => {
1296
+ let elem = document.querySelector( selector );
1297
+ // If the element has a sibling that's a label, we need to
1298
+ // interact with that label instead.
1299
+ // `elem` has never been null from what we've seen
1300
+ if ( elem.nextSibling && elem.nextSibling.tag === `label` ) {
1301
+ return elem.nextSibling;
1302
+ } else { return elem; }
1303
+ // Pass in the selector as an argument
1304
+ }, field.selector );
1305
+
1306
+ return handle;
1307
+ }, // Ends scope.get_handle_from_field()
1308
+
1309
+ get_custom_datatype: async function( scope, { handle }) {
1310
+ /** If this is part of a custom datatype, returns an element,
1311
+ * otherwise returns null. */
1235
1312
 
1236
- // Detect a custom type and get the element of that custom type if it exists
1237
1313
  let custom_datatype = await handle.evaluate(( elem )=> {
1238
1314
  // If the element is in a form group**, then extract the form group's
1239
1315
  // datatype value.
@@ -1252,28 +1328,52 @@ module.exports = {
1252
1328
  return custom_datatype;
1253
1329
  });
1254
1330
 
1255
- // Set field
1256
- if ( scope.setCustomDatatype[ custom_datatype ] === undefined ) {
1257
- // If no custom datatype, set a regular field
1258
- await scope.setField[ tag ]( scope, { handle, set_to });
1259
- } else {
1331
+ return custom_datatype;
1332
+ }, // Ends scope.is_custom_datatype()
1333
+
1334
+ funnel_the_answer: async function( scope, { handle, field, answer }) {
1335
+ /** Use the tag or custom datatype to funnel the answer to the right
1336
+ * field. Pages without a handle should pass in `null` for the
1337
+ * handle.
1338
+ */
1339
+
1340
+ let custom_datatype = null;
1341
+ // Pages with elements like `canvas` for signatures won't have a handle
1342
+ // to pass in. They should pass in `null` for the handle and shouldn't
1343
+ // need a handle in order to function.
1344
+ if ( handle !== null ) {
1345
+ // Get the element of that custom type if it exists
1346
+ custom_datatype = await scope.get_custom_datatype( scope, { handle });
1347
+ }
1348
+
1349
+ // If there is a custom datatype function available, use it to set the field
1350
+ if ( scope.setCustomDatatype[ custom_datatype ] !== undefined ) {
1260
1351
  // Set da package custom fields. TODO: find `custom_handle` in the setter?
1261
1352
  let custom_handle = await handle.evaluateHandle(( elem )=> { return elem.closest( `.da-form-group` ) });
1262
- await scope.setCustomDatatype[ custom_datatype ]( scope, { handle: custom_handle, set_to });
1353
+ await scope.setCustomDatatype[ custom_datatype ]( scope, { handle: custom_handle, answer });
1354
+ } else {
1355
+ // If no custom datatype, set a regular field
1356
+ await scope.enter_answer[ field.tag ]( scope, { handle, answer });
1263
1357
  }
1264
1358
 
1265
- return row;
1266
- }, // Ends scope.setVariable()
1359
+ }, // Ends scope.funnel_the_answer()
1267
1360
 
1268
1361
  // Handle setting values for da custom datatypes. E.g. `da-field-container-datatype-BirthDate`
1269
1362
  // TODO: Make this more easily extensible.
1270
- // TODO: Just put this in `scope.setField`? Find the custom handle in the setter itself.
1363
+ // TODO: Just put this in `scope.enter_answer`? Find the custom handle in the setter itself.
1271
1364
  setCustomDatatype: {
1272
- "BirthDate": async function ( scope, { handle, set_to }) {
1365
+ al_date: async function ( scope, { handle, answer, is_past }) {
1273
1366
  /* Given a date of the format 'mm/dd/yyyy' and the handle of the
1274
- * custom field container, sets the ALToolbox custom birthdate
1367
+ * custom field container, sets an AssemblyLine three-part date's
1275
1368
  * field values. */
1276
- let date_parts = set_to.split(`/`);
1369
+
1370
+ // If it doesn't have the right format
1371
+ if ( answer === null || !answer.includes(`/`) ) {
1372
+ // Do nothing
1373
+ return;
1374
+ }
1375
+
1376
+ let date_parts = answer.split(`/`);
1277
1377
  let fields = await handle.$$(`select, input:not([type="hidden"])`);
1278
1378
  for ( let field_i = 0; field_i < fields.length; field_i++ ) {
1279
1379
  let field = fields[ field_i ];
@@ -1286,55 +1386,76 @@ module.exports = {
1286
1386
  // Set the month
1287
1387
  await field.select( date_parts[ 0 ] );
1288
1388
  } else {
1289
- // Set the day and year
1389
+ // Set the day or year
1290
1390
  await scope.setText( scope, {
1291
1391
  handle: field,
1292
- set_to: date_parts[ field_i ]
1392
+ answer: date_parts[ field_i ]
1293
1393
  });
1294
1394
  }
1295
1395
 
1296
1396
  } // For each field
1297
1397
 
1298
1398
  await scope.afterStep( scope );
1299
- }, // Ends BirthDate()
1399
+ }, // Ends scope.setCustomDatatype.al_date()
1400
+
1401
+ BirthDate: async function ( scope, { handle, answer }) {
1402
+ /** Given a date of the format 'mm/dd/yyyy' and the handle of the
1403
+ * custom field container, sets the ALToolbox custom birthdate
1404
+ * field values. */
1300
1405
 
1406
+ await scope.setCustomDatatype.al_date( scope, { handle, answer })
1407
+ }, // Ends scope.setCustomDatatype.BirthDate()
1408
+
1409
+ ThreePartsDate: async function ( scope, { handle, answer }) {
1410
+ /** Given a date of the format 'mm/dd/yyyy' and the handle of the
1411
+ * custom field container, sets the ALToolbox custom ThreePartsDate
1412
+ * field values. */
1413
+ await scope.setCustomDatatype.al_date( scope, { handle, answer })
1414
+ }, // Ends scope.setCustomDatatype.ThreePartsDate()
1301
1415
 
1302
1416
  }, // ends setCustomDatatype {}
1303
1417
 
1304
- setField: {
1305
- button: async function ( scope, { handle, set_to }) { await scope.tapElement( scope, handle ); },
1306
- textarea: async function ( scope, { handle, set_to }) {
1307
- await scope.setText( scope, { handle, set_to });
1418
+ enter_answer: {
1419
+ button: async function ( scope, { handle, answer }) { await scope.tapElement( scope, handle ); },
1420
+ textarea: async function ( scope, { handle, answer }) {
1421
+ await scope.setText( scope, { handle, answer });
1308
1422
  await scope.afterStep(scope); // No showifs for this one?
1309
1423
  },
1310
- select: async function ( scope, { handle, set_to }) {
1424
+ select: async function ( scope, { handle, answer }) {
1425
+ // Note: I don't remember what's really going on in here.
1426
+ // I will note that values of `select` elements are strings,
1427
+ // not variables, so we don't need to check for variable names.
1428
+
1311
1429
  // A dropdown's option value can be one of two things
1312
1430
  // Try to find the element using the first value
1313
1431
 
1314
- // TODO: Can we somehow pass in a `set_to` that is encoded correctly since
1432
+ // TODO: Can we somehow pass in a `answer` that is encoded correctly since
1315
1433
  // we're getting the fields from the DOM to begin with? We'll probably
1316
1434
  // need another prop because we need the `option` human value to
1317
1435
  // match the story table
1318
1436
 
1319
1437
  // TODO: Change this to search in the actual handle instead of just $
1320
- let option = await scope.page.$(`option[value="${ set_to }"]`);
1438
+ // There could be multiple fields on the page with the same option
1439
+ // values.
1440
+ // let option = await handle.$(`option[value="${ answer }"]`);
1441
+ let option = await scope.page.$(`option[value="${ answer }"]`);
1321
1442
  if ( option ) {
1322
- await handle.select( set_to ); // And use that value to set it
1443
+ await handle.select( answer ); // And use that value to set it
1323
1444
 
1324
1445
  // If that literal value isn't on the page, it should be a base64 encoded value
1325
- // TODO: Can these be double encoded?
1446
+ // TODO: Can these be double encoded? Can they be encoded like objects?
1326
1447
  } else {
1327
- let base64_name = await scope.toBase64( scope, { utf8_str: set_to });
1448
+ let base64_name = await scope.toBase64( scope, { utf8_str: answer });
1328
1449
  await handle.select( base64_name );
1329
1450
  }
1330
1451
 
1331
1452
  await scope.afterStep(scope, { waitForShowIf: true });
1332
1453
  },
1333
- canvas: async function ( scope, { handle, set_to }) {
1454
+ canvas: async function ( scope, { handle, answer }) {
1334
1455
  await scope.sign( scope );
1335
1456
  await scope.afterStep( scope );
1336
1457
  },
1337
- input: async function ( scope, { handle, set_to }) {
1458
+ input: async function ( scope, { handle, answer }) {
1338
1459
  /* Set value of some `input` element to the given value. */
1339
1460
 
1340
1461
  let type = await handle.evaluate( elem => { return elem.getAttribute('type'); });
@@ -1343,50 +1464,60 @@ module.exports = {
1343
1464
  let [label] = await handle.$x(`following-sibling::label`);
1344
1465
 
1345
1466
  if ( session_vars.get_debug() ) {
1346
- if ( set_to.toLowerCase() === 'false' ) { await label.evaluate( elem => { elem.style.background = 'tomato'; }); }
1467
+ if ( answer.toLowerCase() === 'false' ) { await label.evaluate( elem => { elem.style.background = 'tomato'; }); }
1347
1468
  else { await label.evaluate( elem => { elem.style.background = 'teal'; }); }
1348
1469
  let name = await label.evaluate( elem => { return elem.getAttribute('for'); });
1349
1470
  }
1350
1471
 
1351
1472
  if ( type === `radio` ) { await label.evaluate( elem => { return elem.click(); }); }
1352
- else { await scope.setCheckbox( scope, { label, set_to }); }
1473
+ else { await scope.setCheckbox( scope, { label, answer }); }
1353
1474
 
1354
1475
  await scope.afterStep(scope, { waitForShowIf: true });
1355
1476
 
1356
1477
  } else if ( type === `file` ) {
1357
- await scope.uploadFiles( scope, { handle, set_to });
1478
+ await scope.uploadFiles( scope, { handle, answer });
1358
1479
  await scope.afterStep(scope, { waitForShowIf: true });
1359
1480
 
1360
1481
  } else if (type == `date`) {
1361
- let set_to_date = set_to;
1482
+ let answer_date = answer;
1362
1483
  // If the date is specified in the format "today" or "today + 2" or "today-1"
1363
1484
  // it will need some pre-processing.
1364
- if (set_to.indexOf(`today`) !== -1) {
1485
+ if (answer.indexOf(`today`) !== -1) {
1365
1486
  // Find out how many days to add/subtract
1366
- let date_delta = set_to.replace(/\s/g, '');
1487
+ let date_delta = answer.replace(/\s/g, '');
1367
1488
  date_delta = date_delta.match(/today([+-]\d+)?/)[1];
1368
1489
  date_delta = date_delta == undefined ? 0 : date_delta;
1369
1490
  date_delta = parseInt(date_delta);
1370
1491
  // Get today's date
1371
- set_to_date = new Date();
1492
+ answer_date = new Date();
1372
1493
  // Add/subtract required number of days
1373
- set_to_date.setDate(set_to_date.getDate() + date_delta);
1494
+ answer_date.setDate(answer_date.getDate() + date_delta);
1374
1495
  // Convert to the docassemble date field format: mm/dd/yyyy
1375
1496
  // Reference: https://gomakethings.com/setting-a-date-input-to-todays-date-with-vanilla-js/
1376
- set_to_date = ((set_to_date.getMonth() + 1).toString().padStart(2, 0) + '/' +
1377
- set_to_date.getDate().toString().padStart(2, 0) + '/' +
1378
- set_to_date.getFullYear().toString());
1497
+ answer_date = ((answer_date.getMonth() + 1).toString().padStart(2, 0) + '/' +
1498
+ answer_date.getDate().toString().padStart(2, 0) + '/' +
1499
+ answer_date.getFullYear().toString());
1379
1500
  }
1380
- await scope.setText( scope, { handle, set_to: set_to_date });
1501
+ await scope.setText( scope, { handle, answer: answer_date });
1381
1502
  await scope.afterStep(scope, { waitForShowIf: true });
1382
1503
  } else {
1383
- await scope.setText( scope, { handle, set_to });
1504
+ await scope.setText( scope, { handle, answer });
1384
1505
  await scope.afterStep(scope, { waitForShowIf: true });
1385
1506
  }
1386
- }, // Ends scope.setField.input()
1387
- },
1507
+ }, // Ends scope.enter_answer.input()
1508
+ hidden: async function ( scope, { handle, answer }) {
1509
+ // Do nothing. The only way the code should get here is if
1510
+ // this wasn't a custom datatype we can handle.
1511
+ // If we were to write a message, I don't know what we can
1512
+ // write - this may be a different custom datatype or this
1513
+ // could be a special da field that we shouldn't worry the
1514
+ // dev about at all.
1515
+ return;
1516
+ },
1517
+
1518
+ }, // ends scope.enter_answer
1388
1519
 
1389
- uploadFiles: async function ( scope, { handle, set_to }) {
1520
+ uploadFiles: async function ( scope, { handle, answer }) {
1390
1521
  /* Try to upload one or more files to a file-type input field. */
1391
1522
 
1392
1523
  // Tests could be in one of two places. Figure out which directory they're in
@@ -1419,7 +1550,7 @@ module.exports = {
1419
1550
  }
1420
1551
 
1421
1552
  // Allow a typo where someone forgot to include a space after a comma
1422
- let file_names = set_to.split(`,`);
1553
+ let file_names = answer.split(`,`);
1423
1554
 
1424
1555
  // If a file exist, add it to a list of files to be uploaded. If it doesn't, add a warning.
1425
1556
  let paths = [];
@@ -1441,8 +1572,8 @@ module.exports = {
1441
1572
  }
1442
1573
 
1443
1574
  if ( paths.length === 0 ) {
1444
- await scope.addToReport( scope, { type: `error`, value: `Could not find "${set_to}".` });
1445
- throw ReferenceError(`Could not find "${set_to}".`)
1575
+ await scope.addToReport( scope, { type: `error`, value: `Could not find "${answer}".` });
1576
+ throw ReferenceError(`Could not find "${answer}".`)
1446
1577
  }
1447
1578
  // Prepare to wait for upload to complete
1448
1579
  let name = await handle.evaluate( elem => { return elem.getAttribute(`name`); });
@@ -1467,20 +1598,20 @@ module.exports = {
1467
1598
 
1468
1599
  }, // Ends scope.uploadFiles()
1469
1600
 
1470
- setCheckbox: async function ( scope, { label, set_to }) {
1601
+ setCheckbox: async function ( scope, { label, answer }) {
1471
1602
  // Depending on the current value/status of a checkbox and the desired
1472
1603
  // value to set, either taps the checkbox or leaves it alone.
1473
1604
  let status = await label.evaluate( elem => { return elem.getAttribute('aria-checked'); });
1474
- if ( set_to.toLowerCase() !== status ) {
1605
+ if ( answer.toLowerCase() !== status ) {
1475
1606
  await label.evaluate( elem => { return elem.click(); });
1476
1607
  }
1477
1608
  }, // Ends scope.setCheckbox()
1478
1609
 
1479
- setText: async function ( scope, { handle, set_to }) {
1610
+ setText: async function ( scope, { handle, answer }) {
1480
1611
  // Set text in some kind of field (input text, input date, textarea, etc.)
1481
1612
  await handle.evaluate( el => { el.value = '' });
1482
1613
  await handle.focus();
1483
- await handle.type( set_to );
1614
+ await handle.type( answer );
1484
1615
  setTimeout(async () => {
1485
1616
  }, 500)
1486
1617
  },
@@ -1527,7 +1658,7 @@ module.exports = {
1527
1658
  }, // Ends scope.examinePageID()
1528
1659
 
1529
1660
  // TODO: Rename var_data to input_data?
1530
- // Note: Totally different from `setField` (which is singular)
1661
+ // Note: Totally different from `enter_answer` (which is singular)
1531
1662
  // TODO: If a step triggers this and is not able to set a value, should it error?
1532
1663
  // TODO: Rename to setFieldsOfOnePage? setPageFields?
1533
1664
  setFields: async function ( scope, {
@@ -1656,7 +1787,8 @@ module.exports = {
1656
1787
  }
1657
1788
  }
1658
1789
  if ( num_unused_rows > 0 ) {
1659
- let msg = `Missing variable or variables on page.`;
1790
+ let { id } = await scope.examinePageID( scope, 'none to match' );
1791
+ let msg = `Missing variable or variables on page "${id}"`;
1660
1792
  await scope.addToReport(scope, { type: `error`, value: msg });
1661
1793
  expect( num_unused_rows, msg ).to.equal( 0 );
1662
1794
  }
@@ -1885,12 +2017,18 @@ module.exports = {
1885
2017
  /* Download and save a screenshot. `name` does not have to be
1886
2018
  * unique as the filename will be made unique. */
1887
2019
  name = name || '';
1888
- let safe_path = await scope.getSafeScenarioFilename(
1889
- scope, { prefix: `screenshot_${ name }` }
1890
- );
2020
+
2021
+ let date = files.readable_date();
2022
+ let { id } = await scope.examinePageID( scope, 'none to match' );
2023
+ // Include the custom name if it has one
2024
+ if ( name.length > 0 ) {
2025
+ name = `screenshot-${ name }-on-${ id }-${ date }`;
2026
+ } else {
2027
+ name = `screenshot_on-${ id }-${ date }`;
2028
+ }
1891
2029
 
1892
2030
  await scope.page.screenshot({
1893
- path: `./${ safe_path }.jpg`,
2031
+ path: `./${ scope.paths.scenario }/${ name }.jpg`,
1894
2032
  type: 'jpeg',
1895
2033
  fullPage: true
1896
2034
  });
@@ -1930,7 +2068,7 @@ module.exports = {
1930
2068
  await scope.afterStep(scope);
1931
2069
  }, // Ends link_works()
1932
2070
 
1933
- set_regular_var: async ( scope, var_name, set_to ) => {
2071
+ set_regular_var: async ( scope, var_name, answer ) => {
1934
2072
  /** Set a non-story table variable (with or without a choice associated with it) to a value. Set:
1935
2073
  * - Buttons associated with variables
1936
2074
  * - Dropdowns
@@ -1941,23 +2079,23 @@ module.exports = {
1941
2079
  */
1942
2080
  // Let kiln know not to test against trigger variable values, even for proxy vars
1943
2081
  let var_data = await scope.normalizeTable( scope, {
1944
- var_data: [{ var: var_name, value: set_to, trigger: scope.trigger_not_needed_flag }],
2082
+ var_data: [{ var: var_name, value: answer, trigger: scope.trigger_not_needed_flag }],
1945
2083
  });
1946
2084
  // Don't continue if the variable doesn't cause navigation itself. Also,
1947
2085
  // if this variable cannot be set on this page, trigger an error
1948
2086
  await scope.setFields( scope, { var_data, ensure_navigation: false, ensure_all_vars_used: true });
1949
2087
  }, // Ends set_regular_var()
1950
2088
 
1951
- set_secret_var: async ( scope, var_name, set_to_env_name ) => {
2089
+ set_secret_var: async ( scope, var_name, answer_env_name ) => {
1952
2090
  /** Sets a non-story table variable to a "secret" value (i.e. an env var) */
1953
2091
  // Prevent pictures of a screen with a secret.
1954
2092
  scope.disable_error_screenshot = true;
1955
2093
  let var_data = await scope.normalizeTable( scope, {
1956
2094
  var_data: [{
1957
2095
  var: var_name,
1958
- value: set_to_env_name,
2096
+ value: answer_env_name,
1959
2097
  trigger: scope.trigger_not_needed_flag,
1960
- flags: {secret_name: set_to_env_name}
2098
+ flags: {secret_name: answer_env_name}
1961
2099
  }],
1962
2100
  });
1963
2101
  // Don't continue if the variable doesn't cause navigation itself. Also,
@@ -1990,6 +2128,9 @@ module.exports = {
1990
2128
  * WARNING: Cannot download the same file twice in a single scenario.
1991
2129
  * WARNING: Must not have any characters that are totally unique to that
1992
2130
  * page load. It's usually just the name of the file.
2131
+ *
2132
+ * TODO: Properly wait for download to complete. See notes in
2133
+ * scope.js scope.detectDownloadComplete()
1993
2134
  */
1994
2135
  let [elem] = await scope.page.$x(`//a[contains(@href, "${ filename }")]`);
1995
2136
 
@@ -2126,10 +2267,235 @@ module.exports = {
2126
2267
  });
2127
2268
 
2128
2269
  await scope.afterStep(scope, {waitForShowIf: false});
2129
- }, // Ends sign_in
2270
+ }, // Ends scope.steps.sign_in()
2271
+
2272
+ set_random_page_vars: async ( scope ) => {
2273
+ /** Answer inputs randomly. */
2274
+
2275
+ let html = await scope.page.content();
2276
+ // These are the fields on the current page. Their handles should all exist.
2277
+ let fields = await scope.getAllFields( scope, { html: html });
2278
+ let buttons = [];
2279
+
2280
+ // For every field on the page
2281
+ for ( let field of fields ) {
2282
+
2283
+ // fields[n].tag is HTML tag of node, as a string
2284
+
2285
+ // If the field is a button, save it in a list of buttons that we'll deal with later
2286
+ if ( field.tag === `button` ) {
2287
+ buttons.push( field );
2288
+ } else {
2289
+ // Try to give a random answer for that element tag
2290
+ await scope.set_random_input_for[ field.tag ]( scope, { field: field });
2291
+ process.stdout.write(`\x1b[36m${ '*' }\x1b[0m`); // assumes var was set if no error occurred
2292
+ }
2293
+
2294
+ } // End for all page fields
2295
+
2296
+ return buttons;
2297
+
2298
+ }, // Ends scope.steps.set_random_page_vars()
2130
2299
 
2131
2300
  }, // ends scope.steps
2132
2301
 
2302
+ set_random_input_for: {
2303
+ a: async function (scope, { fields }) {
2304
+ // We don't do anything with links for random input tests
2305
+ },
2306
+ buttons: async function (scope, { fields }) {
2307
+ // Get a random button
2308
+ let field = faker.helpers.arrayElement( fields );
2309
+ let handle = await scope.get_handle_from_field( scope, { field: field });
2310
+ // Send it to be tapped
2311
+ await scope.funnel_the_answer( scope, { handle, field, answer: `doesn't matter` });
2312
+ },
2313
+ textarea: async function (scope, { field }) {
2314
+ // Get random paragraph of text
2315
+ let answer = faker.lorem.paragraph();
2316
+ // Get the element's puppeteer handler
2317
+ let handle = await scope.get_handle_from_field( scope, { field: field });
2318
+ // Type the random text into the textarea
2319
+ await scope.funnel_the_answer( scope, { handle, field, answer: answer });
2320
+ },
2321
+ select: async function (scope, { field }) {
2322
+ // Get the values of all the options of the field
2323
+ let handle = await scope.get_handle_from_field( scope, { field: field });
2324
+ // Get the values of all the choices
2325
+ let answer_choices = await handle.evaluate(function ( elem ) {
2326
+ let values = Array.from(elem.querySelectorAll(`option`)).map(element=>element.value);
2327
+ return values;
2328
+ });
2329
+
2330
+ // Remove the "no selection" option from the list ('')
2331
+ // TODO: blue sky - for optional fields allow no selection
2332
+ if ( answer_choices[0] === `` ) {
2333
+ answer_choices.shift();
2334
+ }
2335
+
2336
+ // Select randomly between them
2337
+ let answer = faker.helpers.arrayElement( answer_choices );
2338
+
2339
+ // Set that value
2340
+ await scope.funnel_the_answer( scope, { handle, field, answer: answer });
2341
+
2342
+ },
2343
+ canvas: async function (scope, { field }) {
2344
+ // WARNING: Do not test this in headless mode.
2345
+ // If we do this in headless mode, the test will fail. It signs, but
2346
+ // can't continue. Not sure why.
2347
+ await scope.funnel_the_answer( scope, { field, handle: null, answer: `doesn't matter` });
2348
+ },
2349
+ input: async function (scope, { field }) {
2350
+ /** Handles various `type`s of input element fields, except:
2351
+ *
2352
+ * Does not handle "file" type
2353
+ * Does not handle custom data values yet, like BirthDate, because they have
2354
+ * `type="hidden"` which also matches other special da fields on the
2355
+ * page that we SHOULDN'T interact with.
2356
+ * Does not handle range, which is not a `type`, but it is an input element
2357
+ */
2358
+
2359
+ // Use the type instead of the tag to get get the answer for
2360
+ // an input field
2361
+ let type = field.type;
2362
+
2363
+ // Custom datatype question: Do all custom datatypes hide their
2364
+ // original field? Probably not...
2365
+ // TODO: Move custom datatype checking to the caller of this
2366
+ // function somehow - in future, not all custom datatypes will
2367
+ // be inputs _and_ this won't handle, for example, a `select` field
2368
+ // that is part of a custom datatype with a hidden field - this
2369
+ // doesn't take care of that situation.
2370
+
2371
+ let handle = await scope.get_handle_from_field( scope, { field: field });
2372
+
2373
+ // If this is a custom datatype
2374
+ let custom_datatype = await scope.get_custom_datatype( scope, { handle });
2375
+ let is_custom_datatype = scope.get_random_input_for[ custom_datatype ] !== undefined;
2376
+ if ( is_custom_datatype ) {
2377
+
2378
+ // And the custom datatype has a hidden field (which will be the original field)
2379
+ let hidden_field = await scope.get_custom_datatype_hidden_field( scope, { handle });
2380
+ if ( hidden_field !== null ) {
2381
+ // only set the value once - just for the original field
2382
+ if ( type === `hidden` ) {
2383
+
2384
+ // set the value
2385
+ let answer = await scope.get_random_input_for[ custom_datatype ]( scope );
2386
+ await scope.funnel_the_answer( scope, { handle, field, answer });
2387
+
2388
+ }
2389
+ // Otherwise ignore the non-original field
2390
+
2391
+ // Untested: If no hidden field exists, just try to set the custom
2392
+ // datatype field
2393
+ } else {
2394
+ // set the value
2395
+ let answer = await scope.get_random_input_for[ custom_datatype ]( scope );
2396
+ await scope.funnel_the_answer( scope, { handle, field, answer });
2397
+ }
2398
+
2399
+ // If this is not a custom datatype
2400
+ } else {
2401
+
2402
+ // `file` type inputs come next to an input with a type of ''.
2403
+ // Maybe others do too.
2404
+ if ( type !== `` && type !== `hidden` ) {
2405
+ // Field types that we can't yet handle, but plan to in future
2406
+ if ( type === `file` ) {
2407
+ await scope.addToReport( scope, {
2408
+ type: `warning`,
2409
+ value: `Sorry, ALKiln's random-input cannot yet handle "${ type }" fields.`
2410
+ });
2411
+ } else {
2412
+ let answer = await scope.get_random_input_for[ type ]( scope );
2413
+ await scope.funnel_the_answer( scope, { handle, field, answer });
2414
+ } // ends if field type is one we cover
2415
+ } // ends if field type exists
2416
+
2417
+ } // ends if is custom datatype
2418
+
2419
+ }, // ends scope.set_random_input_for.input
2420
+ }, // ends scope.set_random_input_for
2421
+
2422
+ get_custom_datatype_hidden_field: async function (scope, { handle }) {
2423
+ /** If there's a hidden field in a custom datatype, returns that
2424
+ * DOM node, otherwise returns null. */
2425
+
2426
+ let hidden_field = await handle.evaluate(( elem )=> {
2427
+ // If the element is in a form group**, then extract the form group's
2428
+ // datatype value.
2429
+ // ** For now the only custom datatype we have is in a form group. In
2430
+ // future we should look into what happens with a `fieldset` for
2431
+ // `choices:`, etc.
2432
+ let custom_datatype = null, matches = null;
2433
+ let group = elem.closest( `.da-form-group` );
2434
+ // Returns an element or null
2435
+ let hidden_child = group.querySelector( `[type="hidden"]` );
2436
+
2437
+ return hidden_child;
2438
+ });
2439
+
2440
+ return hidden_field;
2441
+ }, // Ends scope.get_custom_datatype_hidden_field()
2442
+
2443
+ get_random_input_for: {
2444
+ checkbox: async function ( scope ) {
2445
+ return `${ Math.random() > .5 }`;
2446
+ },
2447
+ radio: async function ( scope ) {
2448
+ return `${ Math.random() > .5 }`;
2449
+ },
2450
+ text: async function ( scope ) {
2451
+ return faker.random.word();
2452
+ },
2453
+ number: async function ( scope ) {
2454
+ let answer = faker.helpers.arrayElement( [`1`, `2`, `3`], 1 );
2455
+ return answer;
2456
+ },
2457
+ date: async function ( scope ) {
2458
+ let past = faker.date.past(25);
2459
+ let future = faker.date.future(1);
2460
+
2461
+ let date = null;
2462
+ if ( Math.random() > .5 ) {
2463
+ date = past;
2464
+ } else {
2465
+ date = future;
2466
+ }
2467
+ // Ensure 2-digit number for month and day
2468
+ let month = ("0" + (date.getMonth() + 1)).slice(-2);
2469
+ let day = ("0" + (date.getDay() + 1)).slice(-2);
2470
+ // Format the string in the way ALKiln can read it
2471
+ return `${ month }/${ day }/${ date.getFullYear() }`;
2472
+ },
2473
+ currency: async function ( scope ) {
2474
+ return `${ Math.random() * 100 }`;
2475
+ },
2476
+ email: async function ( scope ) {
2477
+ return faker.internet.email( faker.random.word(), faker.random.word(), `example.com` );
2478
+ },
2479
+ password: async function ( scope ) {
2480
+ return faker.internet.password();
2481
+ },
2482
+ hidden: async function ( scope ) {
2483
+ return null;
2484
+ },
2485
+ BirthDate: async function ( scope ) {
2486
+ let date = faker.date.birthdate({ max: 70, min: 0, mode: `age` });
2487
+ // Ensure 2-digit number for month and day
2488
+ let month = ("0" + (date.getMonth() + 1)).slice(-2);
2489
+ let day = ("0" + (date.getDay() + 1)).slice(-2);
2490
+ // Format the string in the way ALKiln can read it
2491
+ let date_str = `${ month }/${ day }/${ date.getFullYear() }`;
2492
+ return date_str;
2493
+ },
2494
+ ThreePartsDate: async function ( scope ) {
2495
+ return await scope.get_random_input_for.date( scope );
2496
+ },
2497
+ }, // ends scope.get_random_input_for()
2498
+
2133
2499
 
2134
2500
  //#####################################
2135
2501
  //#####################################