@suffolklitlab/alkiln 4.3.0 → 4.5.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,17 @@ Format:
40
40
  -->
41
41
  <!-- ## [Unreleased] -->
42
42
 
43
+ ## [4.5.0] - 2022-05-23
44
+ - Steps that tap tabs (made using the `tabbed_templates_html` function from ALToolbox) and other arbitrary elements on the page.
45
+
46
+ ## [4.4.0] - 2022-05-23
47
+ ### Added
48
+ - Appends the results of the cucumber [summary formatter](https://github.com/cucumber/cucumber-js/blob/main/docs/formatters.md#summary) to the `debug_log.txt`, which includes useful stack traces into the Kiln code when tests fail.
49
+
50
+ ### Fixed
51
+ - Prevent login info from being saved in the report or screenshots being taken on error. [#599](https://github.com/SuffolkLITLab/ALKiln/issues/559).
52
+ - Prevent error screenshots of screens that used a secret.
53
+
43
54
  ## [4.3.0] - 2022-04-07
44
55
  ### Added
45
56
  - GitHub environment variable `MAX_SECONDS_FOR_SETUP` to set a custom maximum time for setup and takedown for packages that take a long time to load.
package/action.yml CHANGED
@@ -29,7 +29,7 @@ inputs:
29
29
  required: false
30
30
  default: '^4.0.0'
31
31
  MAX_SECONDS_FOR_SETUP:
32
- description: 'Amount of time to give the Project to upload your package's GitHub code.'
32
+ description: "Amount of time to give the Project to upload your package's GitHub code."
33
33
  required: false
34
34
  default: '120'
35
35
 
@@ -86,6 +86,13 @@ runs:
86
86
  "@suffolklitlab/alkiln": "$ALKILN_VERSION"
87
87
  }
88
88
  }
89
+ EOF
90
+ # Keep in sync with the cucumber.js in root
91
+ cat << EOF > "cucumber.js"
92
+ module.exports = {
93
+ default: "--require ./lib/index.js --format progress --format summary:cucumber-report.txt"
94
+ }
95
+ EOF
89
96
  shell: bash
90
97
 
91
98
  # Install node
package/cucumber.js ADDED
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ default: "--require ./lib/index.js --format progress --format summary:cucumber-report.txt"
3
+ }
package/lib/scope.js CHANGED
@@ -244,10 +244,37 @@ module.exports = {
244
244
  if ( showif ) { await scope.page.waitForTimeout( scope.showif_timeout ); }
245
245
  },
246
246
 
247
- afterStep: async function afterStep(scope, options = {waitForShowIf: false, navigated: false, waitForTimeout: 0}) {
247
+ waitForTab: async function waitForTab(scope, tab_to_show) {
248
+ /**
249
+ * @param {tab_to_show} the aria-labeledby text of the tab panel, which
250
+ * we use to query and wait for that tab panel to be shown.
251
+ */
252
+ // Implementation linked to https://github.com/SuffolkLITLab/docassemble-ALToolbox/blob/4aa1069389d2ca9cadb586f309dd73d791f081d1/docassemble/ALToolbox/misc.py#L127
253
+ // I think `.$()` does not use a timeout
254
+ if ( tab_to_show.length == 0) { return }
255
+ // waiting for the transitionend event: https://stackoverflow.com/a/57955251/11416267
256
+ await scope.page.evaluate((element_query) => {
257
+ return new Promise((resolve) => {
258
+ const elem = document.querySelector(element_query);
259
+ // Taken from jQuery: https://stackoverflow.com/a/33456469/11416267
260
+ const is_visible = !!( elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length );
261
+ if (!is_visible) {
262
+ const onEnd = function() {
263
+ elem.removeEventListener('transitionend', onEnd);
264
+ resolve();
265
+ };
266
+ elem.addEventListener('transitionend', onEnd);
267
+ } else {
268
+ resolve();
269
+ }
270
+ });
271
+ }, `div[aria-labelledby="${tab_to_show}"]`);
272
+ },
273
+
274
+ afterStep: async function afterStep(scope, options = {waitForShowIf: false, tabToWaitFor: '', navigated: false, waitForTimeout: 0}) {
248
275
  /* Common things to do after a step, including take care of errors.
249
276
  * TODO: discuss splitting into `afterField` and `afterStep` or something. Or just change name
250
- * TODO: Update to latest cucumberjs, which as `AfterStep` */
277
+ * TODO: Update to latest cucumberjs, which has `AfterStep` */
251
278
 
252
279
  if ( scope.page ) {
253
280
  // Use a while loop until the below does not error with `Error: Execution context was destroyed`
@@ -274,17 +301,24 @@ module.exports = {
274
301
 
275
302
  // Wait for elements that might get revealed
276
303
  if ( options.waitForShowIf ) { await scope.waitForShowIf( scope ); }
304
+ if ( options.tabToWaitFor ) { await scope.waitForTab( scope, options.tabToWaitFor); }
277
305
  } // ends if scope.page
278
306
 
279
- // Reset navigation (TODO: discuss use for observational steps)
280
- if ( options.navigated ) { scope.navigated = true; } // Consider `daPageLoad` event
281
- else { scope.navigated = false; }
307
+ if ( options.navigated ) {
308
+ // Reset navigation (TODO: discuss use for observational steps)
309
+ // TODO: Consider `daPageLoad` event
310
+ scope.navigated = true;
311
+ // Allow screenshots of the next page (may have been disabled in order to hide secret values)
312
+ scope.disable_error_screenshot = false;
313
+ } else {
314
+ scope.navigated = false;
315
+ }
282
316
 
283
317
  // Plain old waiting. Here to avoid doing this in each individual place
284
318
  if ( options.waitForTimeout ) { await time.waitForTimeout( options.waitForTimeout ); }
285
319
  }, // Ends afterStep
286
320
 
287
- tapElement: async function tapElement(scope, elem) {
321
+ tapElement: async function tapElement(scope, elem, tabToWaitFor='', waitForTimeout=0) {
288
322
  /* Tap a given element, detect navigation, and do appropriate related things. */
289
323
 
290
324
  // This check might never be needed now as we try to match fields on the page
@@ -297,25 +331,32 @@ module.exports = {
297
331
 
298
332
  let start_url = await scope.page.url(); // so we can later check for navigation
299
333
 
300
- let winner;
301
- winner = await Promise.race([
302
- Promise.all([ // Error loads page, so no need to detect to keep things short
303
- // Click with no navigation will end immediately
304
- // Can't use elem[ scope.activate ](). It works locally, but errors on GitHub
305
- // with "Node is not visible or not an HTMLElement". We're not using a custom Chromium version
306
- // Unfortunately, this won't catch an invisible element. Hopefully we'd catch it before now...?
307
- // "Tap" on mobile is more complex to implement ourselves.
308
- // See https://stackoverflow.com/a/56547605/14144258
309
- // and other convos around it. We haven't made it public that the device
310
- // can customized. Until we do that, we'll just use "click" all the time.
334
+ let clickWait;
335
+ // Can't use elem[ scope.activate ](). It works locally, but errors on GitHub
336
+ // with "Node is not visible or not an HTMLElement". We're not using a custom Chromium version
337
+ // Unfortunately, this won't catch an invisible element. Hopefully we'd catch it before now...?
338
+ // "Tap" on mobile is more complex to implement ourselves.
339
+ // See https://stackoverflow.com/a/56547605/14144258
340
+ // and other convos around it. We haven't made it public that the device
341
+ // can customized. Until we do that, we'll just use "click" all the time.
342
+ if (!tabToWaitFor) {
343
+ // Click with no navigation will end immediately
344
+ clickWait = Promise.all([
311
345
  elem.evaluate( (el) => { return el.click(); }),
312
346
  scope.page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
313
- ]),
347
+ ]);
348
+ } else {
349
+ // Same thing as above, but the promise doesn't wait for navigation.
350
+ clickWait = elem.evaluate( (el) => {return el.click(); });
351
+ }
352
+ // Error loads page, so no need to detect to keep things short
353
+ let winner = await Promise.race([
354
+ clickWait,
314
355
  // This is meant to detect user-visible alerts/errors,
315
356
  // not breaking errors in the testing code.
316
357
  scope.page.waitForSelector('.alert-danger', { visible: true }),
317
358
  scope.page.waitForSelector('.da-has-error', { visible: true }),
318
- ]);
359
+ ])
319
360
 
320
361
  // TODO: Not sure how to detect navigation landing at same page.
321
362
  // Maybe url change + trigger var change? Maybe just trigger var?
@@ -329,8 +370,12 @@ module.exports = {
329
370
  // If navigation, wait for 200ms to let things settle down
330
371
  await scope.afterStep(scope, {waitForTimeout: 200, navigated: navigated});
331
372
  } else {
332
- // If stay on page, wait for `show if` or feedback elements to be move around
333
- await scope.afterStep(scope, {waitForShowIf: true, navigated: navigated});
373
+ // If stay on page, wait for `show if`, the new tab to be loaded, or feedback elements to be move around
374
+ options = {waitForShowIf: !tabToWaitFor, tabToWaitFor: tabToWaitFor, navigated: navigated};
375
+ if (waitForTimeout > 0) {
376
+ options = {waitForTimeout: waitForTimeout, navigated: navigated};
377
+ }
378
+ await scope.afterStep(scope, options);
334
379
  }
335
380
 
336
381
  return winner;
@@ -420,13 +465,33 @@ module.exports = {
420
465
  await scope.tapElement( scope, elem );
421
466
  },
422
467
 
468
+ tapTab: async function ( scope, tab_id ) {
469
+ let elem = await scope.page.$(`#${tab_id}`);
470
+ let error_msg = `Couldn't find the tab with id "#${tab_id}" on the page, is there a typo?`;
471
+ if ( !elem ) {
472
+ await scope.addToReport(scope, { type: `error`, value: error_msg });
473
+ }
474
+ expect( elem, error_msg ).to.exist;
475
+ await scope.tapElement( scope, elem, tab_id );
476
+ },
477
+
478
+ tapElementBySelector: async function ( scope, selector, waitForTimeout ) {
479
+ let elem = await scope.page.$(`${selector}`);
480
+ let error_msg = `Couldn't find "${selector}" on the page, is there a typo?`;
481
+ if ( !elem ) {
482
+ await scope.addToReport(scope, { type: `error`, value: error_msg });
483
+ }
484
+ expect( elem, error_msg ).to.exist;
485
+ await scope.tapElement( scope, elem, '', waitForTimeout );
486
+ },
487
+
423
488
  load: async function ( scope, file_name ) {
424
489
  /* Try to load the page. Should we also pass a `timeout` arg or just
425
490
  * use scope.timeout? */
426
491
 
427
492
  // If there is no browser open, start a new one
428
493
  if (!scope.browser) {
429
- scope.browser = await scope.driver.launch({ headless: !session_vars.get_debug() });
494
+ scope.browser = await scope.driver.launch({ headless: !session_vars.get_debug(), devtools: session_vars.get_debug() });
430
495
  }
431
496
  if ( !scope.page ) { scope.page = await scope.browser.newPage(); }
432
497
 
@@ -1836,6 +1901,8 @@ module.exports = {
1836
1901
 
1837
1902
  set_secret_var: async ( scope, var_name, set_to_env_name ) => {
1838
1903
  /** Sets a non-story table variable to a "secret" value (i.e. an env var) */
1904
+ // Prevent pictures of a screen with a secret.
1905
+ scope.disable_error_screenshot = true;
1839
1906
  let var_data = await scope.normalizeTable( scope, {
1840
1907
  var_data: [{
1841
1908
  var: var_name,
@@ -1963,6 +2030,9 @@ module.exports = {
1963
2030
  /** Allow the developer to log a user into their server using GitHub
1964
2031
  * secrets to authenticate.
1965
2032
  */
2033
+ // Don't take a picture of a failed login in case one of the inputs is correct
2034
+ scope.disable_error_screenshot = true;
2035
+
1966
2036
  // If there is no browser open, start a new one
1967
2037
  if (!scope.browser) {
1968
2038
  scope.browser = await scope.driver.launch({ headless: !session_vars.DEBUG });
@@ -2003,8 +2073,10 @@ module.exports = {
2003
2073
 
2004
2074
  await scope.addToReport( scope, {
2005
2075
  type: `row`,
2006
- value: `Logged into ${ session_vars.get_da_server_url() }/user/sign-in as a ${ email } with ${ password }.`
2076
+ value: `Signed into ${ session_vars.get_da_server_url() }/user/sign-in successfully.`
2007
2077
  });
2078
+
2079
+ await scope.afterStep(scope, {waitForShowIf: false});
2008
2080
  }, // Ends sign_in
2009
2081
 
2010
2082
  }, // ends scope.steps
package/lib/steps.js CHANGED
@@ -83,6 +83,7 @@ Before(async (scenario) => {
83
83
  // Device interaction
84
84
  scope.device = 'pc';
85
85
  scope.activate = click_with[ scope.device ];
86
+ scope.disable_error_screenshot = false;
86
87
 
87
88
  scope.page_id = null;
88
89
 
@@ -390,7 +391,7 @@ When(/^(?:the user gets)?(?:I get)? to ?(?:the)?(?: ?question)?(?: id)? "([^"]+)
390
391
  //#####################################
391
392
  //#####################################
392
393
 
393
- When(/I wait (\d*\.?\d+) seconds?/, { timeout: -1 }, async ( seconds ) => {
394
+ When(/I wait ?(?:for)? (\d*\.?\d+) seconds?/, { timeout: -1 }, async ( seconds ) => {
394
395
  /** Wait for a specified amount of time. Can help with some race conditions.
395
396
  * We're working on reducing race conditions, but it'll never be perfect.
396
397
  *
@@ -706,6 +707,27 @@ When('I tap to continue', { timeout: -1 }, async () => {
706
707
  );
707
708
  });
708
709
 
710
+ When(/I tap the "([^"]+)" tab/i, { timeout: -1 }, async ( tabId ) => {
711
+ return wrapPromiseWithTimeout(
712
+ scope.tapTab( scope, tabId ),
713
+ scope.timeout
714
+ )
715
+ });
716
+
717
+ When(/I tap the "([^"]+)" element$/i, { timeout: -1 }, async ( elemSelector ) => {
718
+ return wrapPromiseWithTimeout(
719
+ scope.tapElementBySelector( scope, elemSelector, 2000 ),
720
+ scope.timeout
721
+ )
722
+ });
723
+
724
+ When(/I tap the "([^"]+)" element and wait ([0-9]+) second(?:s)?/i, { timeout: -1 }, async ( elemSelector, waitTime ) => {
725
+ return wrapPromiseWithTimeout(
726
+ scope.tapElementBySelector( scope, elemSelector, waitTime * 1000 ),
727
+ scope.timeout
728
+ )
729
+ });
730
+
709
731
 
710
732
  //#####################################
711
733
  // UI element interaction
@@ -882,11 +904,19 @@ After(async function(scenario) {
882
904
 
883
905
  await scope.addToReport(scope, { type: `outcome`, value: `**-- Scenario Failed --**` });
884
906
  if ( scope.page ) {
885
- // Save/download a picture of the screen that's showing during the unexpected status
886
- let safe_filename = await scope.getSafeScenarioFilename( scope, { prefix: `error` });
887
- await scope.page.screenshot({ path: `${ safe_filename }.jpg`, type: `jpeg`, fullPage: true });
888
- }
889
- } // Ends if not passed
907
+ if ( scope.disable_error_screenshot ) {
908
+ scope.addToReport(scope, {
909
+ type: `row`,
910
+ value: `For security, ALKiln will not create a screenshot for this error. It's possible a secret is being used on this screen.`
911
+ });
912
+ } else {
913
+ // Save/download a picture of the screen that's showing during the unexpected status
914
+ let safe_filename = await scope.getSafeScenarioFilename( scope, { prefix: `error` });
915
+ await scope.page.screenshot({ path: `${ safe_filename }.jpg`, type: `jpeg`, fullPage: true });
916
+ } // ends if scope.disable_error_screenshot
917
+ } // ends if scope.page exists
918
+
919
+ } // ends if not passed
890
920
 
891
921
  if (scenario.result.status === `FAILED`) {
892
922
  await scope.addToReport(scope, { type: `outcome`, value: `**-- Scenario Failed --**` });
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+
5
+ // This is hardcoded information from `cucumber.js`. Once we move to cucumber 8, we should
6
+ // change that the cucumber.json and actually parse the info from it.
7
+ let summary_file_name = 'cucumber-report.txt';
8
+ fs.readFile(summary_file_name, 'utf8', (err, data) => {
9
+ if (err) {
10
+ console.error(err);
11
+ return;
12
+ }
13
+ // Replace terminal color codes, if present
14
+ // https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797#colors--graphics-mode
15
+ data = data.replace(/\x1b\[[0-9][0-9]?[0-9]?m/g, '');
16
+ const debug_log_file = 'debug_log.txt';
17
+ fs.appendFile(debug_log_file, data, err => {
18
+ if (err) {
19
+ console.error(err);
20
+ return;
21
+ }
22
+ });
23
+ });
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@suffolklitlab/alkiln",
3
- "version": "4.3.0",
3
+ "version": "4.5.0",
4
4
  "description": "Integrated automated end-to-end testing with docassemble, puppeteer, and cucumber.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
7
7
  "test": "npm run unit && npm run langs_setup && npm run cucumber -- $@",
8
- "cucumber_base": "./node_modules/.bin/cucumber-js --require ./lib/index.js",
9
- "cucumber": "run_cucumber(){ npm run cucumber_base -- \"$@\" tests/features/*.feature; }; run_cucumber",
8
+ "cucumber_base": "run_cucumber(){ ./node_modules/.bin/cucumber-js \"$@\"; status=$?; ./lib/utils/clean_reports.js; return $status; }; run_cucumber",
9
+ "cucumber": "run_base(){ npm run cucumber_base -- \"$@\" tests/features/*.feature; }; run_base",
10
10
  "langs": "npm run langs_setup && ./node_modules/.bin/cucumber-js --require ./lib/index.js tests/features/*_lang*.feature",
11
11
  "langs_setup": "./lib/utils/langs.js 'tests/features/*.feature'",
12
12
  "langs_local": "npm run langs_setup && npm run local",
@@ -101,3 +101,25 @@ Scenario: handles settings from Github secrets
101
101
  Then I see the phrase "secret-var2-value"
102
102
  When I tap to continue
103
103
  And I set the variable "third_text_entry" to secret "SECRET_NOT_THERE"
104
+
105
+ @i3 @tap-elements @tabs
106
+ Scenario: tap elements
107
+ Given I start the interview at "test_taps"
108
+ And I tap the "Tests-first_template-tab" tab
109
+ Then I see the phrase "Mechanics"
110
+ And I tap the "Tests-second_template-tab" tab
111
+ Then I see the phrase "villify"
112
+ And I tap the "Tests-third_template-tab" tab
113
+ Then I see the phrase "museum"
114
+ And I tap the "#special_event" element and wait 1 second
115
+ Then I see the phrase "Portishead"
116
+
117
+ @i4 @tap-elements @tabs
118
+ Scenario: tap element with an error
119
+ Given the final Scenario status should be "failed"
120
+ And the Scenario report should include:
121
+ """
122
+ is there a typo?
123
+ """
124
+ Given I start the interview at "test_taps"
125
+ And I tap the "Tests-not_there-tab" tab
@@ -346,7 +346,7 @@ Scenario: I can't match JSON page var to str
346
346
  """
347
347
 
348
348
  @fast @rf22 @signin
349
- Scenario: Fail with wrong email secret
349
+ Scenario: Fail with wrong email secret name
350
350
  Given the final Scenario status should be "failed"
351
351
  Given the Scenario report should include:
352
352
  """
@@ -355,7 +355,7 @@ Scenario: Fail with wrong email secret
355
355
    Given I log on with the email "WRONG_EMAIL_NAME" and the password "USER1_PASSWORD"
356
356
 
357
357
  @fast @rf23 @signin
358
- Scenario: Fail with wrong password secret
358
+ Scenario: Fail with wrong password secret name
359
359
  Given the final Scenario status should be "failed"
360
360
  Given the Scenario report should include:
361
361
  """
@@ -364,7 +364,7 @@ Scenario: Fail with wrong password secret
364
364
    Given I sign in with the email "USER1_EMAIL" and the password "WRONG_PASSWORD_NAME"
365
365
 
366
366
  @fast @rf24 @signin
367
- Scenario: Fail with 2 wrong signin secrets
367
+ Scenario: Fail with 2 wrong signin secret names
368
368
  Given the final Scenario status should be "failed"
369
369
  Given the Scenario report should include:
370
370
  """
@@ -393,6 +393,10 @@ Scenario: Fail to find var while keeping value secret
393
393
  """
394
394
  Did not find a field on this page for the variable "missing_var" that could be set to "SECRET_FOR_MISSING_FIELD"
395
395
  """
396
+ And the Scenario report should include:
397
+ """
398
+ For security, ALKiln will not create a screenshot for this error.
399
+ """
396
400
  And I start the interview at "test_secret_vars"
397
401
  And I set the var "missing_var" to the secret "SECRET_FOR_MISSING_FIELD"
398
402
 
@@ -518,3 +522,11 @@ Scenario: Report lists unused table rows
518
522
  | button_continue | True | |
519
523
  | buttons_other | button_2 | |
520
524
  | buttons_yesnomaybe | True | |
525
+
526
+ @fast @rp3 @signin
527
+ Scenario: Sign in to server successfully
528
+ Given the Scenario report should include:
529
+ """
530
+ Signed in
531
+ """
532
+   Given I sign in with the email "USER1_EMAIL" and the password "USER1_PASSWORD"