@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.
- package/.github/workflows/test_tests.yml +77 -0
- package/CHANGELOG.md +381 -0
- package/README.md +18 -0
- package/action.yml +158 -0
- package/lib/DOM.md.html +63 -0
- package/lib/github_integration/install-branch.js +84 -0
- package/lib/github_integration/puppeteer-utils.js +224 -0
- package/lib/github_integration/setup.js +4 -0
- package/lib/github_integration/takedown.js +4 -0
- package/lib/index.js +2 -0
- package/lib/scope.js +1632 -0
- package/lib/steps.js +878 -0
- package/lib/utils/env_vars.js +173 -0
- package/lib/utils/langs.js +51 -0
- package/lib/world.js +10 -0
- package/package.json +53 -0
- package/tests/features/assembly_line.feature +43 -0
- package/tests/features/establishing_steps.feature +22 -0
- package/tests/features/example_test.feature +11 -0
- package/tests/features/interactive_steps.feature +85 -0
- package/tests/features/observation_steps.feature +97 -0
- package/tests/features/reports.feature +482 -0
- package/tests/features/some_png_1.png +0 -0
- package/tests/features/some_png_2.png +0 -0
- package/tests/features/story_table_formats.feature +77 -0
- package/tests/features/story_tables.feature +88 -0
- package/tests/unit_tests/ensureSpecialRows.test.js +34 -0
- package/tests/unit_tests/env_vars.test.js +53 -0
- package/tests/unit_tests/fields.fixtures.js +904 -0
- package/tests/unit_tests/getAllFields.test.js +142 -0
- package/tests/unit_tests/getMatchingRows.test.js +261 -0
- package/tests/unit_tests/getSafeScenarioBaseFilename.fixtures.js +12 -0
- package/tests/unit_tests/getSafeScenarioBaseFilename.test.js +62 -0
- package/tests/unit_tests/getSafeScenarioFilename.test.js +83 -0
- package/tests/unit_tests/html.fixtures.js +861 -0
- package/tests/unit_tests/matches.fixtures.js +168 -0
- package/tests/unit_tests/names.fixtures.js +35 -0
- package/tests/unit_tests/normalizeTable.test.js +22 -0
- package/tests/unit_tests/special_rows.fixtures.js +101 -0
- 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
|
+
};
|