@suffolklitlab/alkiln 4.6.2 → 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/CHANGELOG.md CHANGED
@@ -40,6 +40,13 @@ Format:
40
40
  -->
41
41
  <!-- ## [Unreleased] -->
42
42
 
43
+ ## [4.7.0] - 2022-07-27
44
+ ### Added
45
+ - Generate random input to fill out a simple form automatically
46
+
47
+ ### Changed
48
+ - Add page id to the "Missing Variable or variables on page" error report
49
+
43
50
  ## [4.6.2] - 2022-06-28
44
51
  ### Changed
45
52
  - Put cucumber tests' artifacts in one folder. See https://github.com/SuffolkLITLab/ALKiln/issues/552.
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');
@@ -488,7 +490,7 @@ module.exports = {
488
490
 
489
491
  // Do your own waiting if you need to in the step
490
492
  return to_manipulate;
491
- }, // Ends setVar()
493
+ }, // Ends scope.setVar()
492
494
 
493
495
  continue: async function ( scope ) {
494
496
  /* Presses whatever button it finds that might lead to the next page. */
@@ -500,7 +502,25 @@ module.exports = {
500
502
  await elem.evaluate( el => { return el.className });
501
503
  // Waits for navigation or user error
502
504
  await scope.tapElement( scope, elem );
503
- },
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()
504
524
 
505
525
  tapTab: async function ( scope, tab_id ) {
506
526
  let elem = await scope.page.$(`#${tab_id}`);
@@ -967,6 +987,9 @@ module.exports = {
967
987
  // names.var_names.push( layer_2_decoded );
968
988
  }
969
989
 
990
+ // Detecting valid variable names/characters
991
+ // https://stackoverflow.com/a/23377268
992
+
970
993
  return var_names;
971
994
  }, // Ends scope.getPossibleVarNames()
972
995
 
@@ -1233,13 +1256,7 @@ module.exports = {
1233
1256
  await scope.page.waitForSelector('#dasigcanvas');
1234
1257
  }
1235
1258
 
1236
- let handle = await scope.page.evaluateHandle(( selector, field ) => {
1237
- let elem = document.querySelector( selector );
1238
- // `elem` has never been null
1239
- if ( elem.nextSibling && elem.nextSibling.tag === `label` ) {
1240
- return elem.nextSibling;
1241
- } else { return elem; }
1242
- }, field.selector, field );
1259
+ let handle = await scope.get_handle_from_field( scope, { field: field });
1243
1260
 
1244
1261
  let disabled = await handle.evaluate(( elem )=> {
1245
1262
  // da's header covers the top of the section containing the fields which means
@@ -1260,15 +1277,39 @@ module.exports = {
1260
1277
  await scope.page.waitForTimeout( 400 );
1261
1278
  }
1262
1279
 
1263
- let { tag, type } = field;
1264
1280
  // We use the last match. We wrote a warning for the user if there were
1265
1281
  // multiple matches in the story that the last value will be used. In future,
1266
1282
  // Steps will be able to set this data gradually and we don't want the developer
1267
1283
  // to get confused thinking that an earlier Step was used instead of a later one.
1268
1284
  let row = matching_input_rows[ matching_input_rows.length - 1 ];
1269
- 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. */
1270
1312
 
1271
- // Detect a custom type and get the element of that custom type if it exists
1272
1313
  let custom_datatype = await handle.evaluate(( elem )=> {
1273
1314
  // If the element is in a form group**, then extract the form group's
1274
1315
  // datatype value.
@@ -1287,28 +1328,52 @@ module.exports = {
1287
1328
  return custom_datatype;
1288
1329
  });
1289
1330
 
1290
- // Set field
1291
- if ( scope.setCustomDatatype[ custom_datatype ] === undefined ) {
1292
- // If no custom datatype, set a regular field
1293
- await scope.setField[ tag ]( scope, { handle, set_to });
1294
- } 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 ) {
1295
1351
  // Set da package custom fields. TODO: find `custom_handle` in the setter?
1296
1352
  let custom_handle = await handle.evaluateHandle(( elem )=> { return elem.closest( `.da-form-group` ) });
1297
- 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 });
1298
1357
  }
1299
1358
 
1300
- return row;
1301
- }, // Ends scope.setVariable()
1359
+ }, // Ends scope.funnel_the_answer()
1302
1360
 
1303
1361
  // Handle setting values for da custom datatypes. E.g. `da-field-container-datatype-BirthDate`
1304
1362
  // TODO: Make this more easily extensible.
1305
- // 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.
1306
1364
  setCustomDatatype: {
1307
- "BirthDate": async function ( scope, { handle, set_to }) {
1365
+ al_date: async function ( scope, { handle, answer, is_past }) {
1308
1366
  /* Given a date of the format 'mm/dd/yyyy' and the handle of the
1309
- * custom field container, sets the ALToolbox custom birthdate
1367
+ * custom field container, sets an AssemblyLine three-part date's
1310
1368
  * field values. */
1311
- 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(`/`);
1312
1377
  let fields = await handle.$$(`select, input:not([type="hidden"])`);
1313
1378
  for ( let field_i = 0; field_i < fields.length; field_i++ ) {
1314
1379
  let field = fields[ field_i ];
@@ -1321,55 +1386,76 @@ module.exports = {
1321
1386
  // Set the month
1322
1387
  await field.select( date_parts[ 0 ] );
1323
1388
  } else {
1324
- // Set the day and year
1389
+ // Set the day or year
1325
1390
  await scope.setText( scope, {
1326
1391
  handle: field,
1327
- set_to: date_parts[ field_i ]
1392
+ answer: date_parts[ field_i ]
1328
1393
  });
1329
1394
  }
1330
1395
 
1331
1396
  } // For each field
1332
1397
 
1333
1398
  await scope.afterStep( scope );
1334
- }, // Ends BirthDate()
1399
+ }, // Ends scope.setCustomDatatype.al_date()
1335
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. */
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()
1336
1415
 
1337
1416
  }, // ends setCustomDatatype {}
1338
1417
 
1339
- setField: {
1340
- button: async function ( scope, { handle, set_to }) { await scope.tapElement( scope, handle ); },
1341
- textarea: async function ( scope, { handle, set_to }) {
1342
- 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 });
1343
1422
  await scope.afterStep(scope); // No showifs for this one?
1344
1423
  },
1345
- 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
+
1346
1429
  // A dropdown's option value can be one of two things
1347
1430
  // Try to find the element using the first value
1348
1431
 
1349
- // 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
1350
1433
  // we're getting the fields from the DOM to begin with? We'll probably
1351
1434
  // need another prop because we need the `option` human value to
1352
1435
  // match the story table
1353
1436
 
1354
1437
  // TODO: Change this to search in the actual handle instead of just $
1355
- 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 }"]`);
1356
1442
  if ( option ) {
1357
- await handle.select( set_to ); // And use that value to set it
1443
+ await handle.select( answer ); // And use that value to set it
1358
1444
 
1359
1445
  // If that literal value isn't on the page, it should be a base64 encoded value
1360
- // TODO: Can these be double encoded?
1446
+ // TODO: Can these be double encoded? Can they be encoded like objects?
1361
1447
  } else {
1362
- let base64_name = await scope.toBase64( scope, { utf8_str: set_to });
1448
+ let base64_name = await scope.toBase64( scope, { utf8_str: answer });
1363
1449
  await handle.select( base64_name );
1364
1450
  }
1365
1451
 
1366
1452
  await scope.afterStep(scope, { waitForShowIf: true });
1367
1453
  },
1368
- canvas: async function ( scope, { handle, set_to }) {
1454
+ canvas: async function ( scope, { handle, answer }) {
1369
1455
  await scope.sign( scope );
1370
1456
  await scope.afterStep( scope );
1371
1457
  },
1372
- input: async function ( scope, { handle, set_to }) {
1458
+ input: async function ( scope, { handle, answer }) {
1373
1459
  /* Set value of some `input` element to the given value. */
1374
1460
 
1375
1461
  let type = await handle.evaluate( elem => { return elem.getAttribute('type'); });
@@ -1378,50 +1464,60 @@ module.exports = {
1378
1464
  let [label] = await handle.$x(`following-sibling::label`);
1379
1465
 
1380
1466
  if ( session_vars.get_debug() ) {
1381
- 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'; }); }
1382
1468
  else { await label.evaluate( elem => { elem.style.background = 'teal'; }); }
1383
1469
  let name = await label.evaluate( elem => { return elem.getAttribute('for'); });
1384
1470
  }
1385
1471
 
1386
1472
  if ( type === `radio` ) { await label.evaluate( elem => { return elem.click(); }); }
1387
- else { await scope.setCheckbox( scope, { label, set_to }); }
1473
+ else { await scope.setCheckbox( scope, { label, answer }); }
1388
1474
 
1389
1475
  await scope.afterStep(scope, { waitForShowIf: true });
1390
1476
 
1391
1477
  } else if ( type === `file` ) {
1392
- await scope.uploadFiles( scope, { handle, set_to });
1478
+ await scope.uploadFiles( scope, { handle, answer });
1393
1479
  await scope.afterStep(scope, { waitForShowIf: true });
1394
1480
 
1395
1481
  } else if (type == `date`) {
1396
- let set_to_date = set_to;
1482
+ let answer_date = answer;
1397
1483
  // If the date is specified in the format "today" or "today + 2" or "today-1"
1398
1484
  // it will need some pre-processing.
1399
- if (set_to.indexOf(`today`) !== -1) {
1485
+ if (answer.indexOf(`today`) !== -1) {
1400
1486
  // Find out how many days to add/subtract
1401
- let date_delta = set_to.replace(/\s/g, '');
1487
+ let date_delta = answer.replace(/\s/g, '');
1402
1488
  date_delta = date_delta.match(/today([+-]\d+)?/)[1];
1403
1489
  date_delta = date_delta == undefined ? 0 : date_delta;
1404
1490
  date_delta = parseInt(date_delta);
1405
1491
  // Get today's date
1406
- set_to_date = new Date();
1492
+ answer_date = new Date();
1407
1493
  // Add/subtract required number of days
1408
- set_to_date.setDate(set_to_date.getDate() + date_delta);
1494
+ answer_date.setDate(answer_date.getDate() + date_delta);
1409
1495
  // Convert to the docassemble date field format: mm/dd/yyyy
1410
1496
  // Reference: https://gomakethings.com/setting-a-date-input-to-todays-date-with-vanilla-js/
1411
- set_to_date = ((set_to_date.getMonth() + 1).toString().padStart(2, 0) + '/' +
1412
- set_to_date.getDate().toString().padStart(2, 0) + '/' +
1413
- 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());
1414
1500
  }
1415
- await scope.setText( scope, { handle, set_to: set_to_date });
1501
+ await scope.setText( scope, { handle, answer: answer_date });
1416
1502
  await scope.afterStep(scope, { waitForShowIf: true });
1417
1503
  } else {
1418
- await scope.setText( scope, { handle, set_to });
1504
+ await scope.setText( scope, { handle, answer });
1419
1505
  await scope.afterStep(scope, { waitForShowIf: true });
1420
1506
  }
1421
- }, // Ends scope.setField.input()
1422
- },
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
1423
1519
 
1424
- uploadFiles: async function ( scope, { handle, set_to }) {
1520
+ uploadFiles: async function ( scope, { handle, answer }) {
1425
1521
  /* Try to upload one or more files to a file-type input field. */
1426
1522
 
1427
1523
  // Tests could be in one of two places. Figure out which directory they're in
@@ -1454,7 +1550,7 @@ module.exports = {
1454
1550
  }
1455
1551
 
1456
1552
  // Allow a typo where someone forgot to include a space after a comma
1457
- let file_names = set_to.split(`,`);
1553
+ let file_names = answer.split(`,`);
1458
1554
 
1459
1555
  // If a file exist, add it to a list of files to be uploaded. If it doesn't, add a warning.
1460
1556
  let paths = [];
@@ -1476,8 +1572,8 @@ module.exports = {
1476
1572
  }
1477
1573
 
1478
1574
  if ( paths.length === 0 ) {
1479
- await scope.addToReport( scope, { type: `error`, value: `Could not find "${set_to}".` });
1480
- 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}".`)
1481
1577
  }
1482
1578
  // Prepare to wait for upload to complete
1483
1579
  let name = await handle.evaluate( elem => { return elem.getAttribute(`name`); });
@@ -1502,20 +1598,20 @@ module.exports = {
1502
1598
 
1503
1599
  }, // Ends scope.uploadFiles()
1504
1600
 
1505
- setCheckbox: async function ( scope, { label, set_to }) {
1601
+ setCheckbox: async function ( scope, { label, answer }) {
1506
1602
  // Depending on the current value/status of a checkbox and the desired
1507
1603
  // value to set, either taps the checkbox or leaves it alone.
1508
1604
  let status = await label.evaluate( elem => { return elem.getAttribute('aria-checked'); });
1509
- if ( set_to.toLowerCase() !== status ) {
1605
+ if ( answer.toLowerCase() !== status ) {
1510
1606
  await label.evaluate( elem => { return elem.click(); });
1511
1607
  }
1512
1608
  }, // Ends scope.setCheckbox()
1513
1609
 
1514
- setText: async function ( scope, { handle, set_to }) {
1610
+ setText: async function ( scope, { handle, answer }) {
1515
1611
  // Set text in some kind of field (input text, input date, textarea, etc.)
1516
1612
  await handle.evaluate( el => { el.value = '' });
1517
1613
  await handle.focus();
1518
- await handle.type( set_to );
1614
+ await handle.type( answer );
1519
1615
  setTimeout(async () => {
1520
1616
  }, 500)
1521
1617
  },
@@ -1562,7 +1658,7 @@ module.exports = {
1562
1658
  }, // Ends scope.examinePageID()
1563
1659
 
1564
1660
  // TODO: Rename var_data to input_data?
1565
- // Note: Totally different from `setField` (which is singular)
1661
+ // Note: Totally different from `enter_answer` (which is singular)
1566
1662
  // TODO: If a step triggers this and is not able to set a value, should it error?
1567
1663
  // TODO: Rename to setFieldsOfOnePage? setPageFields?
1568
1664
  setFields: async function ( scope, {
@@ -1691,7 +1787,8 @@ module.exports = {
1691
1787
  }
1692
1788
  }
1693
1789
  if ( num_unused_rows > 0 ) {
1694
- 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}"`;
1695
1792
  await scope.addToReport(scope, { type: `error`, value: msg });
1696
1793
  expect( num_unused_rows, msg ).to.equal( 0 );
1697
1794
  }
@@ -1971,7 +2068,7 @@ module.exports = {
1971
2068
  await scope.afterStep(scope);
1972
2069
  }, // Ends link_works()
1973
2070
 
1974
- set_regular_var: async ( scope, var_name, set_to ) => {
2071
+ set_regular_var: async ( scope, var_name, answer ) => {
1975
2072
  /** Set a non-story table variable (with or without a choice associated with it) to a value. Set:
1976
2073
  * - Buttons associated with variables
1977
2074
  * - Dropdowns
@@ -1982,23 +2079,23 @@ module.exports = {
1982
2079
  */
1983
2080
  // Let kiln know not to test against trigger variable values, even for proxy vars
1984
2081
  let var_data = await scope.normalizeTable( scope, {
1985
- 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 }],
1986
2083
  });
1987
2084
  // Don't continue if the variable doesn't cause navigation itself. Also,
1988
2085
  // if this variable cannot be set on this page, trigger an error
1989
2086
  await scope.setFields( scope, { var_data, ensure_navigation: false, ensure_all_vars_used: true });
1990
2087
  }, // Ends set_regular_var()
1991
2088
 
1992
- set_secret_var: async ( scope, var_name, set_to_env_name ) => {
2089
+ set_secret_var: async ( scope, var_name, answer_env_name ) => {
1993
2090
  /** Sets a non-story table variable to a "secret" value (i.e. an env var) */
1994
2091
  // Prevent pictures of a screen with a secret.
1995
2092
  scope.disable_error_screenshot = true;
1996
2093
  let var_data = await scope.normalizeTable( scope, {
1997
2094
  var_data: [{
1998
2095
  var: var_name,
1999
- value: set_to_env_name,
2096
+ value: answer_env_name,
2000
2097
  trigger: scope.trigger_not_needed_flag,
2001
- flags: {secret_name: set_to_env_name}
2098
+ flags: {secret_name: answer_env_name}
2002
2099
  }],
2003
2100
  });
2004
2101
  // Don't continue if the variable doesn't cause navigation itself. Also,
@@ -2170,10 +2267,235 @@ module.exports = {
2170
2267
  });
2171
2268
 
2172
2269
  await scope.afterStep(scope, {waitForShowIf: false});
2173
- }, // 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()
2174
2299
 
2175
2300
  }, // ends scope.steps
2176
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
+
2177
2499
 
2178
2500
  //#####################################
2179
2501
  //#####################################
package/lib/steps.js CHANGED
@@ -458,7 +458,7 @@ Then(/I take a screenshot ?(?:named "([^"]+)")?/, { timeout: -1 }, async ( name
458
458
  /* Here to make writing tests more comfortable. */
459
459
  When("I do nothing", async () => { await scope.afterStep(scope); }); // √
460
460
 
461
- Then('the question id should be {string}', { timeout: -1 }, async ( question_id ) => {
461
+ Then(/the (?:question|page|screen) id should be "([^"]+)"/i, { timeout: -1 }, async ( question_id ) => {
462
462
  /* Looks for a sanitized version of the question id as it's written
463
463
  * in the .yml. docassemble's way */
464
464
  let { question_has_id, id, id_matches } = await scope.examinePageID( scope, question_id );
@@ -780,10 +780,107 @@ When(/I check all pages for (?:accessibility|a11y) issues/i, {timeout: -1}, asyn
780
780
  // UI element interaction
781
781
  //#####################################
782
782
 
783
+ // -------------------------------------
784
+ // --- RANDOM INPUT ---
785
+ When(/I answer randomly for at most ([0-9]+) (?:page(?:s)?|screen(?:s)?)/, { timeout: -1 }, async ( max_pages_str ) => {
786
+ /** Give random answers until the user can't continue any more.
787
+ *
788
+ * Variations:
789
+ * I answer randomly for at most 1 page
790
+ * I answer randomly for at most 10 pages
791
+ * I answer randomly for at most 101 screens
792
+ */
793
+ scope.paths.random_screenshots = `${ scope.paths.scenario }/random_answers_screenshots`;
794
+ fs.mkdirSync( scope.paths.random_screenshots );
795
+
796
+ // Force the dev to prevent infinite loops
797
+ let max_pages = parseInt( max_pages_str );
798
+ let curr_page_num = 0;
799
+
800
+ // While a continue button is on the current page, fill out
801
+ // the fields and then try to continue
802
+ // let continue_exists = true;
803
+ // while ( continue_exists && curr_page_num < max_pages ) {
804
+ let buttons_exist = true;
805
+ while ( buttons_exist && curr_page_num < max_pages ) {
806
+
807
+ curr_page_num = curr_page_num + 1;
808
+ let { id } = await scope.examinePageID( scope, `no target id` );
809
+
810
+ // Ensure no infinite loop, but each page has a full timeout to run
811
+ let custom_timeout = new Promise(async function( resolve, reject ) {
812
+
813
+ // Set up timeout to stop infinite loops
814
+ let page_timeout_id = setTimeout(async function() {
815
+
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 );
820
+
821
+ }, scope.timeout);
822
+
823
+ // self-invoke an anonymous async function so we don't have to
824
+ // abstract the details of resolving and rejecting.
825
+ (async function() {
826
+ try {
827
+
828
+ // Set all the fields on the page
829
+ let buttons = await scope.steps.set_random_page_vars( scope );
830
+
831
+ // Take a screenshot of the page before continuing or ending
832
+ await scope.page.screenshot({
833
+ path: `${ scope.paths.random_screenshots }/${ Date.now() }.jpg`,
834
+ type: `jpeg`,
835
+ fullPage: true
836
+ });
837
+
838
+ // Prep for the next loop to possibly stop. Signature page buttons
839
+ // don't count as buttons in the way our field detects them
840
+ buttons_exist = buttons.length > 0;
841
+ // If there were any buttons, pick a random one to try to press
842
+ if ( buttons_exist ) {
843
+ await scope.set_random_input_for.buttons( scope, { fields: buttons });
844
+ } else {
845
+ // If can theoretically continue, try to. Also prep to
846
+ // stop or continue for the next loop
847
+ buttons_exist = await scope.continue_exists( scope );
848
+ if ( buttons_exist ) {
849
+ await scope.continue( scope );
850
+ } // ends if continue exists
851
+ }
852
+
853
+ if ( buttons_exist ) {
854
+ process.stdout.write(`\x1b[36m${ 'v' }\x1b[0m`); // pressed button
855
+ }
856
+
857
+ resolve();
858
+
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 );
864
+
865
+ } finally {
866
+ // prevent temporary hang at end of tests
867
+ clearTimeout( page_timeout_id );
868
+ }
869
+ })();
870
+
871
+ }); // ends custom_timeout for set_random_page_vars
872
+
873
+ await custom_timeout;
874
+
875
+ } // ends while continue_exists
876
+
877
+
878
+ }); // Ends random input
879
+
783
880
  // -------------------------------------
784
881
  // --- VARIABLES-BASED STEPS ---
785
882
 
786
- When(/I set the var(?:iable)? "([^"]+)" to "([^"]+)"/, { timeout: -1 }, async ( var_name, set_to ) => {
883
+ When(/I set the var(?:iable)? "([^"]+)" to "([^"]+)"/, { timeout: -1 }, async ( var_name, answer ) => {
787
884
  /* Set a non-story table variable (with or without a choice associated with it) to a value. Set:
788
885
  * - Buttons associated with variables
789
886
  * - Dropdowns
@@ -797,7 +894,7 @@ When(/I set the var(?:iable)? "([^"]+)" to "([^"]+)"/, { timeout: -1 }, async (
797
894
  * 1. I set the var "rent_interval" to "weekly"
798
895
  */
799
896
  return wrapPromiseWithTimeout(
800
- scope.steps.set_regular_var( scope, var_name, set_to ),
897
+ scope.steps.set_regular_var( scope, var_name, answer ),
801
898
  scope.timeout
802
899
  );
803
900
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suffolklitlab/alkiln",
3
- "version": "4.6.2",
3
+ "version": "4.7.0",
4
4
  "description": "Integrated automated end-to-end testing with docassemble, puppeteer, and cucumber.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -37,6 +37,7 @@
37
37
  "dependencies": {
38
38
  "@axe-core/puppeteer": "4.4.2",
39
39
  "@cucumber/cucumber": "7.3.2",
40
+ "@faker-js/faker": "^7.3.0",
40
41
  "axios": "0.24.0",
41
42
  "chai": "4.2.0",
42
43
  "cheerio": "1.0.0-rc.10",
@@ -131,4 +131,9 @@ Scenario: tap element with an error
131
131
  is there a typo?
132
132
  """
133
133
  Given I start the interview at "test_taps"
134
- And I tap the "Tests-not_there-tab" tab
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
@@ -0,0 +1,15 @@
1
+ @random_input
2
+ Feature: Assembly Line package-specific Steps
3
+
4
+ # In tag names, 'ri' is for 'random input'
5
+
6
+ @slow @ri1 @random
7
+ Scenario: I fill in random input till the end
8
+   Given I start the interview at "test_random_input"
9
+ And I answer randomly for at most 20 pages
10
+ Then the question id should be "end"
11
+
12
+ @slow @ri2 @random
13
+ Scenario: I fill in random input for 1 page
14
+   Given I start the interview at "test_random_input"
15
+ And I answer randomly for at most 1 page