@suffolklitlab/alkiln 5.12.0 → 5.13.0-logs1

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.
Files changed (38) hide show
  1. package/.env.example +29 -0
  2. package/.github/workflows/github_server.yml +59 -12
  3. package/.github/workflows/playground.yml +11 -4
  4. package/CHANGELOG.md +41 -1
  5. package/CONTRIBUTING.md +98 -83
  6. package/README.md +1 -1
  7. package/action.yml +67 -29
  8. package/action_for_github_server/action.yml +59 -31
  9. package/docs/decisions/006_which_log_level_to_pick_2024_09_5.md +46 -0
  10. package/docs/decisions/adopt_architectural_decision_records.md +58 -0
  11. package/docs/decisions/detect_sign_in.md +124 -0
  12. package/docs/decisions/pdfjs_version_2024_08_08.md +18 -0
  13. package/docs/decisions/reports_and_logs_2024_08_11.md +141 -0
  14. package/docs/decisions/update_puppeteer_2024_07_28.md +62 -0
  15. package/docs/story_tables.md +1 -1
  16. package/lib/docassemble/docassemble_api_REST.js +67 -1
  17. package/lib/docassemble/docassemble_api_interface.js +225 -18
  18. package/lib/docassemble/server_install.js +23 -9
  19. package/lib/docassemble/setup.js +33 -20
  20. package/lib/docassemble/takedown.js +27 -7
  21. package/lib/run_cucumber.js +83 -33
  22. package/lib/scope.js +222 -82
  23. package/lib/steps.js +206 -62
  24. package/lib/utils/files.js +8 -6
  25. package/lib/utils/log.js +477 -84
  26. package/lib/utils/reports.js +14 -5
  27. package/lib/utils/session_vars.js +25 -18
  28. package/lib/utils/time.js +44 -0
  29. package/npm-shrinkwrap.json +635 -193
  30. package/package.json +8 -8
  31. package/setup.py +1 -1
  32. package/tests/log_codes/check_codes.sh +163 -108
  33. package/tests/log_codes/log_code_expected_instances.txt +213 -0
  34. package/tests/unit_tests/getAllFields.test.js +5 -0
  35. package/tests/unit_tests/getMatchingRows.test.js +5 -0
  36. package/tests/unit_tests/log.test.js +20 -0
  37. package/tests/unit_tests/normalizeTable.test.js +5 -0
  38. package/tests/log_codes/deleted_codes.json +0 -8
package/lib/scope.js CHANGED
@@ -13,14 +13,13 @@ const pdf_reader = require('./utils/pdf_reader');
13
13
  const session_vars = require('./utils/session_vars');
14
14
  const get_base_interview_url = require(`./docassemble/get_base_interview_url`);
15
15
  const time = require('./utils/time');
16
- const log = require('./utils/log');
16
+ // const log = require('./utils/log');
17
17
  const { waitForTimeout } = require('./utils/time');
18
18
  const files = require('./utils/files' );
19
- const server = require('./docassemble/docassemble_api_interface');
19
+ const da_api = require('./docassemble/docassemble_api_interface');
20
20
  const wrapPromiseWithTimeout = require('./cucumber_8_shim');
21
21
  const reports = require(`./utils/reports`);
22
22
 
23
-
24
23
  let ordinal_to_integer = {
25
24
  only: 0, first: 0, second: 1, third: 2, fourth: 3, fifth: 4,
26
25
  sixth: 5, seventh: 6, eighth: 7, ninth: 8, tenth: 9,
@@ -84,10 +83,16 @@ let toBase64 = function ( utf8_str, exclude_padding=true) {
84
83
 
85
84
  let proxies_regex = /(\bx\.)|(\bx\[)|(\[[ijklmn]\])/g;
86
85
  let da_base64_regex = /^[A-Za-z0-9+/]+$/;
86
+ let log = null;
87
87
 
88
88
  module.exports = {
89
89
  trigger_not_needed_flag: `ALKiln: no trigger variable needed`,
90
90
 
91
+
92
+ set_log: function ({ log_obj }) {
93
+ log = log_obj;
94
+ },
95
+
91
96
  getSafeScenarioBaseFilename: async function( scope, { scenario }) {
92
97
  /** Return a string that's safe to be a filename. Include the Scenario
93
98
  * description and tags.
@@ -171,7 +176,7 @@ module.exports = {
171
176
  // If something might be hidden or shown, wait extra time to let it hide or show
172
177
  // I think `.$()` does not use a timeout
173
178
  let showif = await scope.page.$('.dashowif');
174
- if ( showif ) { await scope.page.waitForTimeout( scope.showif_timeout ); }
179
+ if ( showif ) { await time.waitForTimeout( scope.showif_timeout ); }
175
180
  },
176
181
 
177
182
  waitForTab: async function waitForTab(scope, { tab_id }) {
@@ -288,9 +293,7 @@ module.exports = {
288
293
  /** Tap a given element, detect navigation, and do appropriate related things.
289
294
  * `click_promise can be any kind of Promise, including a Promise.all()
290
295
  */
291
- let msg = `Cannot find the element to tap.`;
292
- if ( !elem ) { reports.addToReport(scope, { type: `error`, code: `ALK0102`, value: msg }); }
293
- expect( elem, msg ).to.exist;
296
+ await scope.guard_against_missing_tap_element(scope, { elem });
294
297
 
295
298
  // Get the text on the button or link for a potential error message later
296
299
  let tapperText = await (await elem.getProperty('textContent')).jsonValue();
@@ -326,6 +329,14 @@ module.exports = {
326
329
  } // ends try/catch
327
330
  }, // Ends scope.tapElementAndStayOnPage()
328
331
 
332
+ guard_against_missing_tap_element: async function(scope, { elem }) {
333
+ /** Handle the error message for a missing tap element, if it's missing. */
334
+ let msg = `Cannot find the element to tap.`;
335
+ if ( !elem ) { reports.addToReport(scope, { type: `error`, code: `ALK0102`, value: msg }); }
336
+ expect( elem, msg ).to.exist;
337
+ return elem;
338
+ },
339
+
329
340
  // Handle server reload errors
330
341
  server_statuses: [],
331
342
  server_check_interval_ms: 1 * 1000,
@@ -339,7 +350,7 @@ module.exports = {
339
350
  // This code assumes a reasonable response time would be n seconds if the
340
351
  // server is up and that a server reload/restart won't take less than n seconds.
341
352
  // Also, it's non-blocking, so it doesn't slow down tests
342
- let is_up = await server.is_server_responding({ timeout: scope.server_check_interval_ms });
353
+ let is_up = await da_api.is_server_responding({ timeout: scope.server_check_interval_ms });
343
354
  // Add status to the beginning of the list to make truncating easier
344
355
  scope.server_statuses.unshift( is_up );
345
356
 
@@ -501,7 +512,7 @@ module.exports = {
501
512
  if ( diff <= 0 ) { return false; }
502
513
 
503
514
  // If time hasn't expired, wait and then check again
504
- await scope.page.waitForTimeout( 100 );
515
+ await time.waitForTimeout( 100 );
505
516
  await scope.detectDownloadComplete(scope, endTime);
506
517
 
507
518
  return false;
@@ -627,7 +638,7 @@ module.exports = {
627
638
  interview_url = `${ base_url }${ file_name }`;
628
639
  }
629
640
 
630
- reports.addToReport(scope,{
641
+ reports.addToReport(scope, {
631
642
  code: `ALK0113`,
632
643
  type: `row info`,
633
644
  value: `Trying to load the interview at "${ interview_url }"`
@@ -638,7 +649,17 @@ module.exports = {
638
649
  // TODO: implement and use scope.handle_possible_timeout()
639
650
  try {
640
651
 
641
- await scope.page.goto(interview_url, { waitUntil: 'domcontentloaded', timeout: scope.timeout })
652
+ await scope.page.goto(interview_url, { waitUntil: 'domcontentloaded', timeout: scope.timeout });
653
+ // Get interview session values, if possible, so we can delete the session later
654
+ let interview_vars = await scope.page.evaluate(async function() {
655
+ try {
656
+ // won't exist on non-da interview pages
657
+ return await get_interview_variables();
658
+ } catch (error) {
659
+ return null;
660
+ }
661
+ });
662
+ scope.set_interview_session_data( scope, { vars: interview_vars });
642
663
 
643
664
  } catch ( error ) {
644
665
 
@@ -676,12 +697,42 @@ module.exports = {
676
697
  });
677
698
  expect( load_result.error ).to.equal( '' );
678
699
  } else {
679
- reports.addToReport(scope, { type: `row info`, code: `ALK0117`, value: `Successfully found the interview at "${ interview_url }"` });
700
+ reports.addToReport(scope, { type: `row info`, code: `ALK0117`, value: `Successfully found the interview` });
680
701
  }
681
702
 
682
703
  return scope.page;
683
704
  }, // Ends scope.load()
684
705
 
706
+ set_interview_session_data: function ( scope, { vars = null }) {
707
+ /** Sets this interview's session data for this scenario if it doesn't
708
+ * already exist.
709
+ *
710
+ * @returns final_vars { object | null }
711
+ * @returns final_vars.i { str } File path of interview
712
+ * @returns final_vars.uid { str } UID of interview
713
+ * */
714
+ if ( vars && vars.i ) {
715
+ let scenario = scope.scenarios.get( scope.scenario_id );
716
+ if ( !scenario.interviews ) { scenario.interviews = {}; }
717
+ if ( !scenario.interviews[ vars.uid ] ) {
718
+ scenario.interviews[ vars.uid ] = vars;
719
+ log.debug({ code: `ALK0185`, context: `session_id_etc` },
720
+ `Saved new interview data. File path: ${ vars.i }. Session id: ${ vars.uid }.`
721
+ );
722
+ } else {
723
+ log.debug({ code: `ALK0186`, context: `session_id_etc` },
724
+ `Interview data already existed. File path: ${ vars.i }. Session id: ${ vars.uid }.`
725
+ );
726
+ }
727
+ return vars;
728
+ } else {
729
+ log.debug({ code: `ALK0187`, context: `session_id_etc` },
730
+ `Found no interview data.`
731
+ );
732
+ return null;
733
+ }
734
+ }, // Ends scope.set_interview_session_data()
735
+
685
736
  getLoadData: async function ( scope ) {
686
737
  /* Return elements that could load on the page, like #daMainQuestion
687
738
  * and #da-retry. If there's a system error on the page, return its main text.
@@ -771,7 +822,7 @@ module.exports = {
771
822
  if ( row_raw.length > 3 ) { more_or_less = `less`; }
772
823
  let bad_table_msg = `Your Story Table should have ${ more_or_less } than`
773
824
  + ` ${ row_raw.length } columns. Take another look at Story Table documentation at`
774
- + ` https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/alkiln/#story-tables.`
825
+ + ` https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/alkiln/writing#story-table.`
775
826
  reports.addToReport( scope, { type: `error`, code: `ALK0120`, value: bad_table_msg})
776
827
  throw Error(bad_table_msg);
777
828
  }
@@ -798,7 +849,7 @@ module.exports = {
798
849
  let not_in_env_msg = `ALKiln could not find the GitHub secret "${ row.flags.secret_name }".`
799
850
  + ` Check for a typo and check your workflow file.`
800
851
  + ` Read more about using GitHub secrets in your workflow file at`
801
- + ` https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/alkiln/#github-secrets.`
852
+ + ` https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/alkiln/writing#secrets.`
802
853
  reports.addToReport( scope, { type: `error`, code: `ALK0121`, value: not_in_env_msg });
803
854
  throw ReferenceError(not_in_env_msg);
804
855
  }
@@ -824,11 +875,9 @@ module.exports = {
824
875
  supported_table.push( result );
825
876
  } // ends for every row
826
877
 
827
- log.debug({
828
- code: `ALK0122`,
829
- type: `info`,
830
- pre: `scope.normalizedTable():\n ${JSON.stringify( supported_table )}`
831
- });
878
+ log.debug({ code: `ALK0122`, context: `table`, },
879
+ `scope.normalizedTable():`, supported_table
880
+ );
832
881
 
833
882
  return supported_table;
834
883
  }, // Ends scope.normalizeTable()
@@ -978,11 +1027,9 @@ module.exports = {
978
1027
  fields.push( field_data );
979
1028
  } // ends for every node
980
1029
 
981
- log.debug({
982
- code: `ALK0124`,
983
- type: `info`,
984
- pre: `page fields:\n${ JSON.stringify( fields )}`
985
- });
1030
+ log.debug({ code: `ALK0124`, context: `page fields`, },
1031
+ fields
1032
+ );
986
1033
  return fields;
987
1034
  }, // Ends scope.getAllFields()
988
1035
 
@@ -1032,11 +1079,9 @@ module.exports = {
1032
1079
  /* Return variable names and values combined in ways that can
1033
1080
  * try to match table rows. Tables can have two properties:
1034
1081
  * variable name and value. */
1035
- log.debug({
1036
- code: `ALK0126`,
1037
- type: `info`,
1038
- pre: `scope.getPossibleTableRowMatchers() var_names: ${JSON.stringify(var_names)} values: ${JSON.stringify(values)}`
1039
- });
1082
+ log.debug({ code: `ALK0126`, context: `matching`, },
1083
+ `scope.getPossibleTableRowMatchers() var_names:`, var_names, `values:`, values
1084
+ );
1040
1085
  let possible_table_rows = [];
1041
1086
  for ( let var_name of var_names ) {
1042
1087
  // Inputs like textareas don't have values
@@ -1317,6 +1362,7 @@ module.exports = {
1317
1362
  * setting fields, and an object for a DOM field, returns list of the
1318
1363
  * rows that match the DOM field. That list of story table rows may be empty.
1319
1364
  *
1365
+ * Using a `trigger` column is deprecated, but still used in many tests in the wild.
1320
1366
  * For notes on trigger var, see https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/alkiln/#trigger
1321
1367
  *
1322
1368
  * @param var_data {arr} - fields and the values they can be set to
@@ -1411,9 +1457,13 @@ module.exports = {
1411
1457
 
1412
1458
  // field/page trigger name is needed but is missing.
1413
1459
  if ( !page_trigger_name ) {
1414
- let page_trigger_warning = `A field on this page uses an index `
1415
- + `variable or a generic object, but the page is missing `
1416
- + `the ALKiln "trigger" variable's name. See `
1460
+ let page_trigger_warning = `You are using a 3-column Story Table. If possible, you should `
1461
+ + `think about switching to the newer, easier, 2-column Story Table. That will probably `
1462
+ + `get rid of this warning. Read more at `
1463
+ + `https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/alkiln/trouble#deprecated-story-tables. `
1464
+ + `If you still want to use this 3-column format, here is more info about the warning: `
1465
+ + `A field on this page uses an index variable or a generic object in a 3-column `
1466
+ + `Story Table, but the page is missing the ALKiln "trigger" variable's name. See `
1417
1467
  + `https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/alkiln/#a-missing-trigger-variable.`;
1418
1468
  reports.addToReport(scope, { type: `warning`, code: `ALK0127`, value: page_trigger_warning });
1419
1469
  }
@@ -1430,7 +1480,7 @@ module.exports = {
1430
1480
 
1431
1481
  // First, make the row data more readable to humans.
1432
1482
  let row_as_story_table = reports.convertToOriginalStoryTableRow( scope, { row_data: row_that_matches_this_field });
1433
- let row_trigger_warning = `This Story Table row needs a value in the "trigger" `
1483
+ let row_trigger_warning = `This 3-column Story Table row needs a value in the "trigger" `
1434
1484
  + `column, but it is empty. The test might get unexpected results. See `
1435
1485
  + `https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/alkiln/#trigger.`
1436
1486
  + `\nThe rows:\n${ row_as_story_table }`;
@@ -1467,7 +1517,7 @@ module.exports = {
1467
1517
 
1468
1518
  let all_trigger_names_conflict_msg = `${ rows_that_match_field_names.length } `
1469
1519
  + `Story Table row(s) matched a field name, but didn't match a trigger `
1470
- + `variable. The Story Table might be missng a row or a trigger variable. `
1520
+ + `variable. This 3-column Story Table might be missing a row or a trigger variable. `
1471
1521
  + `The test will have to ignore this field. See `
1472
1522
  + `https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/alkiln/#trigger.`
1473
1523
  + `\nThe row(s) data:\n${ conflicts_as_story_table_rows.join( `\n` )}`
@@ -1506,17 +1556,13 @@ module.exports = {
1506
1556
  reports.addToReport(scope, { type: `warning`, code: `ALK0130`, value: multiple_matches_msg });
1507
1557
  }
1508
1558
 
1509
- log.debug({
1510
- code: `ALK0131`,
1511
- type: `info`,
1512
- pre: `final_matching_rows:\n${JSON.stringify( final_matching_rows )}`
1513
- });
1559
+ log.debug({ code: `ALK0131`, context: `matching`, },
1560
+ `final_matching_rows:`, final_matching_rows
1561
+ );
1514
1562
  if ( final_matching_rows.length == 0) {
1515
- log.debug({
1516
- code: `ALK0132`,
1517
- type: `info`,
1518
- pre: `field:\n${JSON.stringify( field )}`
1519
- });
1563
+ log.debug({ code: `ALK0132`, context: `matching`, },
1564
+ `0 matching rows. Field:`, field
1565
+ );
1520
1566
  }
1521
1567
 
1522
1568
  return final_matching_rows;
@@ -1582,7 +1628,7 @@ module.exports = {
1582
1628
  catch(err){ elem.style.background = 'Khaki'; }
1583
1629
  return elem.getAttribute('for') || elem.getAttribute('name');
1584
1630
  });
1585
- await scope.page.waitForTimeout( 400 );
1631
+ await time.waitForTimeout( 400 );
1586
1632
  }
1587
1633
 
1588
1634
  // We use the last match. We wrote a warning for the user if there were
@@ -1602,11 +1648,9 @@ module.exports = {
1602
1648
 
1603
1649
  get_handle_from_field: async function ( scope, { field }) {
1604
1650
  /** Get the element's puppeteer handle using the field's selector. */
1605
- log.debug({
1606
- code: `ALK0133`,
1607
- type: `info`,
1608
- pre: `scope.get_handle_from_field() field.selector: ${JSON.stringify(field.selector)}`
1609
- });
1651
+ log.debug({ code: `ALK0133`, },
1652
+ `scope.get_handle_from_field() field.selector:`, field.selector
1653
+ );
1610
1654
  let handle = await scope.page.evaluateHandle(( selector ) => {
1611
1655
  let elem = document.querySelector( selector );
1612
1656
  // If the element has a sibling that's a label, we need to
@@ -1805,7 +1849,7 @@ module.exports = {
1805
1849
  });
1806
1850
  if ( type === `radio` || type === `checkbox` ) {
1807
1851
 
1808
- let [label] = await handle.$x(`following-sibling::label`);
1852
+ let [label] = await handle.$$(`xpath/.//following-sibling::label`);
1809
1853
 
1810
1854
  if ( session_vars.get_debug() ) {
1811
1855
  if ( answer.toLowerCase() === 'false' ) { await label.evaluate( elem => { elem.style.background = 'tomato'; }); }
@@ -2118,7 +2162,9 @@ module.exports = {
2118
2162
  * @param ensure_all_vars_used {bool} [false] = Default false. If true, errors if any
2119
2163
  * object in var_data is not used during running of this function.
2120
2164
  */
2121
- log.debug({ type: `plain`, pre: `~~~~~~~~~~~~~~ scope.setFields() ~~~~~~~~~~~~~~` });
2165
+ log.debug({ code: `ALK0221`, before: `~~~~~~~~~~~~~~\n`, },
2166
+ `scope.setFields()`
2167
+ );
2122
2168
 
2123
2169
  // Only record the page id once for each page to which we navigate
2124
2170
  let { id } = await scope.examinePageID( scope, 'none to match' );
@@ -2162,13 +2208,12 @@ module.exports = {
2162
2208
  // Accumulate number of times this row was used. Esp important for `.target_number`
2163
2209
  await scope.increment_row_use( scope, { row: row_used });
2164
2210
 
2165
- process.stdout.write(`\x1b[36m${ '*' }\x1b[0m`); // assumes var was set if no error occurred
2166
-
2211
+ log.stdout({}, `\x1b[36m${ '*' }\x1b[0m`); // assumes var was set if no error occurred
2167
2212
  }
2168
2213
  } // ends for every non-submit-button field on the page
2169
2214
  } // ends while fields are still being set
2170
2215
 
2171
- if ( session_vars.get_debug() ) { await scope.page.waitForTimeout(2000); }
2216
+ if ( session_vars.get_debug() ) { await time.waitForTimeout(2000); }
2172
2217
 
2173
2218
  // Press a continue button with a story table variable name if it exists
2174
2219
  let did_navigate = false;
@@ -2208,7 +2253,7 @@ module.exports = {
2208
2253
  // }
2209
2254
 
2210
2255
  did_navigate = true;
2211
- process.stdout.write(`\x1b[36m${ 'v' }\x1b[0m`); // pressed button successfully
2256
+ log.stdout({}, `\x1b[36m${ 'v' }\x1b[0m`); // pressed button successfully
2212
2257
  break;
2213
2258
 
2214
2259
  } // ends if row_used
@@ -2255,7 +2300,7 @@ module.exports = {
2255
2300
  // }
2256
2301
 
2257
2302
  did_navigate = true;
2258
- process.stdout.write(`\x1b[36m${ '>' }\x1b[0m`); // continued successfully
2303
+ log.stdout({}, `\x1b[36m${ '>' }\x1b[0m`); // continued successfully
2259
2304
  } // ends if !did_navigate && ensure_navigation
2260
2305
 
2261
2306
  if ( did_navigate && scope.check_all_for_a11y ) {
@@ -2516,7 +2561,7 @@ module.exports = {
2516
2561
 
2517
2562
  // Attempt to solve weird race condition issues where page has not properly continued
2518
2563
  while ( curr_url === prev_url && !error.was_found && !timedOut ) {
2519
- await scope.page.waitForTimeout( 50 );
2564
+ await time.waitForTimeout( 50 );
2520
2565
  curr_url = await scope.page.url();
2521
2566
  error = await scope.checkForError( scope );
2522
2567
  }
@@ -2537,10 +2582,10 @@ module.exports = {
2537
2582
 
2538
2583
  // All results are lists to match `$x`
2539
2584
  let possible_errors = {
2540
- complex_user_error: await scope.page.$$('.alert-danger'),
2585
+ complex_user_error: await scope.page.$$(`.alert-danger`),
2541
2586
  // The below does not work if there's not a space between ':' and 'none'
2542
- simple_user_error: await scope.page.$x(`//*[contains(@class, "da-has-error")][not(contains(@style,"display: none"))]`),
2543
- system_error: await scope.page.$x( '//meta[@content="docassemble: Error"]' ),
2587
+ simple_user_error: await scope.page.$$(`xpath/.//*[contains(@class, "da-has-error")][not(contains(@style,"display: none"))]`),
2588
+ system_error: await scope.page.$$(`xpath/.//meta[@content="docassemble: Error"]` ),
2544
2589
  };
2545
2590
 
2546
2591
  let error_handlers = {};
@@ -2552,7 +2597,7 @@ module.exports = {
2552
2597
  // Also, if you're debugging and you close the window early, the process will still
2553
2598
  // take this long to exit after the tests finish. Not sure how to account for that.
2554
2599
  if ( session_vars.get_debug() ) {
2555
- await scope.page.waitForTimeout( ( scope.timeout - (1 * 1000)) );
2600
+ await time.waitForTimeout( ( scope.timeout - (1 * 1000)) );
2556
2601
  }
2557
2602
  was_found = true;
2558
2603
  error_handlers[ error_key ] = possible_errors[ error_key ][0];
@@ -2657,7 +2702,7 @@ module.exports = {
2657
2702
  }, // Ends scope.steps.add_json_vars_to_report()
2658
2703
 
2659
2704
  link_works: async ( scope, linkText ) => {
2660
- let [link] = await scope.page.$x(`//a[contains(text(), "${ linkText }")]`);
2705
+ let [link] = await scope.page.$$(`xpath/.//a[contains(text(), "${ linkText }")]`);
2661
2706
  let prop_obj = await link.getProperty( 'href' );
2662
2707
  let actual_url = await prop_obj.jsonValue();
2663
2708
 
@@ -2715,13 +2760,13 @@ module.exports = {
2715
2760
  /** Tap a link that doesn't navigate. Depends on the language of the text.
2716
2761
  * TODO: This may be broken at this point.
2717
2762
  * */
2718
- const [link] = await scope.page.$x(`//a[contains(text(), "${phrase}")]`);
2763
+ const [link] = await scope.page.$$(`xpath/.//a[contains(text(), "${phrase}")]`);
2719
2764
 
2720
2765
  let msg = `The term "${ phrase }" seems to be missing.`;
2721
2766
  if ( !link ) { reports.addToReport(scope, { type: `error`, code: `ALK0151`, value: msg }); }
2722
2767
  expect( link, msg ).to.exist;
2723
2768
  await scope.tapElementAndStayOnPage(scope, { elem: link });
2724
- await scope.page.waitForTimeout(10000);
2769
+ await time.waitForTimeout(10000);
2725
2770
  }, // Ends scope.steps.tap_term()
2726
2771
 
2727
2772
  sign: async ( scope, name ) => {
@@ -2740,7 +2785,7 @@ module.exports = {
2740
2785
  * TODO: Properly wait for download to complete. See notes in
2741
2786
  * scope.js scope.detectDownloadComplete()
2742
2787
  */
2743
- let [elem] = await scope.page.$x(`//a[contains(@href, "${ filename }")]`);
2788
+ let [elem] = await scope.page.$$(`xpath/.//a[contains(@href, "${ filename }")]`);
2744
2789
 
2745
2790
  let msg = `"${ filename }" seems to be missing. Cannot find a link to that document.`;
2746
2791
  if ( !elem ) { reports.addToReport(scope, { type: `error`, code: `ALK0152`, value: msg }); }
@@ -2893,7 +2938,9 @@ module.exports = {
2893
2938
  await scope.afterStep(scope, {waitForShowIf: true});
2894
2939
  }, // Ends scope.steps.set_al_address()
2895
2940
 
2896
- sign_in: async function ( scope, { email_secret_name, password_secret_name }) {
2941
+ sign_in: async function ( scope, {
2942
+ email_secret_name, password_secret_name, api_key_secret_name
2943
+ }) {
2897
2944
  /** Allow the developer to log a user into their server using GitHub
2898
2945
  * secrets to authenticate.
2899
2946
  */
@@ -2946,24 +2993,118 @@ module.exports = {
2946
2993
  if ( password === undefined ) {
2947
2994
  reports.addToReport( scope, { type: `error`, code: `ALK0162`, value: password_msg })
2948
2995
  }
2996
+ // Save API key to clean up interviews later. Nice to have, not critical
2997
+ if ( api_key_secret_name ) {
2998
+ let api_key = process.env[ api_key_secret_name ];
2999
+ let api_key_msg = `The API key in the variable "${ api_key_secret_name }" is missing. Check for a typo in your test, workflow file, GitHub secret, or server config.`
3000
+ if ( api_key === undefined ) {
3001
+ reports.addToReport( scope, { type: `warning`, code: `ALK0199`, value: api_key_msg });
3002
+ } else if ( api_key === `` ) {
3003
+ reports.addToReport( scope, { type: `warning`, code: `ALK0200`,
3004
+ value: `ALKiln found the API key in the variable "${ api_key_secret_name }", but it was empty. It had no text in it. Edit the value of the GitHub secret or server config variable called "${ api_key_secret_name }".` });
3005
+ } else {
3006
+ let access_info = await da_api.get_api_key_access_status({ api_key });
3007
+ if ( !access_info.has_auth ) {
3008
+ reports.addToReport( scope, { type: `warning`, code: `ALK0201`,
3009
+ value: `The server rejected the API key in the variable "${ api_key_secret_name }". Does it exist on this server? Was it deleted somehow? You can always make a new one.` });
3010
+ }
3011
+ // Even if it doesn't have access, give it a shot anyway.
3012
+ // Maybe we got something wrong.
3013
+ scope.scenarios.get( scope.scenario_id ).api_keys.push( api_key );
3014
+ }
3015
+ }
2949
3016
 
2950
3017
  expect( email, email_msg ).to.not.equal( undefined );
2951
3018
  expect( password, password_msg ).to.not.equal( undefined );
2952
3019
 
2953
- // Try to log in
3020
+ // Try to enter the information
2954
3021
  await scope.page.type( `#email`, email );
2955
3022
  await scope.page.type( `#password`, password );
2956
- let elem = await scope.page.$( `button[name="submit"]` );
2957
- await scope.tapElementAndNavigate( scope, { elem });
3023
+ let elem = await scope.page.$( `button[type="submit"]` );
3024
+ await scope.guard_against_missing_tap_element(scope, { elem });
2958
3025
 
2959
- reports.addToReport( scope, {
2960
- type: `row info`,
2961
- code: `ALK0163`,
2962
- value: `Signed into ${ session_vars.get_da_server_url() }/user/sign-in successfully.`
3026
+ // Submit and see what happens
3027
+ let winner = await scope.steps.race_sign_in_navigation( scope, { elem });
3028
+ // Add the result to the report and possibly throw errors
3029
+ if ( winner[0] === `success` ) {
3030
+ reports.addToReport( scope, {
3031
+ type: `row info`, code: `ALK0163`,
3032
+ value: `Successfully signed into ${ login_url }`
3033
+ });
3034
+ await scope.afterStep(scope, {waitForShowIf: false});
3035
+ } else if ( winner[0] === `failure` ) {
3036
+ let error_msg = reports.addToReport( scope, {
3037
+ type: `error`, code: `ALK0209`,
3038
+ value: `Failed to sign into ${ login_url }. Make sure you followed the instructions at https://assemblyline.suffolklitlab.org/docs/alkiln/writing/#sign-in.`
3039
+ });
3040
+ throw new Error( error_msg );
3041
+ } else if ( winner[0] === `error` ) {
3042
+ let error_msg = reports.addToReport( scope, {
3043
+ type: `error`, code: `ALK0210`,
3044
+ value: `Error on sign in page at ${ login_url }. Try this by hand and then run the test again.`
3045
+ });
3046
+ throw new Error( error_msg );
3047
+ }
3048
+ }, // Ends scope.steps.sign_in()
3049
+
3050
+ race_sign_in_navigation: async function ( scope, { elem }) {
3051
+ /** Wait for sign in navigation success or failure, or system error. */
3052
+
3053
+ // After everything, clean up incomplete promises
3054
+ const controller = new AbortController;
3055
+
3056
+ // Redirect
3057
+ let redirect_promise = scope.page.waitForResponse(function ( response ) {
3058
+ return response.status() === 302;
3059
+ }, { signal: controller.signal })
3060
+ .catch(( catch_info ) => {
3061
+ log.debug({ code: `ALK0204`, context: `nav`, },
3062
+ `302 sign-in wait catch error:`, catch_info.name, catch_info,
3063
+ );
2963
3064
  });
2964
3065
 
2965
- await scope.afterStep(scope, {waitForShowIf: false});
2966
- }, // Ends scope.steps.sign_in()
3066
+ // Invalid credentials, no navigation, no sign in
3067
+ let wrong_sign_in_promise = scope.page.waitForResponse(function ( response ) {
3068
+ return response.status() >= 200 && response.status() < 300 && response.url().includes(`/sign-in`);
3069
+ }, { signal: controller.signal })
3070
+ .catch(( catch_info ) => {
3071
+ log.debug({ code: `ALK0205`, context: `nav`, },
3072
+ `200s sign-in wait catch error:`, catch_info.name, catch_info,
3073
+ );
3074
+ });
3075
+
3076
+ // System error
3077
+ let error_promise = scope.page.waitForResponse(function ( response ) {
3078
+ return response.status() >= 500 && response.status() < 600;
3079
+ }, { signal: controller.signal })
3080
+ .catch(( catch_info ) => {
3081
+ log.debug({ code: `ALK0206`, context: `nav`, },
3082
+ `500s sign-in wait catch error:`, catch_info.name, catch_info,
3083
+ );
3084
+ });
3085
+
3086
+ let click_promise = elem.click(); // MUST complete
3087
+ const winner = await Promise.race([
3088
+ Promise.all([ `success`, click_promise, redirect_promise ]),
3089
+ Promise.all([ `failure`, click_promise, wrong_sign_in_promise ]),
3090
+ Promise.all([ `error`, click_promise, error_promise ]),
3091
+ ]).catch(function ( error ) {
3092
+ let error_msg = reports.addToReport( scope, {
3093
+ type: `error`, code: `ALK0207`,
3094
+ value: `Unknown error waiting for results during sign in at ${ login_url }.`
3095
+ });
3096
+ throw new Error( error );
3097
+ });
3098
+
3099
+ // Clean up unresolved promises
3100
+ controller.abort();
3101
+
3102
+ log.debug({ code: `ALK0208`, context: `nav` },
3103
+ `Sign-in winner:`, winner
3104
+ );
3105
+
3106
+ return winner;
3107
+ },
2967
3108
 
2968
3109
  set_random_page_vars: async ( scope ) => {
2969
3110
  /** Answer inputs randomly. */
@@ -2983,7 +3124,7 @@ module.exports = {
2983
3124
  } else {
2984
3125
  // Try to give a random answer for that element tag
2985
3126
  await scope.set_random_input_for[ field.tag ]( scope, { field: field });
2986
- process.stdout.write(`\x1b[36m${ '*' }\x1b[0m`); // assumes var was set if no error occurred
3127
+ log.stdout({}, `\x1b[36m${ '*' }\x1b[0m`); // assumes var was set if no error occurred
2987
3128
  }
2988
3129
 
2989
3130
  } // End for all page fields
@@ -3019,11 +3160,10 @@ module.exports = {
3019
3160
  }
3020
3161
  if (field == null) {
3021
3162
  // Just skip it? Shouldn't happen but has before. Dig in more
3022
- log.debug({
3023
- code: `ALK0164`,
3024
- type: `info`,
3025
- pre: `A random button field choice was null. These were the fields to choose from:\n${ JSON.stringify(fields, null, 2) }`,
3026
- });
3163
+ log.debug({ code: `ALK0164`, context: `random`, },
3164
+ `A random button field choice was null. These were the fields to choose from:`,
3165
+ fields
3166
+ );
3027
3167
  return;
3028
3168
  }
3029
3169
  let handle = await scope.get_handle_from_field( scope, { field: field });