@suffolklitlab/alkiln 3.0.9

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 (40) hide show
  1. package/.github/workflows/test_tests.yml +77 -0
  2. package/CHANGELOG.md +381 -0
  3. package/README.md +18 -0
  4. package/action.yml +158 -0
  5. package/lib/DOM.md.html +63 -0
  6. package/lib/github_integration/install-branch.js +84 -0
  7. package/lib/github_integration/puppeteer-utils.js +224 -0
  8. package/lib/github_integration/setup.js +4 -0
  9. package/lib/github_integration/takedown.js +4 -0
  10. package/lib/index.js +2 -0
  11. package/lib/scope.js +1632 -0
  12. package/lib/steps.js +878 -0
  13. package/lib/utils/env_vars.js +173 -0
  14. package/lib/utils/langs.js +51 -0
  15. package/lib/world.js +10 -0
  16. package/package.json +53 -0
  17. package/tests/features/assembly_line.feature +43 -0
  18. package/tests/features/establishing_steps.feature +22 -0
  19. package/tests/features/example_test.feature +11 -0
  20. package/tests/features/interactive_steps.feature +85 -0
  21. package/tests/features/observation_steps.feature +97 -0
  22. package/tests/features/reports.feature +482 -0
  23. package/tests/features/some_png_1.png +0 -0
  24. package/tests/features/some_png_2.png +0 -0
  25. package/tests/features/story_table_formats.feature +77 -0
  26. package/tests/features/story_tables.feature +88 -0
  27. package/tests/unit_tests/ensureSpecialRows.test.js +34 -0
  28. package/tests/unit_tests/env_vars.test.js +53 -0
  29. package/tests/unit_tests/fields.fixtures.js +904 -0
  30. package/tests/unit_tests/getAllFields.test.js +142 -0
  31. package/tests/unit_tests/getMatchingRows.test.js +261 -0
  32. package/tests/unit_tests/getSafeScenarioBaseFilename.fixtures.js +12 -0
  33. package/tests/unit_tests/getSafeScenarioBaseFilename.test.js +62 -0
  34. package/tests/unit_tests/getSafeScenarioFilename.test.js +83 -0
  35. package/tests/unit_tests/html.fixtures.js +861 -0
  36. package/tests/unit_tests/matches.fixtures.js +168 -0
  37. package/tests/unit_tests/names.fixtures.js +35 -0
  38. package/tests/unit_tests/normalizeTable.test.js +22 -0
  39. package/tests/unit_tests/special_rows.fixtures.js +101 -0
  40. package/tests/unit_tests/tables.fixtures.js +428 -0
package/lib/steps.js ADDED
@@ -0,0 +1,878 @@
1
+ const { When, Then, And, Given, After, Before, BeforeAll, AfterAll, setDefaultTimeout } = require('cucumber');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { expect } = require('chai');
5
+ const puppeteer = require('puppeteer');
6
+ const { v4: uuidv4 } = require('uuid');
7
+
8
+ const scope = require('./scope');
9
+ const env_vars = require('./utils/env_vars');
10
+
11
+ /* Of Note:
12
+ - We're using `*=` because sometimes da text has funny characters in it that are hard to anticipate
13
+ */
14
+
15
+ require("dotenv").config(); // Where did this go?
16
+
17
+ /* TODO:
18
+ 1. 'choice' to be any kind of choice - radio, checkbox,
19
+ dropdown, etc. Have to research the different DOM for each
20
+ 1. 'choice' to have a more specific way to access each item. For
21
+ example, for a list collect or other things that have multiple
22
+ things with the same text on the page.
23
+ 1. Figure out how to test allowing felxibility for coder. For example:
24
+ there is placeholder text for the title of the form and if it's not
25
+ defined, placeholder text should appear (though that behavior may
26
+ bear discussion).
27
+ 1. Add links to resources on:
28
+ 1. taps that trigger navigation need Promise.all: https://stackoverflow.com/a/60789449/14144258
29
+ > I ran into a scenario, where there was the classic POST-303-GET
30
+ and an input[type=submit] was involved. It seems that in this case,
31
+ the tap of the button won't resolve until after the associated form's
32
+ submission and redirection, so the solution was to remove the waitForNavigation,
33
+ because it was executed after the redirection and thus was timing out.
34
+
35
+ I think Promise.all is what's taking care of these situations.
36
+ 1. Listening for `targetchanged` or changed URL
37
+ 1. Listening for responses
38
+
39
+ Should post example of detecting new page/no new page on submit when there
40
+ is a DOM change that you can detect. I suspect no request is
41
+ being sent, but I could be wrong. Haven't yet figured out how
42
+ to detect that.
43
+
44
+ regex thoughts: https://stackoverflow.com/questions/171480/regex-grabbing-values-between-quotation-marks
45
+ */
46
+
47
+ // Hoping that most load and download times will be under 30 seconds.
48
+ // TODO: Check what average load and download times are in our current interviews.
49
+ setDefaultTimeout( 30 * 1000 );
50
+
51
+ let click_with = {
52
+ mobile: 'tap',
53
+ pc: 'click',
54
+ };
55
+
56
+ BeforeAll(async () => {
57
+ scope.timeout = 30 * 1000;
58
+ scope.showif_timeout = 600;
59
+ scope.report = new Map();
60
+ });
61
+
62
+ Before(async (scenario) => {
63
+ scope.scenario_id = uuidv4();
64
+
65
+ await scope.addReportHeading(scope, {scenario});
66
+ scope.base_filename = await scope.getSafeScenarioBaseFilename(scope, {scenario});
67
+
68
+ // Downloads
69
+ scope.downloadComplete = false;
70
+ scope.toDownload = '';
71
+ // Device interaction
72
+ scope.device = 'pc';
73
+ scope.activate = click_with[ scope.device ];
74
+
75
+ scope.page_id = null;
76
+
77
+ // Reset default timeout
78
+ scope.timeout = 30 * 1000;
79
+ setDefaultTimeout( 30 * 1000 );
80
+ });
81
+
82
+ // Add a check for an error page before each step? After each step?
83
+
84
+
85
+ //#####################################
86
+ //#####################################
87
+ // Establishing
88
+ //#####################################
89
+ //#####################################
90
+
91
+ Given(/I start the interview at "([^"]+)"(?: in lang "([^"]+)")?/, {timeout: -1}, async (file_name, language) => { // √
92
+ // NO CUCUMBER TIMEOUT. Handling our own timeouts to deal with initial scenario load.
93
+ // It involves wrapping everything in custom timeouts. See
94
+ // https://github.com/SuffolkLITLab/ALKiln/issues/389#issuecomment-984844240
95
+ let max_load_attempts = 3;
96
+ let num_attempts = 0;
97
+ // First test long load time discussion:
98
+ // https://github.com/SuffolkLITLab/ALKiln/issues/389#issuecomment-984844240
99
+ while ( num_attempts < max_load_attempts ) {
100
+
101
+ num_attempts++;
102
+ let timeout_message = `Failed to load "${ file_name }" after ${ num_attempts } tries. `
103
+ + `Each try gave the page ${ scope.timeout/1000 } seconds to load.`;
104
+
105
+ let loaded = new Promise(async ( resolve, reject ) => {
106
+ // Exit if timeout is exceeded. Reject only if final attempt failed
107
+ let page_timeout = setTimeout(function() {
108
+ if ( num_attempts === max_load_attempts ) { reject( `${ timeout_message } Internal timeout.` ); }
109
+ else { resolve( false ); } // Give it another shot
110
+ }, scope.timeout);
111
+ // self-invoke an anonymous async function so we don't have to
112
+ // abstract the details of resolving and rejecting.
113
+ (async function() {
114
+ try {
115
+ await scope.load( scope, file_name );
116
+ resolve( true );
117
+ } catch ( err ) {
118
+ // Only fail on final attempt. Actual error should be logged by cucumber output.
119
+ if ( num_attempts === max_load_attempts ) {
120
+ // This may not be the most useful error for us framework developers, but
121
+ // we'll have the rejection error I hope
122
+ await scope.addToReport(scope, { type: `error`, value: timeout_message });
123
+ reject( err );
124
+ } else { resolve( false ); } // Give it another shot
125
+ } finally {
126
+ clearTimeout( page_timeout ); // prevent temporary hang at end of tests
127
+ }
128
+ })();
129
+ }); // ends loaded promise
130
+ await loaded;
131
+ if ( loaded === true ) { break; }
132
+ } // ends while attempting to load page
133
+
134
+ // The page timeout is set in here too in case someone set a custom timeout
135
+ // before a page was loaded.
136
+ // The function itself won't really timeout here, so we don't need to wrap it in
137
+ // a custom timeout
138
+ await scope.page.setDefaultTimeout( scope.timeout ); // overrides outer default timeout
139
+
140
+ // Allow developer to save download files
141
+ let custom_download_behavior_timeout = new Promise(async ( resolve, reject ) => {
142
+ let page_timeout = setTimeout(function() {reject( `Took too long to set download behavior.` ); }, scope.timeout);
143
+ // self-invoke an anonymous async function so we don't have to
144
+ // abstract the details of resolving and rejecting.
145
+ (async function() {
146
+ // I've seen stuff take an extra moment or two. Shame to have it everywhere
147
+ try {
148
+ // Interview files should get downloaded to downloads folder
149
+ let docs_dir = await scope.getSafeScenarioFilename( scope, { prefix: `downloads` });
150
+ await scope.page._client.send('Page.setDownloadBehavior', {
151
+ behavior: 'allow',
152
+ downloadPath: path.resolve( docs_dir ), // Save in root of this project
153
+ });
154
+ resolve();
155
+ } catch ( err ) { reject( err ); }
156
+ // prevent temporary hang at end of tests
157
+ finally { clearTimeout( page_timeout ); }
158
+ })();
159
+ }); // ends custom_download_behavior_timeout
160
+ await custom_download_behavior_timeout;
161
+
162
+ // ---- EVENT LISTENERS ----
163
+ // Wait for possible download. From https://stackoverflow.com/a/51423688
164
+ scope.page.on('response', async response => {
165
+ // Note: We can expect that only one file will be getting downloaded
166
+ // at any one time since each step completes synchronously
167
+
168
+ // If response has a file in it that matches the file we are downloading
169
+ if ( response._headers['content-disposition'] && (response._headers['content-disposition']).includes(`attachment;filename=${scope.toDownload}`) ) {
170
+ // Remove the event listener since we might not be closing the browser
171
+ scope.page.removeEventListener('response', arguments.callee);
172
+
173
+ // Watch event on download folder or file
174
+ await fs.watchFile( docs_dir, function (curr, prev) {
175
+ // If current size eq to size from response then close
176
+ if (parseInt(curr.size) === parseInt(response._headers['content-length'])) {
177
+ // Lets the related function in `scope` know that the download
178
+ // is done so that the next step can start.
179
+ scope.downloadComplete = true;
180
+ this.close();
181
+ return true;
182
+ }
183
+ }); // ends fs.watchFile
184
+
185
+ return false;
186
+ } // ends if this is the file the developer expected
187
+ }); // ends page.on 'response'
188
+
189
+
190
+ // TODO: Move language into a different step
191
+ scope.language = language;
192
+ let custom_lang_timeout = new Promise(async ( resolve, reject ) => {
193
+ let page_timeout = setTimeout(function() { reject( `It took to long to try to set the language "${ language }".` ); }, scope.timeout);
194
+ // self-invoke an anonymous async function so we don't have to
195
+ // abstract the details of resolving and rejecting.
196
+ (async function() {
197
+ try {
198
+ await scope.setLanguage( scope, { language });
199
+ resolve();
200
+ } catch ( err ) { reject( err ); }
201
+ // prevent temporary hang at end of tests
202
+ finally { clearTimeout( page_timeout ); }
203
+ })();
204
+ }); // ends custom_lang_timeout
205
+ await custom_lang_timeout;
206
+
207
+
208
+ let custom_aferStep_timeout = new Promise(async ( resolve, reject ) => {
209
+ let page_timeout = setTimeout(function() { reject(`\`afterStep()\` took too long.`); }, scope.timeout);
210
+ // self-invoke an anonymous async function so we don't have to
211
+ // abstract the details of resolving and rejecting.
212
+ (async function() {
213
+ // I've seen stuff take an extra moment or two. Shame to have it everywhere
214
+ try { await scope.afterStep(scope, {waitForTimeout: 200}); resolve(); }
215
+ catch ( err ) { reject( err ); }
216
+ // prevent temporary hang at end of tests
217
+ finally { clearTimeout( page_timeout ); }
218
+ })();
219
+ }); // ends custom_aferStep_timeout
220
+ await custom_aferStep_timeout;
221
+ });
222
+
223
+ // I am using a moble/pc
224
+ When(/I am using an? (.*)/, async ( device ) => {
225
+ // Let developer pick mobile device if they want to
226
+ // TODO: Add tablet?
227
+ if ( device ) {
228
+ if ( device.includes( 'mobile' ) || device.includes( 'phone' ) ) {
229
+ await scope.page.setUserAgent("Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36");
230
+ scope.device = 'mobile';
231
+ }
232
+ } else {
233
+ await scope.page.setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3312.0 Safari/537.36");
234
+ scope.device = 'pc';
235
+ }
236
+
237
+ scope.activate = click_with[ scope.device ];
238
+ });
239
+
240
+ Given(/the max(?:imum)? sec(?:ond)?s for each step (?:in this scenario )?is (\d+)/i, async ( seconds ) => {
241
+ /* At any time, set a custom timeout for the scenario. */
242
+ scope.timeout = parseInt( seconds ) * 1000;
243
+ setDefaultTimeout( scope.timeout );
244
+ // If there is no page yet, we've ensured custom timeout will still be set
245
+ // appropriately in the interview loading step
246
+ if ( scope.page ) { await scope.page.setDefaultTimeout( scope.timeout ); }
247
+ });
248
+
249
+ //#####################################
250
+ //#####################################
251
+ // Story
252
+ //#####################################
253
+ //#####################################
254
+
255
+ When(/^(?:the user gets)?(?:I get)? to ?(?:the)?(?: ?question)?(?: id)? "([^"]+)" with this data:?/, {timeout: -1}, async (target_id, raw_var_data) => {
256
+ /* NO CUCUMBER TIMEOUT. Must handle timeout ourselves:
257
+ * https://github.com/cucumber/cucumber-js/blob/master/docs/support_files/timeouts.md#disable-timeouts
258
+ * Iterate in here to keep timeout reasonable */
259
+
260
+ // Note: Why do we not just stop when we've used all the vars in the table or
261
+ // reach the last var? Vars are not required to be given in sequence and pages that
262
+ // have non-button fields don't usually have a var assigned to the 'continue' button.
263
+ // Alternative: require vars even for 'continue' pages. Then in loop require at least one var
264
+ // to be found on a page before hitting 'continue'. That requires detecting navigation because
265
+ // pages with fields will not have a var for the continue button, so there will
266
+ // still have to be some automatic continuing.
267
+ // It also seems harsh, but more reliable.
268
+ // Ask developers what they'd like.
269
+
270
+ let supported_table = await scope.normalizeTable( scope, { var_data: raw_var_data.hashes() });
271
+
272
+ let { question_has_id, id, id_matches } = await scope.examinePageID( scope, target_id );
273
+ // Until we reach the final page or hit an error
274
+ while ( !id_matches ) {
275
+ let custom_timeout = new Promise(function( resolve, reject ) {
276
+ // Set up the timeout
277
+ let page_timeout = setTimeout(function() {
278
+ let timeout_message = `The target id was "${ target_id }", but it took too long` +
279
+ ` to try to reach it (over ${ scope.timeout/1000/60 } min). The test got stuck on "${ id }".`
280
+ reject( timeout_message );
281
+ }, scope.timeout);
282
+
283
+ // self-invoke an anonymous async function so we don't have to
284
+ // abstract the details of resolving and rejecting.
285
+ (async function() {
286
+ try {
287
+ let { question_has_id, id, id_matches } = await scope.examinePageID( scope );
288
+
289
+ // Add special rows. Basically, do weirder things.
290
+ // TODO: Remove from_story_table - no longer needed if only called from in here
291
+ let ensured_var_data = await scope.ensureSpecialRows( scope, {
292
+ var_data: supported_table,
293
+ from_story_table: true,
294
+ });
295
+ // Some errors happen in there to be closer to the relevant code. We'd have to try/catch anyway.
296
+ let error = await scope.setFields(scope, { var_data: ensured_var_data, id, ensure_navigation: true });
297
+ if ( error.was_found ) { await scope.throwPageError( scope, { id: id }); }
298
+ resolve();
299
+ } catch ( err ) {
300
+ reject( err );
301
+ } finally {
302
+ clearTimeout( page_timeout ); // prevent temporary hang at end of tests
303
+ }
304
+ })();
305
+ }); // ends custom_timeout for setFields
306
+
307
+ await custom_timeout;
308
+
309
+ // Prep for next loop
310
+ ({ question_has_id, id, id_matches } = await scope.examinePageID( scope, target_id ));
311
+
312
+ } // ends while !id_matches
313
+ // Can anything unsatisfactory get through here?
314
+
315
+ // Add report strings from supported table, not an altered one
316
+ for ( let row of supported_table ) {
317
+ row.report_str = `${ await scope.convertToOriginalStoryTableRow(scope, { row_data: row }) }`;
318
+ }
319
+ // Sort into aphabetical order, as is recommended for writing story tables
320
+ let sorted_rows = supported_table.sort( function ( row1, row2 ) {
321
+ if( row1.report_str < row2.report_str ) { return -1; }
322
+ if( row1.report_str > row2.report_str ) { return 1; }
323
+ return 0;
324
+ });
325
+
326
+ // Add full used table to report so it can be copied if needed.
327
+ // TODO: Should we/How do we get this in the final scenario report instead of here?
328
+ await scope.addToReport( scope, { type: `note`, value: `\n${ ' '.repeat(2) }Rows that got set:` });
329
+ await scope.addToReport( scope, { type: `step`, value: `${ ' '.repeat(4) }And I get the question id "${ target_id }" with this data:` });
330
+ await scope.addToReport(scope, { type: `row`, value: `${ ' '.repeat(6) }| var | value | trigger |`, });
331
+ let at_least_one_row_was_NOT_used = false;
332
+ for ( let row of sorted_rows ) {
333
+ if ( row.times_used > 0 ) {
334
+ await scope.addToReport(scope, { type: `row`, value: row.report_str, });
335
+ } else {
336
+ at_least_one_row_was_NOT_used = true;
337
+ }
338
+ }
339
+
340
+ // Add unused rows if needed
341
+ if ( at_least_one_row_was_NOT_used ) {
342
+ await scope.addToReport( scope, { type: `note`, value: `${ ' '.repeat(2) }Unused rows:` });
343
+ for ( let row of sorted_rows ) {
344
+ if ( row.times_used === 0 ) {
345
+ await scope.addToReport(scope, { type: `row`, value: row.report_str, });
346
+ }
347
+ }
348
+ } else {
349
+ await scope.addToReport(scope, { type: `note`, value: `${ ' '.repeat(2) }All rows were used` });
350
+ }
351
+
352
+ await scope.addToReport(scope, { type: `note`, value: `` });
353
+ });
354
+
355
+ //#####################################
356
+ //#####################################
357
+ // Passive/Observational
358
+ //#####################################
359
+ //#####################################
360
+
361
+ // Need to see if it's possible to remove the need for this on most occasions
362
+ // I wait 1 second
363
+ // I wait .5 seconds
364
+ When(/I wait (\d*\.?\d+) seconds?/, async (seconds) => { // √
365
+ // Should this reset the 'navigated' flag?
366
+ // Shoudl any observational things reset the 'navigated' flag?
367
+ // Maybe the 'navitaged' flag should only be reset just before trying to
368
+ // navigate (after pressing a button)
369
+ await scope.afterStep(scope, {waitForTimeout: (parseFloat(seconds) * 1000)});
370
+ });
371
+
372
+ Then(/I take a screenshot ?(?:named "([^"]+)")?/, async ( name ) => {
373
+ /* Save/download a screenshot. `name` does not have to be unique as
374
+ * the filename will be made unique. */
375
+ name = name || '';
376
+ let safe_path = await scope.getSafeScenarioFilename( scope, { prefix: `screenshot_${ name }` });
377
+ await scope.page.screenshot({ path: `./${ safe_path }.jpg`, type: 'jpeg', fullPage: true });
378
+ await scope.afterStep(scope);
379
+ });
380
+
381
+ /* Here to make writing tests more comfortable. */
382
+ When("I do nothing", async () => { await scope.afterStep(scope); }); // √
383
+
384
+ Then('the question id should be {string}', async (question_id) => { // √
385
+ /* Looks for a santized version of the question id as it's written
386
+ * in the .yml. docassemble's way */
387
+ let { question_has_id, id, id_matches } = await scope.examinePageID( scope, question_id );
388
+
389
+ // Trying to make helpful error messages
390
+ let no_id_msg = `Did not find any question id.`;
391
+ if ( !question_has_id ) { await scope.addToReport( scope, { type: `error`, value: no_id_msg }); }
392
+ expect( question_has_id , no_id_msg ).to.be.true;
393
+
394
+ let no_match_msg = `The question id was supposed to be "${ question_id }", but it's actually "${ id }".`;
395
+ if ( !id_matches ) { await scope.addToReport( scope, { type: `error`, value: no_match_msg }); }
396
+ expect( id_matches, no_match_msg ).to.be.true;
397
+
398
+ await scope.afterStep(scope);
399
+ });
400
+
401
+ // Allow emphasis with capital letters
402
+ Then(/I should see the phrase "([^"]+)"/i, async ( phrase ) => { // √
403
+ /* In Chrome, this `innerText` gets only visible text */
404
+ const bodyText = await scope.page.$eval('body', elem => elem.innerText);
405
+
406
+ let msg = `The text "${ phrase }" SHOULD be on this page, but it's NOT.`;
407
+ if ( !bodyText.includes( phrase )) { await scope.addToReport(scope, { type: `error`, value: msg }); }
408
+ expect( bodyText, msg ).to.contain( phrase );
409
+
410
+ await scope.afterStep(scope);
411
+ });
412
+
413
+ // Allow emphasis with capital letters
414
+ Then(/I should not see the phrase "([^"]+)"/i, async ( phrase ) => { // √
415
+ /* In Chrome, this `innerText` gets only visible text:
416
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText
417
+ * "ignores hidden elements" */
418
+ const bodyText = await scope.page.$eval('body', elem => elem.innerText);
419
+
420
+ let msg = `The text "${ phrase }" should NOT be on this page, but it IS here.`;
421
+ if ( bodyText.includes( phrase )) { await scope.addToReport(scope, { type: `error`, value: msg }); }
422
+ expect( bodyText, msg ).not.to.contain( phrase );
423
+
424
+ await scope.afterStep(scope);
425
+ });
426
+
427
+ Then('an element should have the id {string}', async ( id ) => { // √
428
+ const element = await scope.page.$('#' + id);
429
+
430
+ let msg = `No element on this page has the ID "${ id }".`;
431
+ if ( !element ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
432
+ expect( element, msg ).to.exist;
433
+
434
+ await scope.afterStep(scope);
435
+ });
436
+
437
+ // TODO:
438
+ // Just realized it's much more likely that the group name will be
439
+ // repeated than that the choice name will be repeated. Or maybe
440
+ // they're both equally likely to be repeated, but writing about the
441
+ // ordinal of the group would probably be more comfortable for a human.
442
+ // Need to think about this.
443
+
444
+ // let ordinal_regex = '?(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)?';
445
+ // // Haven't figured out ordinal_regex for group label yet
446
+ // // Allow "checkbox" and "radio"? Why would they be called the same thing?
447
+ // // TODO: Remove final pipe in regex
448
+ // let the_checkbox_is_as_expected = new RegExp(`the ${ ordinal_regex } ?(?:"([^"]+)")? (choice|checkbox|radio)(?: button)? ?(?:in "([^"]+)")? is (checked|unchecked|selected|unselected)`);
449
+ // Then(the_checkbox_is_as_expected, async (ordinal, label_text, choice_type, group_label_text, expected_status) => { // √
450
+ /* Non-dropdown non-combobox choices
451
+ * Examples of use:
452
+ * 1. the choice is unchecked
453
+ * 1. the radio is unchecked
454
+ * 1. the checkbox is unchecked
455
+ * 1. the only checkbox is unchecked
456
+ * 1. the third checkbox is checked
457
+ * 1. the "My court" checkbox is unchecked
458
+ * 1. the checkbox in "Which service" is checked
459
+ * 1. the third "My court" checkbox is checked
460
+ * 1. the third checkbox in "Which service" is checked
461
+ * 1. the "My court" checkbox in "Which service" is checked
462
+ * 1. the third "My court" checkbox in "Which service" is checked
463
+ * combos: none; a; b; c; a b; a c; b c; a b c;
464
+ */
465
+ // let roles = [ choice_type ];
466
+ // if ( choice_type === 'choice' ) { roles = ['checkbox', 'radio']; }
467
+
468
+ // let elem = await scope.getSpecifiedChoice(scope, {ordinal, label_text, roles, group_label_text});
469
+
470
+ // // See if it's checked or not
471
+ // let is_checked = await elem.evaluate((elem) => {
472
+ // return elem.getAttribute('aria-checked') === 'true';
473
+ // });
474
+ // let what_it_should_be = expected_status === 'checked' || expected_status === 'selected';
475
+ // expect( is_checked ).to.equal( what_it_should_be );
476
+
477
+ // await scope.afterStep(scope);
478
+ // });
479
+
480
+ // This is phrase is confusing. It sounds like it's going to at least try to continue.
481
+ Then(/I (don't|can't|don’t|can’t|cannot|do not) continue/, async (unused) => { // √
482
+ /* Tests for detection of url change from button or link tap.
483
+ * Can other things trigger navigation? Re: other inputs things
484
+ * like 'Enter' acting like a click is a test for docassemble */
485
+ let msg = `The page should have stopped the user from continuing, but the user was able to continue.`;
486
+ if ( scope.navigated ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
487
+ expect( scope.navigated, msg ).to.be.false;
488
+
489
+ await scope.afterStep(scope);
490
+ });
491
+
492
+ Then('I will be told an answer is invalid', async () => { // √
493
+
494
+ let { was_found, error_handlers } = await scope.checkForError( scope );
495
+
496
+ let no_error_msg = `No error message was found on the page`;
497
+ if ( !was_found ) { await scope.addToReport(scope, { type: `error`, value: no_error_msg }); }
498
+ expect( was_found, no_error_msg ).to.be.true;
499
+
500
+ let was_user_error = error_handlers.complex_user_error !== undefined || error_handlers.simple_user_error !== undefined;
501
+ let not_user_error_msg = `The error was a system error, not an error message to the user. Check the error screenshot.`;
502
+ if ( !was_user_error ) { await scope.addToReport(scope, { type: `error`, value: not_user_error_msg }); }
503
+ expect( was_user_error, not_user_error_msg ).to.be.true;
504
+
505
+ await scope.afterStep(scope);
506
+ });
507
+
508
+ Then('I arrive at the next page', async () => { // √
509
+ /* Tests for detection of url change from button or link tap.
510
+ * Can other things trigger navigation? Re: other inputs things
511
+ * like 'Enter' acting like a click is a test for docassemble */
512
+ let msg = `User did not arrive at the next page.`
513
+ if ( !scope.navigated ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
514
+ expect( scope.navigated, msg ).to.be.true;
515
+ await scope.afterStep(scope);
516
+ });
517
+
518
+ // Link observations have the 'Escape' button link in mind
519
+ Then('I should see the link {string}', async ( linkText ) => { // √
520
+ /* Loosley match link visible text. Should probably deprecate this. */
521
+ let [link] = await scope.page.$x(`//a[contains(text(), "${linkText}")]`);
522
+
523
+ let msg = `Cannot find a link with the text "${ linkText }".`;
524
+ if ( !link ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
525
+ expect( link, msg ).to.exist;
526
+
527
+ await scope.afterStep(scope);
528
+ });
529
+
530
+ Then('I should see the link to {string}', async ( url ) => { // √
531
+ /* Strictly match href of link. */
532
+ let link = await scope.page.$(`*[href="${ url }"]`);
533
+
534
+ let msg = `Cannot find a link to "${ url }".`;
535
+ if ( !link ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
536
+ expect(link, msg ).to.exist;
537
+
538
+ await scope.afterStep(scope);
539
+ });
540
+
541
+ Then(/the "([^"]+)" link leads to "([^"]+)"/, async (linkText, expected_url) => { // √
542
+ /* Find link by its visible text and where it leads. */
543
+ let [link] = await scope.page.$x(`//a[contains(text(), "${linkText}")]`);
544
+ // TODO: Possibly add a check here for the link text, though note this
545
+ // Step is not currently supported.
546
+
547
+ let prop_obj = await link.getProperty('href');
548
+ let actual_url = await prop_obj.jsonValue();
549
+
550
+ let msg = `Cannot find a link with the text "${ linkText }" leading to ${ expected_url }.`;
551
+ if ( actual_url !== expected_url ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
552
+ expect( actual_url ).to.equal( expected_url );
553
+
554
+ await scope.afterStep(scope);
555
+ });
556
+
557
+ Then(/the "([^"]+)" link opens in (a new window|the same window)/, async (linkText, which_window) => { // √
558
+ let [link] = await scope.page.$x(`//a[contains(text(), "${linkText}")]`);
559
+
560
+ let prop_obj = await link.getProperty('target');
561
+ let target = await await prop_obj.jsonValue();
562
+
563
+ let should_open_a_new_window = which_window === 'a new window';
564
+ let opens_a_new_window = target === '_blank';
565
+ let hasCorrectWindowTarget =
566
+ ( should_open_a_new_window && opens_a_new_window )
567
+ || ( !should_open_a_new_window && !opens_a_new_window );
568
+
569
+ let msg = `The link "${ linkText }" does NOT open in ${ which_window }.`;
570
+ if ( !hasCorrectWindowTarget ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
571
+ expect( hasCorrectWindowTarget, msg ).to.be.true;
572
+
573
+ await scope.afterStep(scope);
574
+ });
575
+
576
+ //#####################################
577
+ //#####################################
578
+ // Actions
579
+ //#####################################
580
+ //#####################################
581
+
582
+ //#####################################
583
+ // Possible navigation
584
+ //#####################################
585
+
586
+ // Consider people wanting to use this for an in-interview page
587
+ // Also consider 'the link url "..." should open...'?
588
+ Then(/the "([^"]+)" link opens a working page/, async (linkText) => { // √
589
+ let [link] = await scope.page.$x(`//a[contains(text(), "${linkText}")]`);
590
+ let prop_obj = await link.getProperty('href');
591
+ let actual_url = await prop_obj.jsonValue();
592
+
593
+ let linkPage = await scope.browser.newPage();
594
+ let response = await linkPage.goto(actual_url, {waitUntil: 'domcontentloaded'});
595
+
596
+ let msg = `The "${ linkText }" link is broken.`;
597
+ if ( !response.ok() ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
598
+ expect( response.ok(), msg ).to.be.true;
599
+
600
+ linkPage.close()
601
+
602
+ await scope.afterStep(scope);
603
+ });
604
+
605
+ When('I tap to continue', async () => {
606
+ // Any selectors for this seem somewhat precarious
607
+ // No data will cause an automatic continue when `ensure_navigation` is true
608
+ await scope.setFields( scope, { var_data: [], ensure_navigation: true });
609
+ });
610
+
611
+
612
+ //#####################################
613
+ // UI element interaction
614
+ //#####################################
615
+
616
+ // -------------------------------------
617
+ // --- VARIABLES-BASED STEPS ---
618
+
619
+ When(/I set the var(?:iable)? "([^"]+)" to "([^"]+)"/, async ( var_name, set_to ) => {
620
+ /* Set any variable (with or without a choice associated with it) to a value. Set:
621
+ * - Buttons associated with variables
622
+ * - Dropdowns
623
+ * - Checkboxes (multiple choice checkboxes must be set to "true" or "false")
624
+ * - Radio buttons
625
+ * - Text inputs
626
+ * - Textareas
627
+ *
628
+ * Examples:
629
+ * 1. I set the var "benefits['SSI']" to "false"
630
+ * 1. I set the var "rent_interval" to "weekly"
631
+ */
632
+ let var_data = await scope.normalizeTable( scope, {
633
+ var_data: [{ var: var_name, value: set_to, trigger: '' }],
634
+ });
635
+ // Don't continue if the variable doesn't navigate and if this variable cannot be set
636
+ // on this page, trigger an error
637
+ await scope.setFields( scope, { var_data, ensure_navigation: false, ensure_all_vars_used: true });
638
+ });
639
+
640
+ Then(/I upload "([^"]+)" to "([^"]+)"/, async ( filenames, var_name ) => {
641
+ /* Uploads sibling file (docx, jpg, etc) to input var. Can actually use
642
+ * 'I set the var "" to ""', but this is more explicit if it's desired. */
643
+ let var_data = await scope.normalizeTable( scope, {
644
+ var_data: [{ var: var_name, value: filenames, trigger: '' }],
645
+ });
646
+ // Don't continue if the variable doesn't navigate and if this variable cannot be set
647
+ // on this page, trigger an error
648
+ await scope.setFields( scope, { var_data, ensure_navigation: false, ensure_all_vars_used: true });
649
+ });
650
+
651
+
652
+ // -------------------------------------
653
+ // --- OTHER UI STEPS ---
654
+
655
+ When('I tap the defined text link {string}', async (phrase) => { // hold
656
+ /* Not sure what 'defined' means here. Maybe for terms? */
657
+ const [link] = await scope.page.$x(`//a[contains(text(), "${phrase}")]`);
658
+
659
+ let msg = `The term "${ phrase }" seems to be missing.`;
660
+ if ( !link ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
661
+ expect( link, msg ).to.exist;
662
+
663
+ await link.evaluate( elem => { return elem.click(); });
664
+
665
+ await scope.afterStep(scope, {waitForShowIf: true});
666
+ });
667
+
668
+ Then('I sign', async () => {
669
+ await scope.sign( scope );
670
+ await scope.afterStep(scope);
671
+ });
672
+
673
+ When(/I download "([^"]+)"$/, async (filename) => {
674
+ /* Taps the link that leads to the given filename to trigger downloading.
675
+ * and waits till the file has been downloaded before allowing the tests to continue.
676
+ WARNING: Cannot download the same file twice in a single scenario. */
677
+ let [elem] = await scope.page.$x(`//a[contains(@href, "${ filename }")]`);
678
+
679
+ let msg = `"${ filename }" seems to be missing. Cannot find a link to that document.`;
680
+ if ( !elem ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
681
+ expect( elem, msg ).to.exist;
682
+
683
+ scope.toDownload = filename;
684
+ await elem.evaluate( elem => { return elem.click(); });
685
+ await scope.detectDownloadComplete( scope );
686
+ });
687
+
688
+ //#####################################
689
+ //#####################################
690
+ // Macros
691
+ //#####################################
692
+ //#####################################
693
+
694
+ When(/I set the name of ?(?:the var(?:iable)?)? "([^"]+)" to "([^"]+)"/, async (var_base, name_str) => {
695
+ /* Given a variable name and a space-separated string, fills the
696
+ * appropriate fields on an Assembly Line name question.
697
+ * It handles any of:
698
+ * 1. A one-word value to set name.text (ex: 'LandlordEtAl')
699
+ * 1. An Individual's name that it will set based on the number of words:
700
+ * 1. Two words - a first and last name (ex: "Tal Taylor")
701
+ * 1. Three words - a first, middle, and last name (ex: "Tal Tay Taylor")
702
+ * 1. Four words - a first, middle, last, and suffix (ex: "Tal Tay Taylor III")
703
+ *
704
+ * The string 'the var'/'the variable' part of the step is optional, as you can see below.
705
+ *
706
+ * Examples:
707
+ * 1. I set the name of "user" to "Uli Uster"
708
+ * 1. I set the name of the var "defendant" to "Dalia Day Defo Jr"
709
+ * 1. I set the name of the variable "defendant" to "Dalia Day Defo Jr"
710
+ */
711
+ let name_patterns = [
712
+ [ `text` ],
713
+ [ `first`, `last` ],
714
+ [ `first`, `middle`, `last` ],
715
+ [ `first`, `middle`, `last`, `suffix` ],
716
+ ];
717
+
718
+ let name_parts = name_str.split(' ');
719
+ let desired_pattern = name_patterns[( name_parts.length - 1 )];
720
+
721
+ for ( let parti = 0; parti < name_parts.length; parti++ ) {
722
+ let var_name = `${ var_base }.name.${ desired_pattern[ parti ] }`
723
+ let value = name_parts[ parti ];
724
+ await scope.setVar( scope, var_name, value );
725
+ }
726
+
727
+ // Continue to next page...?
728
+ await scope.afterStep(scope, {waitForShowIf: true});
729
+ });
730
+
731
+
732
+ When(/I set the address of ?(?:the var(?:iable)?)? "([^"]+)" to "([^"]+)"/, async (var_base, address_str) => {
733
+ /* Given a variable name and a four-part address formatted as below,
734
+ * sets the appropriate Assembly Line address fields.
735
+ *
736
+ * The string 'the var'/'the variable' part of the step is optional, as you can see below.
737
+ *
738
+ * Examples:
739
+ * 1. When I set the address of "users[0]" to "112 Southampton St., Unit 1, Boston, MA 02118"
740
+ * 1. When I set the address of the variable "users[0]" to "112 Southampton St., Unit 1, Boston, MA 02118"
741
+ * 1. When I set the address of the var "users[0]" to "112 Southampton St., Unit 1, Boston, MA 02118"
742
+ */
743
+ // Get all the parts of the address properly separated
744
+ // First, split apart each line of the address to [address line 1, address line 2, city, state zip]...
745
+ let split = address_str.split(/,\s*/);
746
+ // ...then, separate the state and the zip code...
747
+ let last_parts = (split.pop()).split(/\s+/);
748
+ // ...and add them back in, to end up with [address line 1, address line 2, city, state, zip].
749
+ let address_parts = split.concat( last_parts );
750
+ let start = `${ var_base }.address.`;
751
+
752
+ let index = 0;
753
+ await scope.setVar( scope, `${start}address`, address_parts[0] );
754
+ // Ensure it's without 'Unit '/'unit '
755
+ await scope.setVar( scope, `${start}unit`, (address_parts[1].toLowerCase()).replace(/unit\s*/, '') );
756
+ await scope.setVar( scope, `${start}city`, address_parts[2] );
757
+ await scope.setVar( scope, `${start}state`, address_parts[3] );
758
+ await scope.setVar( scope, `${start}zip`, address_parts[4] );
759
+
760
+ await scope.afterStep(scope, {waitForShowIf: true});
761
+ });
762
+
763
+ // Next macro - download sequence?
764
+
765
+
766
+
767
+ //#####################################
768
+ //#####################################
769
+ // Framework Development
770
+ //#####################################
771
+ //#####################################
772
+ // TODO: Add Step for testing final report
773
+
774
+ Given(/the Scenario report should include/i, async ( report_text ) => {
775
+ /* WARNING: If a Step will fail, this Step must come first.
776
+ *
777
+ * Prepare to test that the report for a Scenario includes the given text.
778
+ * The actual test has to be done at the end of the Scenario as some text
779
+ * may be added to the report there. */
780
+ if ( !scope.expected_in_report ) {
781
+ scope.expected_in_report = [];
782
+ }
783
+ scope.expected_in_report.push( report_text );
784
+
785
+ });
786
+
787
+ Given(/the final Scenario status should be "(passed|failed)"/i, async ( expected_status ) => {
788
+ /* WARNING: If a Step will fail, this Step must come first.
789
+ *
790
+ * Prepare to test that the Scenario completes with the correct status. */
791
+ scope.expected_status = expected_status;
792
+ });
793
+
794
+
795
+ //#####################################
796
+ //#####################################
797
+ // After
798
+ //#####################################
799
+ //#####################################
800
+
801
+ After(async function(scenario) {
802
+
803
+ // Record status before internal report tests can interfere with values
804
+ await scope.setReportScenarioStatus( scope, { status: scenario.result.status });
805
+
806
+ if (scenario.result.status !== `passed`) {
807
+ if ( env_vars.DEBUG ) {
808
+ // When debugging locally, pause to allow dev to examine the page
809
+ console.log( `Error occurred while running test` );
810
+ await scope.page.waitFor( 60 * 1000 );
811
+ }
812
+
813
+ await scope.addToReport(scope, { type: `outcome`, value: `**-- Scenario Failed --**` });
814
+ // Save/download a picture of the screen during the error
815
+ let safe_filename = await scope.getSafeScenarioFilename( scope, { prefix: `error` });
816
+ await scope.page.screenshot({ path: `${ safe_filename }.jpg`, type: `jpeg`, fullPage: true });
817
+
818
+ }
819
+
820
+ let original_scenario_status = scenario.result.status;
821
+
822
+ // Internal tests of this framework
823
+ if ( scope.expected_status ) {
824
+ let expected = scope.expected_status;
825
+ scope.expected_status = null; // reset for next Scenario
826
+ expect( scenario.result.status ).to.equal( expected );
827
+ }
828
+ if ( scope.expected_in_report ) {
829
+ let expected = scope.expected_in_report;
830
+ // Reset report values no matter what so they don't mess up future scenarios
831
+ // Maybe this should be in `Before`, but it would be so out of context there...
832
+ scope.expected_in_report = [];
833
+ let all_were_included = await scope.reportIncludesAllExpected( scope, { expected });
834
+ if ( all_were_included ) { scenario.result.status = `passed`; }
835
+ }
836
+
837
+ // If there is a page open, then sign out and close it
838
+ if ( scope.page ) {
839
+ // Make sure the new interview really starts fresh. Sometimes insufficient.
840
+ // Also, often is a failure point.
841
+ // Consider, instead, catching the reload error for a bad restart and just
842
+ // re-trying at that point.
843
+ try {
844
+ await scope.page.goto(`${ env_vars.BASE_URL }/user/sign-out`, {waitUntil: `domcontentloaded`});
845
+ } catch ( err ) {
846
+ await scope.addToReport(scope, {
847
+ type: `warning`,
848
+ value: `Trying to reset for the next test a second time because the first time failed.`
849
+ });
850
+ try {
851
+ await scope.page.goto(`${ env_vars.BASE_URL }/user/sign-out`, {waitUntil: `domcontentloaded`});
852
+ } catch ( err ) {
853
+ await scope.addToReport(scope, {
854
+ type: `warning`,
855
+ value: `This test ${ original_scenario_status }, but also there was an error while trying to reset for the next test. Re-running until you don't get this error would probably be a good idea.`
856
+ });
857
+ }
858
+ }
859
+
860
+ await scope.page.close();
861
+ scope.page = null;
862
+ }
863
+ });
864
+
865
+ AfterAll(async function() {
866
+ let report = await scope.getPrintableReport( scope );
867
+ // Log the report. Can't get this.attach() or this.log() to work in `After()` or here.
868
+ // May need newest version of cucumberjs
869
+ console.log( `\n\n${ report }` );
870
+
871
+ // Save/download the report as an artifact
872
+ fs.writeFileSync( `alkiln_report_${ Date.now() }.txt`, report );
873
+
874
+ // If there is a browser open, then close it
875
+ if (scope.browser) {
876
+ await scope.browser.close();
877
+ }
878
+ });