@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/scope.js ADDED
@@ -0,0 +1,1632 @@
1
+ const { expect } = require('chai');
2
+ const cheerio = require('cheerio');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ const safe_filename = require("sanitize-filename");
7
+ const env_vars = require('./utils/env_vars');
8
+
9
+
10
+ let ordinal_to_integer = {
11
+ only: 0, first: 0, second: 1, third: 2, fourth: 3, fifth: 4,
12
+ sixth: 5, seventh: 6, eighth: 7, ninth: 8, tenth: 9,
13
+ '1st': 0, '2nd': 1, '3rd': 2, '4th': 3, '5th': 4,
14
+ '6th': 5, '7th': 6, '8th': 7, '9th': 8,'10th': 9,
15
+ };
16
+
17
+ let escape_regex = function( to_escape ) {
18
+ // https://stackoverflow.com/a/3561711
19
+ return to_escape.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
20
+ };
21
+
22
+ let proxies_regex = /(\bx\.)|(\bx\[)|(\[[ijklmn]\])/g;
23
+
24
+ module.exports = {
25
+ addToReport: async function( scope, { type, value }) {
26
+ /* Add an item to a specific list in a scenario's report.
27
+ * Create report if necessary.
28
+ *
29
+ * The report has a key for each scenario and each of those
30
+ * keys should correspond to a list containing the relevant data. */
31
+ if ( env_vars.DEBUG ) { console.log(`Report:\n type: ${ type }, value: ${ value }`) }
32
+ let scenario = await scope.makeSureReportPartsExist( scope );
33
+ scenario.lines.push({ type, value });
34
+
35
+ return scenario;
36
+ }, // Ends scope.addToReport()
37
+
38
+ makeSureReportPartsExist: async function( scope ) {
39
+ /* If a part of the report needs to exist right now, but doesn't exist,
40
+ * create it. */
41
+ if ( !scope.report ) { scope.report = new Map(); }
42
+ if ( !scope.scenario_id ) { scope.scenario_id = `_report_${ Date.now() }` }
43
+ if ( !scope.report.get( scope.scenario_id )) {
44
+ scope.report.set( scope.scenario_id, {} )
45
+ scope.report.get( scope.scenario_id ).status = `in progress`;
46
+ }
47
+
48
+ let scenario = scope.report.get( scope.scenario_id );
49
+ if ( !scenario.lines ) { scenario.lines = []; }
50
+
51
+ return scenario;
52
+ }, // Ends scope.makeSureReportPartsExist()
53
+
54
+ setReportScenarioStatus: async function( scope, { status=`passed` }) {
55
+ /* Add final status of scenario to scenario report object - passed, failed, etc. */
56
+ scope.report.get( scope.scenario_id ).status = status;
57
+ }, // Ends scope.setReportScenarioStatus()
58
+
59
+ convertToOriginalStoryTableRow: async function( scope, { row_data }) {
60
+ /* Returns the original data of row into a string formated as a story table row.
61
+ * Should this show the actual row data that was used instead? That could
62
+ * be pretty confusing to most devs. */
63
+ let source_row = row_data.source || row_data;
64
+ let original = source_row.original;
65
+ let var_str = await scope.toTrimmedJSON( scope, { str: original.var });
66
+ let value_str = await scope.toTrimmedJSON( scope, { str: original.value });
67
+ let trigger_str = await scope.toTrimmedJSON( scope, { str: original.trigger });
68
+ return `${ ' '.repeat(6) }| ${ var_str } | ${ value_str } | ${ trigger_str } |`;
69
+ }, // Ends scope.convertToOriginalStoryTableRows()
70
+
71
+ toTrimmedJSON: async function( scope, { str='' }) {
72
+ /* Turn into JSON string then trim quotes from the start and end. */
73
+ let json = JSON.stringify( str );
74
+ let trimmed = json.replace( /^"/, '' ).replace( /"$/, '' ).replace( /\\\\/g, `\\`);
75
+ return trimmed;
76
+ }, // Ends scope.toTrimmedJSON()
77
+
78
+ addReportHeading: async function (scope, { scenario }) {
79
+ /** Return printable heading based on Scenario name and tags.
80
+ * Allow characters from other languages.
81
+ */
82
+ if ( env_vars.DEBUG ) { console.log( scenario.pickle.name ); }
83
+
84
+ // Collect all tag names
85
+ let tag_names = [];
86
+ for ( let tag of scenario.pickle.tags ) {
87
+ tag_names.push( tag.name );
88
+ }
89
+
90
+ let heading = `\n---------------\n`
91
+ + `Scenario: ${ scenario.pickle.name }`;
92
+
93
+ if ( tag_names.length > 0 ) {
94
+ heading += `\nTags: ${ tag_names.join(' ') }`;
95
+ }
96
+
97
+ heading += `\n---------------`;
98
+
99
+ await scope.addToReport( scope, {
100
+ type: `heading`,
101
+ value: heading,
102
+ });
103
+ }, // Ends scope.addReportHeading()
104
+
105
+ getPrintableReport: async function( scope ) {
106
+ /* Return a string genereated from test report data. */
107
+ let report = `Assembly Line Kiln Automated Testing Report - ${ (new Date()).toUTCString() }\n`;
108
+
109
+ // First list failed scenarios, then non-passed scenarios, then passed scenarios
110
+ let failed_reports = ``;
111
+ let passed_reports = ``;
112
+ let other_reports = ``;
113
+ for ( let scenario of scope.report ) {
114
+
115
+ let report = await scope.getPrintableScenario( scope, { scenario });
116
+ let [ scenario_id, data ] = scenario;
117
+ if ( data.status === `failed` ) { failed_reports += report; }
118
+ else if ( data.status === `passed` ) { passed_reports += report; }
119
+ else { other_reports += report; }
120
+ } // ends for every scenario
121
+
122
+ if ( failed_reports !== `` ) { report += `\n\n===============================\n===============================\nFailed scenarios:\n${ failed_reports }`; }
123
+ if ( other_reports !== `` ) { report += `\n\n===============================\n===============================\nScenarios that did not pass:\n${ other_reports }`; }
124
+ if ( passed_reports !== `` ) { report += `\n\n===============================\n===============================\nPassed scenarios:\n${ passed_reports }`; }
125
+
126
+ return report;
127
+ }, // Ends scope.getPrintableReport()
128
+
129
+ getPrintableScenario: async function( scope, { scenario }) {
130
+ /* Return a string genereated from scenario list of report items. */
131
+ let [ scenario_id, data ] = scenario;
132
+ let report = ``;
133
+ for ( let line of data.lines ) {
134
+ if ( line.type === `page_id` ) { report += `screen id: `; }
135
+ else if ( line.type === `warning` ) { report += `WARNING: `; }
136
+ else if ( line.type === `error` ) { report += `\nERROR: ` }
137
+ report += `${ line.value }\n`;
138
+ }
139
+ return report;
140
+ }, // Ends scope.getPrintableScenario()
141
+
142
+ getSafeScenarioBaseFilename: async function( scope, { scenario }) {
143
+ /** Return a string that's safe to be a filename. Include the Scenario
144
+ * description, tags, and language.
145
+ */
146
+ let name_parts = [];
147
+
148
+ // Try to order filenames by language if possible/needed
149
+ if ( scope.language ) {
150
+ name_parts.push( scope.language );
151
+ }
152
+ name_parts.push( scenario.pickle.name );
153
+ for ( let tag of scenario.pickle.tags ) {
154
+ name_parts.push( tag.name );
155
+ }
156
+
157
+ let filename = name_parts.join(`-`);
158
+
159
+ return safe_filename( filename, { replacement: `_` });
160
+ }, // Ends scope.getSafeScenarioBaseFilename()
161
+
162
+ getSafeScenarioFilename: async function( scope, options={} ) {
163
+ /* Return a string that's safe to be a filename. Use a timestamp,
164
+ * a prefix the caller wants, and the base filename. We hope it can
165
+ * contain non-english characters too. We do not offer a suffix option
166
+ * because it may be cut off if the filename is too long.
167
+ */
168
+ // Ensure prefix is a string
169
+ let prefix = options.prefix || '';
170
+ // As long as it's not an empty string, add a separator between it and the rest of the name
171
+ // For now, it needs to be `_` for the action and .gitignore. In future,
172
+ // aiming for `-`.
173
+ if ( prefix ) {
174
+ prefix = `${ prefix }_`;
175
+ }
176
+
177
+ let safe_name = safe_filename(`${ prefix }${ Date.now() }-${ scope.base_filename }`, { replacement: `_` });
178
+ let safer_name = safe_name.substring(0, (255 - 10)); // Allow room for extensions
179
+ return safer_name;
180
+ }, // Ends scope.getSafeScenarioFilename()
181
+
182
+ waitForShowIf: async function waitForShowIf(scope) {
183
+ // If something might be hidden or shown, wait extra time to let it hide or show
184
+ // I think `.$()` does not use a timeout
185
+ let showif = await scope.page.$('.dashowif');
186
+ if ( showif ) { await scope.page.waitFor( scope.showif_timeout ); }
187
+ },
188
+
189
+ afterStep: async function afterStep(scope, options = {waitForShowIf: false, navigated: false, waitForTimeout: 0}) {
190
+ /* Common things to do after a step, including take care of errors.
191
+ * TODO: discuss splitting into `afterField` and `afterStep` or something. Or just change name
192
+ * TODO: Update to latest cucumberjs, which as `AfterStep` */
193
+
194
+ // Use a while loop until the below does not error with `Error: Execution context was destroyed`
195
+ // Temporary fix for race condition. See https://github.com/plocket/docassemble-cucumber/issues/190
196
+ let error_id_elem;
197
+ while ( true ) {
198
+ try {
199
+ // Waiting for `page` to exist. '#da-retry' does not need to exist, it's incidental at this point.
200
+ // If this doesn't throw an error, then the page exists and the result is valid.
201
+ error_id_elem = await scope.page.$('#da-retry');
202
+ break;
203
+ } catch ( err ) {}
204
+ }
205
+
206
+ // If there's a system error, throw it
207
+ if ( error_id_elem ) {
208
+ let error_elem = await scope.page.$('blockquote')
209
+ let error_text_prop = await error_elem.getProperty( 'textContent' );
210
+ let system_error_text = await error_text_prop.jsonValue();
211
+
212
+ await scope.addToReport(scope, { type: `error`, value: system_error_text });
213
+ throw system_error_text;
214
+ };
215
+
216
+ // RESET
217
+ if ( options.navigated ) { scope.navigated = true; } // Consider `daPageLoad` event
218
+ else { scope.navigated = false; }
219
+
220
+ // WAITING
221
+ if ( options.waitForShowIf ) { await scope.waitForShowIf(scope); }
222
+ // Plain old waiting. Yeah, I abstracted it into here so only
223
+ // one thing was being called at the end
224
+ if ( options.waitForTimeout ) { await scope.page.waitFor( options.waitForTimeout ); }
225
+ }, // Ends afterStep
226
+
227
+ tapElement: async function tapElement(scope, elem) {
228
+ /* Tap a given element, detect navigation, and do appropriate related things. */
229
+
230
+ // This check might never be needed now as we try to match fields on the page
231
+ // first in user-triggered cases - users used to be able to access this almost
232
+ // directly, but they can't anymore. Our functions should know to only pass good things.
233
+ let msg = `Cannot find that element.`;
234
+ if ( !elem ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
235
+
236
+ expect( elem, msg ).to.exist;
237
+
238
+ let start_url = await scope.page.url(); // so we can later check for navigation
239
+
240
+ let winner;
241
+ winner = await Promise.race([
242
+ Promise.all([ // Error loads page, so no need to detect to keep things short
243
+ // Click with no navigation will end immediately
244
+ // Can't use elem[ scope.activate ](). It works locally, but errors on GitHub
245
+ // with "Node is not visible or not an HTMLElement". We're not using a custom Chromium version
246
+ // Unfortunately, this won't catch an invisible element. Hopefully we'd catch it before now...?
247
+ // "Tap" on mobile is more complex to implement ourselves.
248
+ // See https://stackoverflow.com/a/56547605/14144258
249
+ // and other convos around it. We haven't made it public that the device
250
+ // can customized. Until we do that, we'll just use "click" all the time.
251
+ elem.evaluate( (el) => { return el.click(); }),
252
+ scope.page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
253
+ ]),
254
+ // This is meant to detect user-visible alerts/errors,
255
+ // not breaking errors in the testing code.
256
+ scope.page.waitForSelector('.alert-danger', { visible: true }),
257
+ scope.page.waitForSelector('.da-has-error', { visible: true }),
258
+ ]);
259
+
260
+ // TODO: Not sure how to detect navigation landing at same page.
261
+ // Maybe url change + trigger var change? Maybe just trigger var?
262
+ let end_url = await scope.page.url();
263
+ let navigated = start_url !== end_url;
264
+
265
+ // Might be able to handle this in `scope.afterStep`
266
+ if ( navigated ) {
267
+ // Save the id that comes next
268
+ let { question_has_id, id, id_matches } = await scope.examinePageID( scope, 'none to match' );
269
+ // If navigation, wait for 200ms to let things settle down
270
+ await scope.afterStep(scope, {waitForTimeout: 200, navigated: navigated});
271
+ } else {
272
+ // If stay on page, wait for `show if` or feedback elements to be move around
273
+ await scope.afterStep(scope, {waitForShowIf: true, navigated: navigated});
274
+ }
275
+
276
+ return winner;
277
+ }, // Ends scope.tapElement()
278
+
279
+ detectDownloadComplete: async function detectDownloadComplete(scope, endTime) {
280
+ /* Resolve if a download flag has changed. Timeout after a bit less than
281
+ * the global permitted timeout */
282
+
283
+ // Stop/resolve if too much time has passed
284
+ let expirationTime = scope.timeout - 200; // ms
285
+ if ( !endTime ) { endTime = new Date((new Date()).getTime() + expirationTime); }
286
+ let diff = endTime.getTime() - Date.now();
287
+ if ( diff <= 0 ) { return false; }
288
+
289
+ // If time hasn't expired
290
+ if ( scope.downloadComplete ) {
291
+ // If download is complete, reset the flag and resolve
292
+ scope.downloadComplete = false;
293
+ return true;
294
+ } else {
295
+ // Otherwise wait and then check again
296
+ await scope.page.waitFor( 100 );
297
+ await scope.detectDownloadComplete(scope, endTime);
298
+ }
299
+
300
+ return false;
301
+ },
302
+
303
+ setVar: async function ( scope, var_name, value ) {
304
+ /* Use variable names and values to interact with buttons that
305
+ * have values, text input elements, textareas, and dropdowns.
306
+ * Returns the element that was found.
307
+ *
308
+ * TODO:
309
+ * - Birthdate fields that will be showing up relatively soon
310
+ * - Time fields?
311
+ */
312
+ let base64_var_name = await scope.toBase64( scope, { utf8_str: var_name });
313
+
314
+ // Will catch some radio and checkbox inputs too, which will then be rejected
315
+ // Note: I think `showif`s with `code` don't use `data-saveas`
316
+ let to_manipulate = await Promise.race([
317
+ scope.page.waitForSelector(`button[name="${base64_var_name}"][value="${value}"]`), // button
318
+ scope.page.waitForSelector(`:not(button)[name="${base64_var_name}"]`), // others (not showif)
319
+ scope.page.waitForSelector(`*[data-saveas="${base64_var_name}"] input`), // text input showif
320
+ scope.page.waitForSelector(`*[data-saveas="${base64_var_name}"] textarea`), // textarea showif
321
+ scope.page.waitForSelector(`*[data-saveas="${base64_var_name}"] select`), // select showif/hideif
322
+ ]);
323
+
324
+ // Throw error for checkboxes and radios
325
+ let type = await to_manipulate.evaluate( elem => { return elem.getAttribute('type') });
326
+ if ( type === `checkbox` || type === `radio` ) {
327
+ throw new TypeError(`You need the ${type} step, e.g. 'I tap the ${type} variable "some_dict" with the value "choice_value"'`);
328
+ }
329
+
330
+ // Actions based on element
331
+ let tagName = await to_manipulate.evaluate( elem => { return elem.tagName });
332
+ tagName = tagName.toLowerCase();
333
+
334
+ if ( tagName === `button` ) { // Tap buttons
335
+ await scope.tapElement( scope, to_manipulate );
336
+
337
+ } else if ( tagName === `select` ) { // Choose drop-down option
338
+ await to_manipulate.select(value);
339
+
340
+ } else if ( tagName === `input` || tagName === `textarea` ) {
341
+ // Set text field values (includes any other input fields)
342
+ await to_manipulate.evaluate( el => { el.value = '' });
343
+ await to_manipulate.type( value );
344
+ // TODO: Add hideif to this too
345
+ }
346
+
347
+ // Do your own waiting if you need to in the step
348
+ return to_manipulate;
349
+ }, // Ends setVar()
350
+
351
+ continue: async function ( scope ) {
352
+ /* Presses whatever button it finds that might lead to the next page. */
353
+ // Any selectors I find seem somewhat precarious.
354
+ let elem = await Promise.race([
355
+ scope.page.waitForSelector(`fieldset.da-field-buttons button[type="submit"]`), // other pages (this is the most consistent way)
356
+ scope.page.waitForSelector(`fieldset .dasigsave`), // signature page
357
+ ]);
358
+ let classname = await elem.evaluate( el => { return el.className });
359
+ // Waits for navigation or user error
360
+ await scope.tapElement( scope, elem );
361
+ },
362
+
363
+ load: async function ( scope, file_name ) {
364
+ /* Try to load the page. Should we also pass a `timeout` arg or just
365
+ * use scope.timeout? */
366
+
367
+ // If there is no browser open, start a new one
368
+ if (!scope.browser) {
369
+ scope.browser = await scope.driver.launch({ headless: !env_vars.DEBUG });
370
+ }
371
+ if ( !scope.page ) { scope.page = await scope.browser.newPage(); }
372
+
373
+ if ( !file_name.includes('.yml') ) { file_name = `${ file_name }.yml` }
374
+ let interview_url = `${ env_vars.getBaseInterviewURL() }${ file_name }`;
375
+ if ( env_vars.DEBUG ) { console.log( interview_url ); }
376
+
377
+ await scope.page.goto(interview_url, { waitUntil: 'domcontentloaded', timeout: scope.timeout })
378
+
379
+ let load_result = await scope.getLoadData( scope );
380
+ // Errors, or reaching timeout, causes an excpetion and is shown to the developer at the end
381
+ if ( load_result.error ) {
382
+ await scope.addToReport(scope, { type: `error`, value: `On final attempt to load interview, got "${ load_result.error }"` });
383
+ expect( load_result.error ).to.equal( '' );
384
+ }
385
+
386
+ return scope.page;
387
+ }, // Ends scope.load()
388
+
389
+ getLoadData: async function ( scope ) {
390
+ /* Return one of two elements that could load on the page - #daMainQuestion
391
+ * or #da-retry. If there's a system error on the page, return its main text.
392
+ *
393
+ * If server isn't loaded yet, it should get the browser timeout error,
394
+ * which won't match either of those and this will timeout and, I assume, error.*/
395
+ let result = { error: ``, elem: null };
396
+
397
+ let winner = await Promise.race([
398
+ scope.page.waitForSelector( `#daMainQuestion`, { timeout: scope.timeout }),
399
+ scope.page.waitForSelector( `#da-retry`, { timeout: scope.timeout }),
400
+ ]);
401
+
402
+ result.elem = winner;
403
+
404
+ let error_id_elem = await scope.page.$( `#da-retry` );
405
+ if ( error_id_elem ) {
406
+ let error_elem = await scope.page.$( `blockquote` );
407
+ let error_handle = await error_elem.getProperty( `textContent` );
408
+ let system_error_text = await error_handle.jsonValue();
409
+
410
+ result.error = system_error_text;
411
+ }
412
+
413
+ return result;
414
+ }, // Ends scope.getLoadData()
415
+
416
+ setLanguage: async function ( scope, { language }) {
417
+ // Tap the language button if given
418
+ let lang_url = null;
419
+ if ( language ) {
420
+
421
+ let [lang_link] = await scope.page.$x(`//a[text()="${ language }"]`);
422
+
423
+ let msg = `Could not find the link with the text "${ language }"`;
424
+ if ( !lang_link ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
425
+ expect( lang_link, msg ).to.exist; // Cause test failure
426
+
427
+ await scope.tapElement( scope, lang_link );
428
+ }
429
+ }, // Ends scope.setLanguage()
430
+
431
+ normalizeTable: async function ( scope, { var_data, from_story_table=true }) {
432
+ /* Return data derived from cucumber variable-setting data.
433
+ * Table must have headers. Arrays are not supported. */
434
+
435
+ // Support tables with no 'trigger' column
436
+ let supported_table = [];
437
+ for ( let row of var_data ) {
438
+ // See tests > unit_tests > tables.fixtures.js > tables.old_to_current_formatting
439
+ let result = {
440
+ original: row,
441
+ // Give fake data if needed to prevent some kind of hidden input field from being found
442
+ // See bug at https://github.com/plocket/docassemble-cucumber/issues/79
443
+ // May be able to remove this once converted to `getAllFields`
444
+ var: row.var || 'al_no_var_name',
445
+ value: row.value,
446
+ trigger: row.trigger || '',
447
+ times_used: 0,
448
+ }
449
+
450
+ supported_table.push( result );
451
+ }
452
+
453
+ if ( env_vars.DEBUG ) { console.log( 'scope.normalizedTable():\n', JSON.stringify( supported_table )); }
454
+
455
+ return supported_table;
456
+ }, // Ends scope.normalizeTable()
457
+
458
+ getAllFields: async function ( scope, { html }) {
459
+ /* Given HTML string of a page, returns a list of all the fields in it
460
+ * as some kind of sophisticated data? With their selectors, for example.
461
+ * We will choose selectors that will continue to identify the node, eve
462
+ * after a hidden field is revealed, etc.
463
+ * TODO: Not sure where to bring in cheerio
464
+ * TODO: Pass in `$` instead? */
465
+ let fields = [];
466
+
467
+ const $ = cheerio.load( html ); // Could this be on `scope`?
468
+
469
+ // Get trigger var
470
+ // This is the new trigger var element that developers will have to add to their interviews.
471
+ // AssemblyLine will add it automatically unless someone overrides the values in their own interview.
472
+ // See the report message to the dev below for a link to an example.
473
+ let trigger_elem = $( `#trigger` );
474
+ if ( !trigger_elem.length ) { trigger_elem = $( `#sought_variable` ); } // Backwards compatibility
475
+ let encoded_trigger_var = $( trigger_elem ).data( `variable` ); // This should come out to 'None' at the very least
476
+ if ( !encoded_trigger_var ) {
477
+ // TODO: Add documentation and then link to it in this message.
478
+ await scope.addToReport(scope, { type: `warning`, value: `You are missing an element containing the data for the page's trigger variable. Some fields, like loops with index variables, may not work correctly in these tests. Talk to the folks at the AssemblyLine project to learn more or see an example here: https://github.com/plocket/docassemble-ALAutomatedTestingTests/blob/ad1232049d7ca7a9b97b22f8ca3915dd3dae8114/docassemble/ALAutomatedTestingTests/data/questions/all_tests.yml#L6-L10.` });
479
+ encoded_trigger_var = await scope.toBase64( scope, { utf8_str: `None` });
480
+ }
481
+ let trigger = await scope.fromBase64( scope, { base64_str: encoded_trigger_var });
482
+
483
+
484
+ // Some `show if` fields have `names` like '_field_n' instead of their var name. Ex: _field_1
485
+ let field_like_names = await scope.getFieldNamesDict( scope, { $: $ });
486
+
487
+ // All the different types of fields
488
+ // buttons, canvases, inputs of all kinds, selects (dropdowns), textareas. Are there more?
489
+ // Will deal with `option` once inside `select`
490
+ let all_nodes = $( `#dasigpage, fieldset button, .daquestionactionbutton, fieldset input, .da-form-group input, .da-form-group select, .da-form-group textarea` );
491
+ for ( let node of all_nodes ) {
492
+ // Decision: Do not abstract the below. There's too much data to pass around for it to make sense
493
+ let $node = $( node );
494
+ let field_data = {
495
+ selector: '',
496
+ tag: node.name,
497
+ guesses: [],
498
+ type: '',
499
+ trigger: trigger,
500
+ };
501
+ if ( node.attribs.type ) { field_data.type = node.attribs.type; }
502
+
503
+ // canvas elements are the only field on a signature page
504
+ if ( $(`#dasigpage`).length > 0 && $(`#dasigpage`)[0].name ) {
505
+ field_data.selector = `#daquestion canvas`;
506
+ field_data.tag = `canvas`;
507
+ field_data.guesses = [{
508
+ var: trigger,
509
+ value: '/sign',
510
+ }];
511
+ fields.push( field_data );
512
+ continue;
513
+ }
514
+
515
+ // The selector we'll use to set the DOM node's value later
516
+ field_data.selector = await scope.getFieldSelector( scope, { $, node });
517
+
518
+ // Often used in the variable name
519
+ let encoded_name = $node.attr( `name` );
520
+ // Action buttons keep their var name in a unique place
521
+ if ( $( node ).hasClass( 'daquestionactionbutton' )) {
522
+ let href = node.attribs.href;
523
+ let match = href.match( /action=([^&]*)/ );
524
+ if ( match ) {
525
+ encoded_name = match[1];
526
+ } else {
527
+ // Some action buttons just use urls instead of da actions
528
+ encoded_name = href.replace(/^[A-Za-z0-9]*/, '_');
529
+ }
530
+ }
531
+
532
+ // ====================
533
+ // Table rows
534
+ // ====================
535
+ // Default values
536
+ let var_names = await scope.getPossibleVarNames( scope, {
537
+ encoded: encoded_name,
538
+ field_like_names,
539
+ nota: false, // none of the above (detected and overwritten later)
540
+ });
541
+
542
+ if ( $node.hasClass('danota-checkbox') || $node.parents(`.da-field-container-datatype-object_radio`).name ) {
543
+ // 'None of the above' fields must find var name info from siblings' attributes
544
+ let first_sib = $(node.parent).find( 'input' )[0];
545
+ var_names = await scope.getPossibleVarNames( scope, {
546
+ encoded: first_sib.attribs.name,
547
+ field_like_names,
548
+ nota: true, // none of the above
549
+ });
550
+ }
551
+
552
+ // button, radio, checkbox yesno, and maybe others need the value of the `value` attribute
553
+ let values = [];
554
+ if ( node.name === `select` ) {
555
+ values = await scope.getOptionValues( scope, { $select: $node });
556
+ }
557
+ if ( values.length === 0 && node.attribs.value ) {
558
+ // TODO: Add links to docs about da YAML values that devs never fill in themselves and
559
+ // so don't know what they are. E.g. yesnomabye -> True/False/None.
560
+ // TODO: Text inputs and textareas must ignore the value, etc. How?
561
+ values.push( node.attribs.value );
562
+ }
563
+
564
+ field_data.guesses = await scope.getPossibleTableRowMatchers( scope, { var_names, values });
565
+ fields.push( field_data );
566
+ } // ends for every node
567
+
568
+ if ( env_vars.DEBUG ) { console.log( `page fields:\n${ JSON.stringify( fields )}`); }
569
+ return fields;
570
+ }, // Ends scope.getAllFields()
571
+
572
+ getFieldSelector: async function ( scope, { $, node }) {
573
+ /* Given the page object and the node object, return the selector
574
+ * we'll use to set the field's value later. Excludes `canvas`.
575
+ *
576
+ * What shall we select? Checkbox and radio input elements are
577
+ * hidden and puppeteer does not interact with hidden elements, so
578
+ * it would need their labels. Should we still get the `input`
579
+ * nodes and use `.click()` them ourselves? Is there a downside?
580
+ *
581
+ */
582
+ // This even gets something unique enough for 'none of the above' checkboxes
583
+ let selector = `${ node.name }`; // element tag
584
+ if ( node.attribs.name ) { selector += `[name="${ node.attribs.name }"]`; }
585
+ if ( node.attribs.value ) { selector += `[value="${ node.attribs.value }"]`; }
586
+ if ( node.attribs.id ) { selector += `[id="${ node.attribs.id }"]`; }
587
+ if ( node.attribs.class ) { selector += `[class="${ node.attribs.class }"]`; }
588
+ if ( $( node ).attr( 'data-linknum' )) { selector += `[data-linknum="${ $( node ).attr( 'data-linknum' ) }"]`; }
589
+ // Everything should be in #daquestion and that's also how our unit tests and fixtures work.
590
+ selector = '#daquestion ' + selector;
591
+
592
+ // Make sure this selector matches only one node
593
+ let matching_nodes = $( selector );
594
+ // This check is here to ensure saftey of future development. If there is more
595
+ // or less than one match, this code may have unexpected behavior later.
596
+ // Really need everything to be unique here if at all possible. Shall we resort
597
+ // to parents and n-th child?
598
+ // Exceptions: Some specific fields can be duplicates without being dangerous
599
+ if ( matching_nodes.length > 1 && !selector.includes(`[class="file-caption-name"]`) ) {
600
+ let msg = `${ matching_nodes.length } items on this page matched the base64 encoded name '${ selector }'. You may be setting the same variable in multiple places on this page.`;
601
+ await scope.addToReport(scope, { type: `warning`, value: msg });
602
+ }
603
+
604
+ return selector;
605
+ }, // Ends scope.getSelector()
606
+
607
+ getPossibleTableRowMatchers: async function ( scope, { var_names, values }) {
608
+ /* Return variable names and values combined in ways that can
609
+ * try to match table rows. Tables can have two properties:
610
+ * variable name and value. */
611
+ let possible_table_rows = [];
612
+ for ( let var_name of var_names ) {
613
+ // Inputs like textareas don't have values
614
+ if ( values.length > 0 ) {
615
+ for ( let value of values ){
616
+ possible_table_rows.push({ var: var_name, value, });
617
+ }
618
+ } else {
619
+ possible_table_rows.push({ var: var_name, value: '', });
620
+ }
621
+
622
+ } // end iterate through row possibilities
623
+
624
+ return possible_table_rows;
625
+ }, // Ends scope.getPossibleTableRowMatchers()
626
+
627
+ getFieldNamesDict: async function ( scope, { $ }) {
628
+ /* Returns an object containing a map of the `_field_n` names and
629
+ * their matching variable names. If none exist, returns an empty object.
630
+ *
631
+ * `show if` fields have `_field_n` in the DOM instead of their var name,
632
+ * e.g. `_field_2`. Fortunately, there's a da DOM node that contains
633
+ * a map between such a string and the actual var name.
634
+ * See https://github.com/plocket/docassemble-cucumber/issues/173
635
+ * Example: { _field_0: 'some_checkbox_field' }
636
+ */
637
+ if ( !$('input[name="_varnames"]')[0] ) { return {}; }
638
+
639
+ let field_like_names = {};
640
+ let field_like_names_encoded = $('input[name="_varnames"]')[0].attribs.value;
641
+ let field_like_names_str = await scope.fromBase64( scope, { base64_str: field_like_names_encoded });
642
+ let field_like_names_json = JSON.parse( field_like_names_str );
643
+ for ( let field_like_key in field_like_names_json ) {
644
+ let field_like_DOM_name = await scope.fromBase64( scope, { base64_str: field_like_key });
645
+ let field_like_var_name = await scope.fromBase64( scope, { base64_str: field_like_names_json[ field_like_key ]});
646
+ field_like_names[ field_like_DOM_name ] = field_like_var_name;
647
+ }
648
+
649
+ return field_like_names;
650
+ }, // Ends scope.getFieldNamesDict()
651
+
652
+ getPossibleVarNames: async function ( scope, { encoded, field_like_names, nota }) {
653
+ /* Given a base64 encoded string and a map of `_field_n` to var names, return
654
+ * the decoded possible var names and, sometimes, values that the
655
+ * encoded string could be. Only some node names actually use `_field_n`.
656
+ *
657
+ * They might not be the actual var and value names. They might
658
+ * be proxy names (e.g. thing[i]).
659
+ * We now don't try to guess proxy var names, though. We have the developer put
660
+ * them directly in the data table with the value they want to set and
661
+ * include the trigger variable. Even though the proxy var name is the same
662
+ * in different iterations of the page, the unique trigger variable lets us
663
+ * know which value to set.
664
+ * See https://github.com/plocket/docassemble-cucumber/issues/256
665
+ *
666
+ * TODO: Check if base64-encoded strings could be indicated somehow in their
667
+ * encoded string. Like ending in _base64 or _64
668
+ * TODO: We'll probably eventually have to search for `[B'encoded']` and such
669
+ * iteratively to get all of them. Hasn't cropped up yet, though.
670
+ *
671
+ * @param encoded {str} - base64 encoded string of the node
672
+ * @param field_like_names {obj} - e.g. `{ _field_0: 'some_checkbox_var_name' }`
673
+ * @param nota {bool} - is the field a 'none of the above' field? */
674
+ let var_names = [];
675
+ let var_name = '';
676
+
677
+ // E.g. continue buttons that don't set a variable
678
+ if ( encoded === undefined ) { return var_names; }
679
+
680
+ // Names can be encoded multiple times, so start peeling the layers
681
+ let layer_1_decoded = await scope.fromBase64( scope, { base64_str: encoded });
682
+ // If this is of the format `_field_n`, we need to get the actual matching var name.
683
+ if ( field_like_names[ layer_1_decoded ] ) {
684
+ layer_1_decoded = field_like_names[ layer_1_decoded ];
685
+ }
686
+
687
+ try {
688
+ let action_button_json = JSON.parse( layer_1_decoded );
689
+ if ( action_button_json.action ) { layer_1_decoded = action_button_json.action }
690
+ } catch ( error ) {
691
+ // Things that are not action buttons can't be parsed this way.
692
+ // A special error for action buttons requires passing the node in here too, which is a lot to pass in.
693
+ // TODO: Should we move this back into `getAllFields()`?
694
+ }
695
+
696
+ // A lot of checkbox fields have a key hidden inside them. (not yesno type checkboxes)
697
+ let checkbox_match = layer_1_decoded.match( /^(.*)\[[BR]'(.*)'\]/ );
698
+
699
+ // Of the format `foo[B'encoded']` or `foo[R'encoded']`
700
+ // See https://github.com/jhpyle/docassemble/blob/595ad0a08572c0188f9ab49b1a6af99a3621cd42/docassemble_base/docassemble/base/standardformatter.py#L2108
701
+
702
+ // What about something like foo[R'encoded1'][B'encoded2']? Is that a
703
+ // thing? Can they be index numbers? Or just `foo['encoded1']`? What
704
+ // would their format be? The above? `foo[R'actual_choice'][B'encoded']`?
705
+ // `foo['something'][B'encoded']`?
706
+
707
+ // =====================
708
+ // Ex: checkboxes_choices[B'Y2hlY2tib3hfb3RoZXJfb3B0XzI']
709
+ // Ex: checkboxes_choices[R'Y2hlY2tib3hfb3RoZXJfb3B0XzI']
710
+ if ( checkbox_match ) {
711
+
712
+ let key_or_name = checkbox_match[1];
713
+ // If this is of the format `_field_n`, we need to get the actual matching var name.
714
+ if ( field_like_names[ key_or_name ] ) {
715
+ var_name = field_like_names[ key_or_name ];
716
+ } else {
717
+ var_name = key_or_name;
718
+ }
719
+
720
+ // Our own marker for a 'none of the above' checkbox choice
721
+ if ( nota ) { var_names.push( `${ var_name }['None']` ); }
722
+ else {
723
+ // Get the choice name. E.g. option_1, option_2
724
+ let box_layer_1_decoded = await scope.fromBase64( scope, { base64_str: checkbox_match[2] });
725
+ var_names.push( `${ var_name }['${ box_layer_1_decoded }']` );
726
+
727
+ try {
728
+ // The part inside `[B'encoded']` could need one more decoding (or more?)
729
+ // It may have invalid chars if it's not a one-word var name, though, like '_'
730
+ let box_layer_2_decoded = await scope.fromBase64( scope, { base64_str: box_layer_1_decoded });
731
+ var_names.push(`${ var_name }['${ box_layer_2_decoded }']`);
732
+ } catch ( err ) {} // It wasn't double encoded. Do nothing.
733
+ }
734
+
735
+ // =====================
736
+ // Otherwise, just the var name (non checkboxes or yesno checkbox type things)
737
+ } else {
738
+ var_name = layer_1_decoded;
739
+ var_names.push(`${ layer_1_decoded }`);
740
+ // // Values are sometimes encoded multiple times (check this happens with a plain var name)
741
+ // let layer_2_decoded = await scope.fromBase64( layer_1_decoded );
742
+ // names.var_names.push( layer_2_decoded );
743
+ }
744
+
745
+ return var_names;
746
+ }, // Ends scope.getPossibleVarNames()
747
+
748
+ getOptionValues: async function ( scope, { $select }) {
749
+ /* Returns all the option node values of the select node. */
750
+ let options = [];
751
+ let option_nodes = $select.find( `option` );
752
+ for ( let opt of option_nodes ) {
753
+ options.push( opt.attribs.value );
754
+ // Dropdown options created with objects will be encoded
755
+ let decoded = await scope.fromBase64( scope, { base64_str: opt.attribs.value });
756
+ options.push( decoded );
757
+ }
758
+ // TODO: These may be encoded? Just 'decode' and append those vals too.
759
+ // TODO: Keep those decoded values somewhere for setting the right
760
+ // option in the DOM. Not sure how that can be put together sensically.
761
+ return options;
762
+ }, // Ends scope.getOptionValues()
763
+
764
+ fromBase64: async function ( scope, { base64_str }) {
765
+ let buffer = Buffer.from( base64_str, 'base64' );
766
+ return buffer.toString( `utf-8` );
767
+ },
768
+
769
+ toBase64: async function ( scope, { utf8_str }, exclude_padding=true) {
770
+ if ( typeof( utf8_str ) !== `string` ) { // Prevent an error
771
+ return `Will+Never+Match+Existing+DOM+Node==`;
772
+ }
773
+ // Sometimes, converting to base64 results in padding characters ('=')
774
+ // being added to the end. Docassemble does not use these padding
775
+ // characters everywhere, so we allow for stripping them using
776
+ // the exclude_padding argument. For more details, see
777
+ // https://github.com/nodejs/node/issues/6107#issuecomment-207177131
778
+ let buffer = Buffer.from( utf8_str, 'utf-8' );
779
+ let base64str = buffer.toString( `base64` )
780
+ return exclude_padding ? base64str.replace(/=/g, '') : base64str;
781
+ },
782
+
783
+
784
+ // TODO: Maybe add `from_story_table` to the var data table (array...)
785
+ getMatchingRows: async function ( scope, { field, var_data }) {
786
+ /* Given var_data, a list of objects with variable data for
787
+ * setting fields, and an object for a DOM field, returns list of the
788
+ * rows that match the DOM field. That list of story table rows may be empty.
789
+ *
790
+ * @param var_data {arr} - fields and the values they can be set to
791
+ * @param var_data[n].var {str} - name of variable to set
792
+ * @param var_data[n].value {str} - choice to set or value to set
793
+ * @param var_data[n].trigger {str} - the variable that triggered the
794
+ * page to be shown
795
+ *
796
+ * @param field - data representing one DOM field
797
+ * @param field[n].selector {str} - used to find the DOM node
798
+ * @param field[n].tag {str} - HTML tag of node
799
+ * @param field[n].type {str} - HTML type of node if it has one
800
+ * @param field[n].guesses {arr} - arr of objects that could
801
+ * possibly match a table row
802
+ * @param field[n].guesses[n].var {str} - name of variable
803
+ * @param field[n].guesses[n].value {str} - value of a choice
804
+ * @param <field[n].guesses[n].trigger> {str} - var name that triggered this page
805
+ *
806
+ * @returns [ var_data[n] ]
807
+ */
808
+ let matches = [];
809
+ let uses_proxies = false;
810
+
811
+ // There are mutliple guesses because field names are encoded
812
+ for ( let guess of field.guesses ) {
813
+ for ( let var_datum of var_data ) {
814
+ let row_matches = false;
815
+
816
+ // If the dev uses single instead of double quotes, etc, allow that
817
+ let names_match = await scope.matchNeutralizedQuotes( scope, { value1: guess.var, value2: var_datum.var });
818
+ let values_match = await scope.matchNeutralizedQuotes( scope, { value1: guess.value, value2: var_datum.value });
819
+
820
+ // Check different things for different fields. An input box won't
821
+ // be expected to have the same value as the var you want to set while
822
+ // a radio button and its table row need to have a matching value.
823
+ switch ( field.tag ) {
824
+ case 'button':
825
+ case 'select':
826
+ if ( names_match && values_match ) { row_matches = true; }
827
+ break;
828
+
829
+ case 'canvas':
830
+ case 'textarea':
831
+ case 'a':
832
+ if ( names_match ) { row_matches = true; }
833
+ break;
834
+
835
+ case 'input':
836
+ if ( field.type === 'radio' ) {
837
+ if ( names_match && values_match ) { row_matches = true; }
838
+ } else { // Some kind of text input
839
+ if ( names_match ) { row_matches = true; }
840
+ }
841
+ break;
842
+ }
843
+
844
+ if ( row_matches ) {
845
+ matches.push( var_datum ); // Collect all matches to start with.
846
+ if ( !uses_proxies ) { // If this gets set to `true` once, leave it alone
847
+ // x, i, j, etc. See notes below about the reasoning
848
+ // AVOID `.test()` - it advances through a string each time you call it on the
849
+ // string, so you can have unexpected results.
850
+ uses_proxies = guess.var.match( proxies_regex ); //
851
+ }
852
+ continue;
853
+ }
854
+
855
+ } // ends for each table row
856
+ } // ends for each field row possibility
857
+
858
+ // Some fields will have multiple matches from the story table.
859
+ // The only reason we would care about multiple story table row
860
+ // matches is if there is a possible proxy var conflict.
861
+ // When a dev has a loop with an index var or generic object,
862
+ // the same variable name will be used over and over, e.g. `users[i]`.
863
+ // That's what will be in the story table, so there will be multiple
864
+ // matches for a field setting `users[i]`. For this program to
865
+ // know which table row to use on which page, we need the one unique var that
866
+ // will appear on the page - the trigger var. When a row with `users[i]`
867
+ // has a trigger var that matches the page trigger var, the
868
+ // program knows to use that row to set the value for that page.
869
+
870
+ // If needed, only get the matches that also match the trigger var
871
+ // For now, we're being flexible about it - if we don't think we
872
+ // need that trigger var, we don't prevent data getting through.
873
+ // e.g. If an interview has a list of `users`, but they only set
874
+ // data for `users[0]`, there's no need to check the trigger var.
875
+ // That way devs with simple cases can ignore the trigger var column.
876
+
877
+ // If field uses proxies and there has been more than one match
878
+ // we now use the trigger var to narrow down the matches.
879
+ if ( uses_proxies && matches.length > 1 ) {
880
+ // Multiple matches means this came from a story
881
+
882
+ // Feedback for devs - we need the trigger var but we don't have it (field data)
883
+ // TODO: Add docs and link to docs on how to add the trigger var
884
+ if ( field.trigger === undefined ) { await scope.addToReport(scope, { type: `warning`, value: `A field on this page uses an index variable or a generic object, but the page does not contain an element with the trigger variable's name. Tell the developer to add an element to the page with the id "trigger"` }); }
885
+
886
+ let original_matches = matches;
887
+ matches = [];
888
+ for ( let match of original_matches ) {
889
+ // Feedback for devs - we need the trigger var but we don't have it (var data)
890
+ if ( match.trigger === undefined ) { await scope.addToReport(scope, { type: `warning`, value: `A field on this page uses an index variable or a generic object. The test gave story data for setting the field, but that data is missing the trigger. Add a trigger variable to the story table \`trigger\` column. For now, the test may end up ignoring this field. The row's data is\n${ JSON.stringify( match.original )}.\nThe field's data is\n${ JSON.stringify( field )}` }); }
891
+ // TODO: Add a warning if they're both `undefined`?
892
+ else {
893
+
894
+ let triggers_match = await scope.matchNeutralizedQuotes( scope, { value1: field.trigger, value2: match.trigger });
895
+ if ( triggers_match ) {
896
+ matches.push( match );
897
+ }
898
+ }
899
+ }
900
+ // Feedback for devs - we are missing the matching triggers we need
901
+ if ( matches.length === 0 ) { await scope.addToReport(scope, { type: `warning`, value: `A field on this page uses an index variable or a generic object. The test gave multiple matches for the story data for the field, but none match the trigger variable. Is the story table missing a row? For now, the test will have to ignore this field. The rows' data is\n${ JSON.stringify( original_matches )}.\nThe field's data is\n${ JSON.stringify( field )}` }); }
902
+ }
903
+
904
+ // Dev may have accidentally included a row more than once. Give them feedback
905
+ if ( matches.length > 1 ) {
906
+ await scope.addToReport(scope, { type: `warning`, value: `there are multiple story data matches for this field. The story table data that appears last will be used. The field's data is\n${JSON.stringify( field )}` });
907
+ }
908
+
909
+ if ( env_vars.DEBUG ) { console.log( `matches:\n`, JSON.stringify( matches )); }
910
+ return matches;
911
+ }, // Ends scope.getMatchingRows()
912
+
913
+
914
+ matchNeutralizedQuotes: async function ( scope, { value1, value2 }) {
915
+ /* Returns true if value1 matches value2 when they're turned into
916
+ * strings and single vs. double quote mismatches are neutralized. */
917
+ let val_1_str = `` + value1;
918
+ let val_2_str = `` + value2;
919
+ let val_1_escaped = escape_regex( val_1_str );
920
+ let val_1_regex = new RegExp(`^${ val_1_escaped.replace(/['"]/g, `['"]`)}$`);
921
+ return val_2_str.match( val_1_regex );
922
+ }, // Ends scope.matchNeutralizedQuotes()
923
+
924
+
925
+ setVariable: async function ( scope, { field, var_data }) {
926
+ /* Returns true if the field was set.
927
+ *
928
+ * TODO: Will the selector ever change the second time around?
929
+ * If so, we need to store the handles before this.
930
+ *
931
+ * @returns bool
932
+ */
933
+ let row_used = null;
934
+
935
+ let matching_input_rows = await scope.getMatchingRows( scope, { var_data, field });
936
+
937
+ // Nothing to set if there are no matching table rows
938
+ if ( matching_input_rows.length <= 0 ) { return row_used; }
939
+ // Want this info for the report later
940
+ field.matching_input_rows = matching_input_rows;
941
+
942
+ // Take a while to load sometimes
943
+ if ( field.tag === 'canvas' ) {
944
+ await scope.page.waitForSelector('#dasigcanvas');
945
+ }
946
+
947
+ let handle = await scope.page.evaluateHandle(( selector, field ) => {
948
+ let elem = document.querySelector( selector );
949
+ // `elem` has never been null
950
+ if ( elem.nextSibling && elem.nextSibling.tag === `label` ) {
951
+ return elem.nextSibling;
952
+ } else { return elem; }
953
+ }, field.selector, field );
954
+
955
+ let disabled = await handle.evaluate(( elem )=> {
956
+ // da's header covers the top of the section containing the fields which means
957
+ // sometimes it blocks nodes from clicks. Scroll item to the bottom instead.
958
+ if ( !elem.disabled ) { elem.scrollIntoView(false); }
959
+ return elem.disabled;
960
+ });
961
+
962
+ // If field is disabled, don't set it yet
963
+ if ( disabled ) { return row_used; }
964
+
965
+ if ( env_vars.DEBUG ) {
966
+ let name = await handle.evaluate(( elem ) => { // Not using this right now
967
+ try { elem.nextSibling.style.background = 'Khaki'; }
968
+ catch(err){ elem.style.background = 'Khaki'; }
969
+ return elem.getAttribute('for') || elem.getAttribute('name');
970
+ });
971
+ await scope.page.waitFor( 400 );
972
+ }
973
+
974
+ let { tag, type } = field;
975
+ // We use the last match. We wrote a warning for the user if there were
976
+ // mutliple matches in the story that the last value will be used. In future,
977
+ // Steps will be able to set this data gradually and we don't want the developer
978
+ // to get confused thinking that an earlier Step was used instead of a later one.
979
+ let row = matching_input_rows[ matching_input_rows.length - 1 ];
980
+ let set_to = row.value;
981
+
982
+ // Detect a custom type and get the element of that custom type if it exists
983
+ let custom_datatype = await handle.evaluate(( elem )=> {
984
+ // If the element is in a form group**, then extract the form group's
985
+ // datatype value.
986
+ // ** For now the only custom datatype we have is in a form group. In
987
+ // future we should look into what happens with a `fieldset` for
988
+ // `choices:`, etc.
989
+ let custom_datatype = null, matches = null;
990
+ let group = elem.closest( `.da-form-group` );
991
+ if ( group && group.className ) {
992
+ matches = ` ${ group.className } `.match(/da-field-container-datatype-([^ ]+) /);
993
+ }
994
+ if ( matches ) {
995
+ custom_datatype = matches[1];
996
+ }
997
+
998
+ return custom_datatype;
999
+ });
1000
+
1001
+ // Set field
1002
+ if ( scope.setCustomDatatype[ custom_datatype ] === undefined ) {
1003
+ // If no custom datatype, set a regular field
1004
+ await scope.setField[ tag ]( scope, { handle, set_to });
1005
+ } else {
1006
+ // Set da package custom fields. TODO: find `custom_handle` in the setter?
1007
+ let custom_handle = await handle.evaluateHandle(( elem )=> { return elem.closest( `.da-form-group` ) });
1008
+ await scope.setCustomDatatype[ custom_datatype ]( scope, { handle: custom_handle, set_to });
1009
+ }
1010
+
1011
+ return row;
1012
+ }, // Ends scope.setVariable()
1013
+
1014
+ // Handle setting values for da custom datatypes. E.g. `da-field-container-datatype-BirthDate`
1015
+ // TODO: Make this more easily extensible.
1016
+ // TODO: Just put this in `scope.setField`? Find the custom handle in the setter itself.
1017
+ setCustomDatatype: {
1018
+ "BirthDate": async function ( scope, { handle, set_to }) {
1019
+ /* Given a date of the format 'mm/dd/yyyy' and the handle of the
1020
+ * custom field container, sets the ALToolbox custom birthdate
1021
+ * field values. */
1022
+ let date_parts = set_to.split(`/`);
1023
+ let fields = await handle.$$(`select, input:not([type="hidden"])`);
1024
+ for ( let field_i = 0; field_i < fields.length; field_i++ ) {
1025
+ let field = fields[ field_i ];
1026
+
1027
+ if ( env_vars.DEBUG ) {
1028
+ await field.evaluate( elem => { elem.style.background = `Khaki`; });
1029
+ }
1030
+
1031
+ if ( field_i === 0 ) {
1032
+ // Set the month
1033
+ await field.select( date_parts[ 0 ] );
1034
+ } else {
1035
+ // Set the day and year
1036
+ await scope.setText( scope, {
1037
+ handle: field,
1038
+ set_to: date_parts[ field_i ]
1039
+ });
1040
+ }
1041
+
1042
+ } // For each field
1043
+
1044
+ await scope.afterStep( scope );
1045
+ }, // Ends BirthDate()
1046
+
1047
+
1048
+ }, // ends setCustomDatatype {}
1049
+
1050
+ setField: {
1051
+ button: async function ( scope, { handle, set_to }) { await scope.tapElement( scope, handle ); },
1052
+ textarea: async function ( scope, { handle, set_to }) {
1053
+ await scope.setText( scope, { handle, set_to });
1054
+ await scope.afterStep(scope); // No showifs for this one?
1055
+ },
1056
+ select: async function ( scope, { handle, set_to }) {
1057
+ // A dropdown's option value can be one of two things
1058
+ // Try to find the element using the first value
1059
+
1060
+ // TODO: Can we somehow pass in a `set_to` that is encoded correctly since
1061
+ // we're getting the fields from the DOM to begin with? We'll probably
1062
+ // need another prop because we need the `option` human value to
1063
+ // match the story table
1064
+
1065
+ // TODO: Change this to search in the actual handle instead of just $
1066
+ let option = await scope.page.$(`option[value="${ set_to }"]`);
1067
+ if ( option ) {
1068
+ await handle.select( set_to ); // And use that value to set it
1069
+
1070
+ // If that literal value isn't on the page, it should be a base64 encoded value
1071
+ // TODO: Can these be double encoded?
1072
+ } else {
1073
+ let base64_name = await scope.toBase64( scope, { utf8_str: set_to });
1074
+ await handle.select( base64_name );
1075
+ }
1076
+
1077
+ await scope.afterStep(scope, { waitForShowIf: true });
1078
+ },
1079
+ canvas: async function ( scope, { handle, set_to }) {
1080
+ await scope.sign( scope );
1081
+ await scope.afterStep( scope );
1082
+ },
1083
+ input: async function ( scope, { handle, set_to }) {
1084
+ /* Set value of some `input` element to the given value. */
1085
+
1086
+ let type = await handle.evaluate( elem => { return elem.getAttribute('type'); });
1087
+ if ( type === `radio` || type === `checkbox` ) {
1088
+
1089
+ let [label] = await handle.$x(`following-sibling::label`);
1090
+
1091
+ if ( env_vars.DEBUG ) {
1092
+ if ( set_to.toLowerCase() === 'false' ) { await label.evaluate( elem => { elem.style.background = 'tomato'; }); }
1093
+ else { await label.evaluate( elem => { elem.style.background = 'teal'; }); }
1094
+ let name = await label.evaluate( elem => { return elem.getAttribute('for'); });
1095
+ }
1096
+
1097
+ if ( type === `radio` ) { await label.evaluate( elem => { return elem.click(); }); }
1098
+ else { await scope.setCheckbox( scope, { label, set_to }); }
1099
+
1100
+ await scope.afterStep(scope, { waitForShowIf: true });
1101
+
1102
+ } else if ( type === `file` ) {
1103
+ await scope.uploadFiles( scope, { handle, set_to });
1104
+ await scope.afterStep(scope, { waitForShowIf: true });
1105
+
1106
+ } else if (type == `date`) {
1107
+ let set_to_date = set_to;
1108
+ // If the date is specified in the format "today" or "today + 2" or "today-1"
1109
+ // it will need some pre-processing.
1110
+ if (set_to.indexOf(`today`) !== -1) {
1111
+ // Find out how many days to add/subtract
1112
+ let date_delta = set_to.replace(/\s/g, '');
1113
+ date_delta = date_delta.match(/today([+-]\d+)?/)[1];
1114
+ date_delta = date_delta == undefined ? 0 : date_delta;
1115
+ date_delta = parseInt(date_delta);
1116
+ // Get today's date
1117
+ set_to_date = new Date();
1118
+ // Add/subtract required number of days
1119
+ set_to_date.setDate(set_to_date.getDate() + date_delta);
1120
+ // Convert to the docassemble date field format: mm/dd/yyyy
1121
+ // Reference: https://gomakethings.com/setting-a-date-input-to-todays-date-with-vanilla-js/
1122
+ set_to_date = ((set_to_date.getMonth() + 1).toString().padStart(2, 0) + '/' +
1123
+ set_to_date.getDate().toString().padStart(2, 0) + '/' +
1124
+ set_to_date.getFullYear().toString());
1125
+ }
1126
+ await scope.setText( scope, { handle, set_to: set_to_date });
1127
+ await scope.afterStep(scope, { waitForShowIf: true });
1128
+ } else {
1129
+ await scope.setText( scope, { handle, set_to });
1130
+ await scope.afterStep(scope, { waitForShowIf: true });
1131
+ }
1132
+ }, // Ends scope.setField.input()
1133
+ },
1134
+
1135
+ uploadFiles: async function ( scope, { handle, set_to }) {
1136
+ /* Try to upload one or more files to a file-type input field. */
1137
+
1138
+ // Tests could be in one of two places. Figure out which directory they're in
1139
+ // Makes an absolute path from the directory in which `npm run` was used plus our internal folders
1140
+ let dir_source = `${ process.cwd() }/docassemble/${ env_vars.REPO_URL.match(/docassemble-(.*)/)[1] }/data/sources`;
1141
+ let dir_features = `${ process.cwd() }/tests/features`;
1142
+ let dir = null;
1143
+
1144
+ // Make sure one of the valid directories exist. This only allows
1145
+ // one directory to be used per Step or table row. We don't anticipate
1146
+ // future people having a `tests/features` folder, let alone both.
1147
+ try {
1148
+ // Try the most common directory first
1149
+ fs.accessSync( dir_source );
1150
+ dir = dir_source;
1151
+ // If they have both directories, let them know which directory will be used.
1152
+ try {
1153
+ fs.accessSync( dir_features );
1154
+ await scope.addToReport( scope, { type: `warning`, value: `You have both a docassemble package 'sources' folder and a 'test/features' folder. These tests will use the package's 'sources' folder.` });
1155
+ } catch ( error_features ) { `all's well, do nothing`; }
1156
+
1157
+ } catch ( error_source ) {
1158
+ try {
1159
+ fs.accessSync( dir_features );
1160
+ dir = dir_features;
1161
+ } catch ( error_features ) {
1162
+ await scope.addToReport( scope, { type: `warning`, value: `There are two places where you can store files to upload: "${ dir_source }" and "${ dir_features }". These tests could not find either of those.` });
1163
+ throw error_source; // Most people will want to know the sources folder path;
1164
+ }
1165
+ }
1166
+
1167
+ // Allow a typo where someone forgot to include a space after a comma
1168
+ let file_names = set_to.split(`,`);
1169
+
1170
+ // If a file exist, add it to a list of files to be uploaded. If it doesn't, add a warning.
1171
+ let paths = [];
1172
+ for ( let name of file_names ) {
1173
+
1174
+ // If the developer correctly used a ", ", there will be a space at the
1175
+ // start of the string. Remove it. If they forgot the space, that's fine too.
1176
+ name = name.replace(/^\s/, '');
1177
+ let file_path = path.join( dir, name );
1178
+
1179
+ try {
1180
+ fs.accessSync( file_path );
1181
+ paths.push( file_path );
1182
+
1183
+ } catch ( err ) {
1184
+ // TODO: Should this throw an error?
1185
+ await scope.addToReport( scope, { type: `warning`, value: `Could not find "${ name }" in "${ dir }"` });
1186
+ }
1187
+ }
1188
+
1189
+ if ( !paths ) {
1190
+ // TODO: Should this throw an error?
1191
+ await scope.addToReport( scope, { type: `warning`, value: `Could not find any files to upload at "${ dir }".` });
1192
+ } else {
1193
+ // Prepare to wait for upload to complete
1194
+ let name = await handle.evaluate( elem => { return elem.getAttribute(`name`); });
1195
+ let selector = `*[name="${ name }"]`
1196
+ let disabled_selector = `${ selector }[disabled]`;
1197
+ let enabled_selector = `${ selector }:not([disabled])`;
1198
+
1199
+ // This comes before the upload starts
1200
+ // Notice `await` is absent. Don't actually wait here.
1201
+ // Just start looking for it now. That way we'll catch it before it's gone
1202
+ let wait_for_disable = scope.page.waitForSelector( disabled_selector );
1203
+
1204
+ // Upload
1205
+ await handle.uploadFile(...paths);
1206
+
1207
+ // Halt progress to wait for the input to be disabled. If it already is, then this
1208
+ // will have resolved already and will keep going.
1209
+ await wait_for_disable;
1210
+ // When it's again enabled, the files have finished loading. Or so I have observed.
1211
+ await scope.page.waitForSelector( enabled_selector );
1212
+ }
1213
+
1214
+ }, // Ends scope.uploadFiles()
1215
+
1216
+ setCheckbox: async function ( scope, { label, set_to }) {
1217
+ // Depending on the current value/status of a checkbox and the desired
1218
+ // value to set, either taps the checkbox or leaves it alone.
1219
+ let status = await label.evaluate( elem => { return elem.getAttribute('aria-checked'); });
1220
+ if ( set_to.toLowerCase() !== status ) {
1221
+ await label.evaluate( elem => { return elem.click(); });
1222
+ }
1223
+ }, // Ends scope.setCheckbox()
1224
+
1225
+ setText: async function ( scope, { handle, set_to }) {
1226
+ // Set text in some kind of field (input text, input date, textarea, etc.)
1227
+ await handle.evaluate( el => { el.value = '' });
1228
+ await handle.focus();
1229
+ await handle.type( set_to );
1230
+ setTimeout(async () => {
1231
+ }, 500)
1232
+ },
1233
+
1234
+ sign: async function ( scope ) {
1235
+ /* On a signature page, there should only be a single signature canvas
1236
+ * and a continue button. Draw in the #dasigcanvas element. `<canvas>`
1237
+ * takes a while to load. */
1238
+ let handle = await scope.page.waitForSelector( `#dasigcanvas` );
1239
+ let msg = `Could not find a signature field.`;
1240
+ if ( !handle ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
1241
+ expect( handle, msg ).to.exist;
1242
+
1243
+ let bounding_box = await handle.boundingBox();
1244
+ await scope.page.mouse.move(bounding_box.x + bounding_box.width / 2, bounding_box.y + bounding_box.height / 2);
1245
+ await scope.page.mouse.down();
1246
+ // await scope.page.mouse.move(1, 1); // Too big? Wait a while before drawing?
1247
+ await scope.page.mouse.up();
1248
+ },
1249
+
1250
+ examinePageID: async function ( scope, question_id = '' ) {
1251
+ /* Looks for a santized version of the question id as it's written
1252
+ * in the .yml. docassemble's way in python:
1253
+ * re.sub(r'[^A-Za-z0-9]+', '-', interview_status.question.id.lower()) */
1254
+ let id_as_class = question_id.toLowerCase().replace(/[^A-Za-z0-9]+/g, '-');
1255
+ // `.className` is the node's list of classes as a string, separated by spaces
1256
+ let classes_str = await scope.page.$eval(`body`, function (elem) { return elem.className });
1257
+ let ids_found = classes_str.match(/question-([^ ]+)/);
1258
+
1259
+ // Data that we've decided is useful for our purposes
1260
+ let found_any_question_id = ids_found !== null;
1261
+ // If there is an id, see if it matches the class
1262
+ let id = null;
1263
+ let ids_match = false;
1264
+ if ( found_any_question_id ) {
1265
+ id = ids_found[1]
1266
+ ids_match = id === id_as_class;
1267
+ }
1268
+
1269
+ // TODO: What are good names for these properties?
1270
+ // question_has_id? found_any_id?
1271
+ // class_matches_id? class_and_id_do_match? id_and_page_id_match?
1272
+ return { question_has_id: found_any_question_id, id: id, id_matches: ids_match };
1273
+ }, // Ends scope.examinePageID()
1274
+
1275
+ // TODO: Rename var_data to input_data?
1276
+ // Note: Totally different from `setField` (which is singular)
1277
+ // TODO: If a step triggers this and is not able to set a value, should it error?
1278
+ // TODO: Rename to setFieldsOfOnePage? setPageFields?
1279
+ setFields: async function ( scope, {
1280
+ var_data=[],
1281
+ ensure_navigation=true,
1282
+ ensure_all_vars_used=false
1283
+ }) {
1284
+ /* Non-recursive function to set as many variables as possible before
1285
+ * continuing to the next page. Important to avoid recursion because of async ops.
1286
+ * If it's not data in a table, the code can be more permissive about some input.
1287
+ *
1288
+ * @param var_data {arr} - Array of objects containing names of variables, what
1289
+ * value to set them to, and possibly what the page's trigger variable is. Or empty.
1290
+ * @param ensure_navigation {bool} [true] - Default true. If true, presses continue button
1291
+ * if navigation is not triggered another way.
1292
+ * @param ensure_all_vars_used {bool} [false] = Default false. If true, errors if any
1293
+ * object in var_data is not used during running of this function.
1294
+ */
1295
+ if ( env_vars.DEBUG ) { console.log( `~~~~~~~~~~~~~~ scope.setFields() ~~~~~~~~~~~~~~` ); }
1296
+
1297
+ // Only record the page id once for each page to which we navigate
1298
+ let { id } = await scope.examinePageID( scope, 'none to match' );
1299
+ if ( scope.page_id !== id ) {
1300
+ await scope.addToReport(scope, { type: `page_id`, value: `${ id }`, });
1301
+ scope.page_id = id;
1302
+ }
1303
+
1304
+ let html = await scope.page.content();
1305
+ // These are the fields on the current page. Their handles should all exist.
1306
+ let fields = await scope.getAllFields( scope, { html: html });
1307
+
1308
+ // Avoid setting hidden fields, but when you set a field, other fields on
1309
+ // the screen may be revealed and then need to be set, so loop through
1310
+ // again until nothing more can be set.
1311
+ let something_new_was_set = true; // So loop can start
1312
+ while ( something_new_was_set ) {
1313
+
1314
+ something_new_was_set = false; // start fresh
1315
+ // Go through each field on the page, seeing if we can set its variable
1316
+ for ( let field of fields ) {
1317
+
1318
+ // Avoid setting a field multiple times
1319
+ if ( field.was_set ) { continue; }
1320
+ // Avoid navigating in this `while`. Will do this later so we get to
1321
+ // set all fields in here first. TODO: Can links (<a>) set vars?
1322
+ if ( field.tag === 'button' && field.type === 'submit' ) { continue; }
1323
+
1324
+ // Try to set other types of fields
1325
+ let row_used = await scope.setVariable( scope, { field, var_data });
1326
+ if ( row_used ) {
1327
+
1328
+ something_new_was_set = true;
1329
+ field.was_set = true;
1330
+ await scope.addToReport(scope, {
1331
+ type: `row`,
1332
+ value: `${ await scope.convertToOriginalStoryTableRow(scope, { row_data: row_used }) }`
1333
+ });
1334
+
1335
+ // Accumulate number of times this row was used. Esp important for `.target_number`
1336
+ if ( row_used.source ) { row_used.source.times_used += 1 }
1337
+ row_used.times_used += 1;
1338
+
1339
+ process.stdout.write(`\x1b[36m${ '*' }\x1b[0m`); // assumes var was set if no error occurred
1340
+
1341
+ }
1342
+ } // ends for every non-submit-button field on the page
1343
+ } // ends while fields are still being set
1344
+
1345
+ // Press a continue button with a story table variable name if it exists
1346
+ let did_navigate = false;
1347
+ let error = {};
1348
+ for ( let field of fields ) {
1349
+ // All other fields should have been set
1350
+ if ( field.tag === 'button' && field.type === 'submit' ) {
1351
+ let page_url = await scope.page.url();
1352
+
1353
+ let row_used = await scope.setVariable( scope, { field, var_data, });
1354
+ if ( row_used ) {
1355
+
1356
+ await scope.addToReport(scope, {
1357
+ type: `row`,
1358
+ value: `${ await scope.convertToOriginalStoryTableRow(scope, { row_data: row_used }) }`
1359
+ });
1360
+
1361
+ // Esp important for `.target_number`
1362
+ if ( row_used.source ) { row_used.source.times_used += 1 }
1363
+ row_used.times_used += 1;
1364
+
1365
+ // TODO: special continue/navigation button function that handles url and all?
1366
+
1367
+ // Have to catch errors right now or may get stuck in an infinite loop?
1368
+ // TODO: Don't catch errors - maybe the developer expected/wanted an error
1369
+ // For now they'll have to stop the story table early and do the rest the slow way
1370
+ // TODO: Discuss: add the option of throwing when an error happens? Per page?
1371
+
1372
+ // The developer said to continue by defining this row.
1373
+ // For now, that means this page should not cause any kind of error.
1374
+ // Make sure no system or user error messages appear
1375
+ // Discuss moving this out of here. Having to pass in `id` just for
1376
+ // these messages smells bad. Con: Already a lot of code out there.
1377
+ error = await scope.waitUntilContinued( scope, { url: page_url, id });
1378
+ // if ( error.was_found ) {
1379
+ // await scope.throwPageError( scope, { id: id });
1380
+ // }
1381
+
1382
+ did_navigate = true;
1383
+ process.stdout.write(`\x1b[36m${ 'v' }\x1b[0m`); // pressed button successfully
1384
+ break;
1385
+
1386
+ } // ends if row_used
1387
+ } // ends if button submit
1388
+ } // ends for buttons in fields
1389
+
1390
+ // If all table rows must be used and any table row was not, error
1391
+ if ( ensure_all_vars_used ) {
1392
+ let num_unused_rows = 0;
1393
+ for ( let row of var_data ) {
1394
+ if ( row.times_used === 0 ) {
1395
+ num_unused_rows += 1;
1396
+ let source_row = row;
1397
+ if ( row.source ) { source_row = row.source; }
1398
+ let original_row = source_row.original;
1399
+ let msg = `Did not find a field on this page for the variable \`${ original_row.var }\` that could be set to \`${ original_row.value }\``;
1400
+ await scope.addToReport(scope, { type: `warning`, value: msg });
1401
+ }
1402
+ }
1403
+ if ( num_unused_rows > 0 ) {
1404
+ let msg = `Missing variable or variables on page.`;
1405
+ await scope.addToReport(scope, { type: `error`, value: msg });
1406
+ expect( num_unused_rows, msg ).to.equal( 0 );
1407
+ }
1408
+ }
1409
+
1410
+ // If didn't already press a continue button, press one now.
1411
+ if ( !did_navigate && ensure_navigation ) {
1412
+ // All fields on this page should be taken care of
1413
+ let page_url = await scope.page.url();
1414
+ await scope.continue( scope );
1415
+ // Have to catch errors right now or may get stuck in an infinite loop?
1416
+ // TODO: Don't catch errors - maybe the developer expected/wanted an error
1417
+ // For now they'll have to stop the story table early and do the rest the slow way
1418
+ // TODO: Discuss: add the option of throwing when an error happens? Per page?
1419
+
1420
+ // Make sure no system or user error messages appear
1421
+ // Discuss moving this out of here. Having to pass in `id` just for
1422
+ // these messages smells bad. Con: Already a lot of code out there.
1423
+
1424
+ // TODO: How do we let 'I set the var __ to __' also afterwards check for an invalidation message?
1425
+ error = await scope.waitUntilContinued( scope, { url: page_url, id });
1426
+ // if ( error.was_found ) {
1427
+ // await scope.throwPageError( scope, { id: id });
1428
+ // }
1429
+
1430
+ process.stdout.write(`\x1b[36m${ '>' }\x1b[0m`); // continued successfully
1431
+ } // ends if !did_navigate && ensure_navigation
1432
+
1433
+ // TODO: Since we now continue only once no matter what, we can put
1434
+ // the error in the caller of the function, though we may want
1435
+ // to pass the symbol to print as well.
1436
+ return error;
1437
+ // TODO: Maybe we should return something else or something in addition about used vars
1438
+ }, // Ends scope.setFields()
1439
+
1440
+ // TODO: Move this to under `normalizeTable`
1441
+ ensureSpecialRows: async function ( scope, { var_data, from_story_table=true }) {
1442
+ /* Given a list of variable data objects, add more objects or mutate
1443
+ * rows under special circumstances:
1444
+ * - `.target_number` may indicate the need for a `.there_is_another` row.
1445
+ * - `.there_is_another` in a table is invalid
1446
+ */
1447
+ // Add special cases. Do not mutate `var_data` as a list, but
1448
+ // do allow mutation of items inside.
1449
+ let enhanced_var_data = [...var_data];
1450
+ for ( let var_datum of var_data ) {
1451
+
1452
+ // Add a `.there_is_another` var if it might be needed.
1453
+ // If it's not used in the end, that's fine.
1454
+ // `.target_number` is sometimes a stand-in for `.there_is_another`.
1455
+ // That seems the clearest way to communicate to developers about what they
1456
+ // should write in the test. We will attempt to write more about
1457
+ // the rationale in the documentation at some point.
1458
+ if ( from_story_table && var_datum.var.match( /\.target_number$/ )) {
1459
+ let special_row = {
1460
+ artificial: true, // not currently used but is future-proofing to mark non-dev generated rows
1461
+ source: var_datum,
1462
+ var: var_datum.var.replace( /\.target_number$/, `.there_is_another` ),
1463
+ value: await scope.getThereIsAnotherValue( scope, { row: var_datum, from_story_table }),
1464
+ // devs are supposed to use the right `trigger` name, but they might not
1465
+ trigger: var_datum.trigger.replace( /\.target_number$/, `.there_is_another` ),
1466
+ };
1467
+
1468
+ enhanced_var_data.push( special_row );
1469
+
1470
+ // Neutralize a `.there_is_another` row in a story table
1471
+ } else if ( from_story_table && var_datum.var.match( /\.there_is_another$/ ) ) {
1472
+ var_datum.invalid = true;
1473
+ // Removing the row will just cause a different error that will take longer to fail and
1474
+ // probably add a confusing message to the report, so mutating seems, sadly, more useful.
1475
+ var_datum.value = `False`; // The original value is still preserved
1476
+ // Warn the developer not to use `.there_is_another`.
1477
+ await scope.addToReport( scope, { type: `warning`, value: `The attribute \`.there_is_another\` is invalid in story table tests. Replace it with \`.target_number\` in your \`var\` and \`trigger\` columns. Set the \`value\` to the number of items in that list. This test will now set this row's \`value\` to \`False\`. See https://suffolklitlab.github.io/docassemble-AssemblyLine-documentation/docs/automated_integrated_testing#there_is_another. The row data is\n${ JSON.stringify( var_datum.original )}` });
1478
+ }
1479
+ }
1480
+
1481
+ return enhanced_var_data;
1482
+ }, // Ends scope.ensureSpecialRows()
1483
+
1484
+ getThereIsAnotherValue: async function ( scope, { row, from_story_table=true }) {
1485
+ /* Given story row-like data as well as whether this is from a table row,
1486
+ * return the best guess as to a safe value to set for this round. Give
1487
+ * the developer a warning if
1488
+ * it's an inappropriate value. Avoid mutation.
1489
+ *
1490
+ * WARNING: Remember to decrement the actual value later.
1491
+ *
1492
+ * Sometimes this will be from `.target_number` from story tables and
1493
+ * we believe we've set it up so that people will make it a number.
1494
+ * Sometimes this will be from `.there_is_another`. We think we've ensured
1495
+ * that it will only come from Steps and should be `True` or `False`.
1496
+ * This feels precarious, but I can't see another permuation.
1497
+ *
1498
+ * This may in future be used to normalize all values that need `True`/`False`
1499
+ * normalization.
1500
+ */
1501
+ let value = row.value;
1502
+ let int_val = parseInt( value );
1503
+
1504
+ // Step stuff. Take into account a lot of possible values
1505
+ if ( value.match( /(false|no|unchecked|uncheck|deselected|deselect)/i )) {
1506
+ return `False`;
1507
+
1508
+ } else if ( value.match( /(true|yes|checked|check|selected|select)/i )) {
1509
+ if ( from_story_table ) {
1510
+ // Just in case, but this should never happen.
1511
+ if ( row.var.match( /\.there_is_another$/ ) ) {
1512
+ await scope.addToReport( scope, { type: `warning`, value: `The attribute \`.there_is_another\` is invalid in story table tests. Replace it with \`.target_number\` in your \`var\` column. Set the \`value\` to the number of items in that list. This test will now set this row's \`value\` to \`False\`. See https://suffolklitlab.github.io/docassemble-AssemblyLine-documentation/docs/automated_integrated_testing#there_is_another-loop. The row data is\n${ JSON.stringify( row.original )}` });
1513
+ } else {
1514
+ await scope.addToReport( scope, { type: `warning`, value: `${ value } is not a valid value for ${ row.var } here. This test will default to \`False\` to avoid problems.` });
1515
+ }
1516
+ return `False`;
1517
+ } else { return `True`; }
1518
+
1519
+ // Story table stuff
1520
+ // Target: 3 (3 items. press 'yes' twice.)
1521
+ // Times used: 0 -> `True`, 1 -> `True`, 2 -> `False`,
1522
+ } else if ( row.times_used >= int_val - 1 ) { // Story table loop will end
1523
+ return `False`;
1524
+
1525
+ } else if ( row.source && row.source.times_used >= int_val - 1 ) { // Just in case use changes a tad
1526
+ return `False`;
1527
+
1528
+ } else if ( isNaN( parseInt( value )) ) { // Loop will end
1529
+ await scope.addToReport( scope, { type: `warning`, value: `${ value } is not a valid value for ${ row.var } here. This test will default to \`False\` to avoid problems.` });
1530
+ return `False`;
1531
+
1532
+ } else { // It's a number. Loop will continue.
1533
+ return `True`;
1534
+ }
1535
+ }, // Ends scope.getThereIsAnotherValue()
1536
+
1537
+ waitUntilContinued: async function ( scope, { url = '', id = '' }) {
1538
+ /** Given a url, attempt to continue until the page url changes or an error
1539
+ is found. Return the error object no matter what. */
1540
+ let prev_url = url;
1541
+ let curr_url = await scope.page.url();
1542
+ let error = await scope.checkForError( scope );
1543
+
1544
+ let timedOut = false; // Lazy timeout?
1545
+ let cont_timeout = setTimeout(function(){ timedOut = true }, scope.timeout);
1546
+
1547
+ // Attempt to solve weird race condition issues where page has not properly continued
1548
+ while ( curr_url === prev_url && !error.was_found && !timedOut ) {
1549
+ await scope.page.waitFor( 50 );
1550
+ curr_url = await scope.page.url();
1551
+ error = await scope.checkForError( scope );
1552
+ }
1553
+
1554
+ let timeout_err_message = `The test tried to continue to the next page, but it took longer than ${ (scope.timeout/1000)/60 } min. Check the screenshot.`
1555
+ if ( timedOut ) { await scope.addToReport(scope, { type: `error`, value: timeout_err_message }); }
1556
+ expect( timedOut, timeout_err_message ).to.be.false;
1557
+
1558
+ clearTimeout( cont_timeout );
1559
+
1560
+ return error;
1561
+ }, // Ends scope.waitUntilContinued()
1562
+
1563
+ checkForError: async function ( scope ) {
1564
+ /* Returns whether any system or user error elements were found and, if so,
1565
+ * returns those elements. The error elements are probably exclusive, but
1566
+ * we're not sure. */
1567
+
1568
+ // All results are lists to match `$x`
1569
+ let possible_errors = {
1570
+ complex_user_error: await scope.page.$$('.alert-danger'),
1571
+ // The below does not work if there's not a space betweein ':' and 'none'
1572
+ simple_user_error: await scope.page.$x(`//*[contains(@class, "da-has-error")][not(contains(@style,"display: none"))]`),
1573
+ system_error: await scope.page.$x( '//meta[@content="docassemble: Error"]' ),
1574
+ };
1575
+
1576
+ let error_handlers = {};
1577
+ let was_found = false;
1578
+ for ( let error_key in possible_errors ) {
1579
+ if ( possible_errors[ error_key ][0] ) {
1580
+ // If you're debugging and you close the window early, the process will still
1581
+ // take this long to exit after the tests finish. Not sure how to account for that.
1582
+ if ( env_vars.DEBUG ) { await scope.page.waitFor( 60 * 1000 ); }
1583
+ was_found = true;
1584
+ error_handlers[ error_key ] = possible_errors[ error_key ][0];
1585
+ }
1586
+ }
1587
+
1588
+ return { was_found, error_handlers };
1589
+ }, // Ends scope.checkForError()
1590
+
1591
+ throwPageError: async function ( scope, { error_handlers = {}, id = '' }) {
1592
+ /* Throw a cucumber error with the error that appeared on the page. */
1593
+ if ( error_handlers.system_error !== undefined ) {
1594
+ let sys_err_message = `The test tried to continue to the next page, but there was a system error. Check the screenshot.`
1595
+ await scope.addToReport(scope, { type: `error`, value: sys_err_message });
1596
+ expect( was_system_error, sys_err_message ).to.be.false;
1597
+
1598
+ } else {
1599
+ // If it wasn't a system error, it was a user error
1600
+ let user_err_message = `The test tried to continue to the next page, but the user got an error. Check the screenshot.`
1601
+ await scope.addToReport(scope, { type: `error`, value: user_err_message });
1602
+ expect( true, user_err_message ).to.be.false;
1603
+ }
1604
+
1605
+ return scope;
1606
+ }, // Ends scope.throwPageError()
1607
+
1608
+ //#####################################
1609
+ //#####################################
1610
+ // Framework Development
1611
+ //#####################################
1612
+ //#####################################
1613
+ reportIncludesAllExpected: async function ( scope, { expected=[] }) {
1614
+ /* Tests the behavior of this testing framework.
1615
+ * Test whether the expected values are included in the test report.
1616
+ *
1617
+ * @param expected {array} Arr of strs that should appear in the report.
1618
+ */
1619
+ let current_report_obj = scope.report.get( scope.scenario_id );
1620
+ let scenario = [ scope.scenario_id, current_report_obj ]; // Mimic how loop works with `Map`
1621
+ let report = await scope.getPrintableScenario( scope, { scenario });
1622
+ let all_are_included = true;
1623
+ for ( let one_expectation of expected ) {
1624
+ if ( !report.includes( one_expectation )) {
1625
+ all_are_included = false;
1626
+ expect( report ).to.contain( one_expectation );
1627
+ }
1628
+ }
1629
+
1630
+ return all_are_included;
1631
+ }, // Ends scope.reportIncludesAllExpected()
1632
+ };