@suffolklitlab/alkiln 3.0.9 → 4.0.0-pre-api.2

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/lib/scope.js CHANGED
@@ -2,9 +2,12 @@ const { expect } = require('chai');
2
2
  const cheerio = require('cheerio');
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
-
6
5
  const safe_filename = require("sanitize-filename");
6
+
7
+ // Ours
7
8
  const env_vars = require('./utils/env_vars');
9
+ const session_vars = require('./utils/session_vars');
10
+ const time = require('./utils/time');
8
11
 
9
12
 
10
13
  let ordinal_to_integer = {
@@ -28,11 +31,13 @@ module.exports = {
28
31
  *
29
32
  * The report has a key for each scenario and each of those
30
33
  * keys should correspond to a list containing the relevant data. */
31
- if ( env_vars.DEBUG ) { console.log(`Report:\n type: ${ type }, value: ${ value }`) }
34
+ if ( env_vars.DEBUG ) { console.log(`Report:\ntype: ${ type }, value: ${ value }`) }
32
35
  let scenario = await scope.makeSureReportPartsExist( scope );
33
36
  scenario.lines.push({ type, value });
34
37
 
38
+ fs.appendFileSync('log.txt', `Report:\ntype: ${ type }, value: ${ value }`);
35
39
  return scenario;
40
+
36
41
  }, // Ends scope.addToReport()
37
42
 
38
43
  makeSureReportPartsExist: async function( scope ) {
@@ -51,7 +56,7 @@ module.exports = {
51
56
  return scenario;
52
57
  }, // Ends scope.makeSureReportPartsExist()
53
58
 
54
- setReportScenarioStatus: async function( scope, { status=`passed` }) {
59
+ setReportScenarioStatus: async function( scope, { status=`PASSED` }) {
55
60
  /* Add final status of scenario to scenario report object - passed, failed, etc. */
56
61
  scope.report.get( scope.scenario_id ).status = status;
57
62
  }, // Ends scope.setReportScenarioStatus()
@@ -114,8 +119,8 @@ module.exports = {
114
119
 
115
120
  let report = await scope.getPrintableScenario( scope, { scenario });
116
121
  let [ scenario_id, data ] = scenario;
117
- if ( data.status === `failed` ) { failed_reports += report; }
118
- else if ( data.status === `passed` ) { passed_reports += report; }
122
+ if ( data.status === `FAILED` ) { failed_reports += report; }
123
+ else if ( data.status === `PASSED` ) { passed_reports += report; }
119
124
  else { other_reports += report; }
120
125
  } // ends for every scenario
121
126
 
@@ -179,11 +184,35 @@ module.exports = {
179
184
  return safer_name;
180
185
  }, // Ends scope.getSafeScenarioFilename()
181
186
 
187
+ getJSONFilename: async function( scope ) {
188
+ let filename = await scope.getSafeScenarioFilename( scope, {
189
+ prefix: `downloads_${ scope.page_id }_JSON`
190
+ });
191
+ filename += `.json`;
192
+ return filename;
193
+ },
194
+
195
+ savePageJSONData: async function( scope ) {
196
+ /** Save the JSON variables of the current page to a json file.
197
+ *
198
+ * @returns {Object} info Info about the result.
199
+ * @returns {string} info.filename The name of the file that was saved.
200
+ * @returns {Object} info.json The variables object.
201
+ */
202
+ // Get a unique json file filename
203
+ let filename = await scope.getJSONFilename( scope );
204
+ // Get the data
205
+ let json = await scope.getPageJSON( scope );
206
+ // Save the data
207
+ fs.writeFileSync( filename, JSON.stringify( json, null, 2 ));
208
+ return { filename, json };
209
+ }, // Ends scope.savePageJSONData()
210
+
182
211
  waitForShowIf: async function waitForShowIf(scope) {
183
212
  // If something might be hidden or shown, wait extra time to let it hide or show
184
213
  // I think `.$()` does not use a timeout
185
214
  let showif = await scope.page.$('.dashowif');
186
- if ( showif ) { await scope.page.waitFor( scope.showif_timeout ); }
215
+ if ( showif ) { await scope.page.waitForTimeout( scope.showif_timeout ); }
187
216
  },
188
217
 
189
218
  afterStep: async function afterStep(scope, options = {waitForShowIf: false, navigated: false, waitForTimeout: 0}) {
@@ -191,37 +220,39 @@ module.exports = {
191
220
  * TODO: discuss splitting into `afterField` and `afterStep` or something. Or just change name
192
221
  * TODO: Update to latest cucumberjs, which as `AfterStep` */
193
222
 
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
- }
223
+ if ( scope.page ) {
224
+ // Use a while loop until the below does not error with `Error: Execution context was destroyed`
225
+ // Temporary fix for race condition. See https://github.com/plocket/docassemble-cucumber/issues/190
226
+ let error_id_elem;
227
+ while ( true ) {
228
+ try {
229
+ // Waiting for `page` to exist. '#da-retry' does not need to exist, it's incidental at this point.
230
+ // If this doesn't throw an error, then the page exists and the result is valid.
231
+ error_id_elem = await scope.page.$('#da-retry');
232
+ break;
233
+ } catch ( err ) {}
234
+ }
205
235
 
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();
236
+ // If there's a system error, throw it
237
+ if ( error_id_elem ) {
238
+ let error_elem = await scope.page.$('blockquote')
239
+ let error_text_prop = await error_elem.getProperty( 'textContent' );
240
+ let system_error_text = await error_text_prop.jsonValue();
211
241
 
212
- await scope.addToReport(scope, { type: `error`, value: system_error_text });
213
- throw system_error_text;
214
- };
242
+ await scope.addToReport(scope, { type: `error`, value: system_error_text });
243
+ throw system_error_text;
244
+ };
245
+
246
+ // Wait for elements that might get revealed
247
+ if ( options.waitForShowIf ) { await scope.waitForShowIf( scope ); }
248
+ } // ends if scope.page
215
249
 
216
- // RESET
250
+ // Reset navigation (TODO: discuss use for observational steps)
217
251
  if ( options.navigated ) { scope.navigated = true; } // Consider `daPageLoad` event
218
252
  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 ); }
253
+
254
+ // Plain old waiting. Here to avoid doing this in each individual place
255
+ if ( options.waitForTimeout ) { await time.waitForTimeout( options.waitForTimeout ); }
225
256
  }, // Ends afterStep
226
257
 
227
258
  tapElement: async function tapElement(scope, elem) {
@@ -293,7 +324,7 @@ module.exports = {
293
324
  return true;
294
325
  } else {
295
326
  // Otherwise wait and then check again
296
- await scope.page.waitFor( 100 );
327
+ await scope.page.waitForTimeout( 100 );
297
328
  await scope.detectDownloadComplete(scope, endTime);
298
329
  }
299
330
 
@@ -525,6 +556,8 @@ module.exports = {
525
556
  encoded_name = match[1];
526
557
  } else {
527
558
  // Some action buttons just use urls instead of da actions
559
+ // TODO: Does this `replace` need the `g` flag?
560
+ // TODO: This only replaces the chars at the start of the string. Is that intentional?
528
561
  encoded_name = href.replace(/^[A-Za-z0-9]*/, '_');
529
562
  }
530
563
  }
@@ -569,6 +602,15 @@ module.exports = {
569
602
  return fields;
570
603
  }, // Ends scope.getAllFields()
571
604
 
605
+ getPageJSON: async function ( scope ) {
606
+ /* Get the JSON data of the current page. */
607
+ return await scope.page.evaluate(async function ( elem ) {
608
+ return await get_interview_variables();
609
+ });
610
+
611
+ // return data;
612
+ }, // Ends scope.getPageJSON()
613
+
572
614
  getFieldSelector: async function ( scope, { $, node }) {
573
615
  /* Given the page object and the node object, return the selector
574
616
  * we'll use to set the field's value later. Excludes `canvas`.
@@ -968,7 +1010,7 @@ module.exports = {
968
1010
  catch(err){ elem.style.background = 'Khaki'; }
969
1011
  return elem.getAttribute('for') || elem.getAttribute('name');
970
1012
  });
971
- await scope.page.waitFor( 400 );
1013
+ await scope.page.waitForTimeout( 400 );
972
1014
  }
973
1015
 
974
1016
  let { tag, type } = field;
@@ -1186,30 +1228,30 @@ module.exports = {
1186
1228
  }
1187
1229
  }
1188
1230
 
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
- }
1231
+ if ( paths.length === 0 ) {
1232
+ await scope.addToReport( scope, { type: `error`, value: `Could not find "${set_to}".` });
1233
+ throw ReferenceError(`Could not find "${set_to}".`)
1234
+ }
1235
+ // Prepare to wait for upload to complete
1236
+ let name = await handle.evaluate( elem => { return elem.getAttribute(`name`); });
1237
+ let selector = `*[name="${ name }"]`
1238
+ let disabled_selector = `${ selector }[disabled]`;
1239
+ let enabled_selector = `${ selector }:not([disabled])`;
1240
+
1241
+ // This comes before the upload starts
1242
+ // Notice `await` is absent. Don't actually wait here.
1243
+ // Just start looking for it now. That way we'll catch it before it's gone
1244
+ let wait_for_disable = scope.page.waitForSelector( disabled_selector );
1245
+
1246
+ // Upload
1247
+ await handle.uploadFile(...paths);
1248
+
1249
+ // Halt progress to wait for the input to be disabled. If it already is, then this
1250
+ // will have resolved already and will keep going.
1251
+ await wait_for_disable;
1252
+ // When it's again enabled, the files have finished loading. Or so I have observed.
1253
+ await scope.page.waitForSelector( enabled_selector );
1254
+
1213
1255
 
1214
1256
  }, // Ends scope.uploadFiles()
1215
1257
 
@@ -1546,7 +1588,7 @@ module.exports = {
1546
1588
 
1547
1589
  // Attempt to solve weird race condition issues where page has not properly continued
1548
1590
  while ( curr_url === prev_url && !error.was_found && !timedOut ) {
1549
- await scope.page.waitFor( 50 );
1591
+ await scope.page.waitForTimeout( 50 );
1550
1592
  curr_url = await scope.page.url();
1551
1593
  error = await scope.checkForError( scope );
1552
1594
  }
@@ -1579,7 +1621,7 @@ module.exports = {
1579
1621
  if ( possible_errors[ error_key ][0] ) {
1580
1622
  // If you're debugging and you close the window early, the process will still
1581
1623
  // 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 ); }
1624
+ if ( env_vars.DEBUG ) { await scope.page.waitForTimeout( 60 * 1000 ); }
1583
1625
  was_found = true;
1584
1626
  error_handlers[ error_key ] = possible_errors[ error_key ][0];
1585
1627
  }
@@ -1605,6 +1647,241 @@ module.exports = {
1605
1647
  return scope;
1606
1648
  }, // Ends scope.throwPageError()
1607
1649
 
1650
+
1651
+ //#####################################
1652
+ //#####################################
1653
+ // Custom timeouts for **a bunch of stuff** for v7+ of cucumber
1654
+ //#####################################
1655
+ //#####################################
1656
+ steps: {
1657
+
1658
+ screenshot: async ( scope, name ) => {
1659
+ /* Download and save a screenshot. `name` does not have to be
1660
+ * unique as the filename will be made unique. */
1661
+ name = name || '';
1662
+ let safe_path = await scope.getSafeScenarioFilename(
1663
+ scope, { prefix: `screenshot_${ name }` }
1664
+ );
1665
+
1666
+ await scope.page.screenshot({
1667
+ path: `./${ safe_path }.jpg`,
1668
+ type: 'jpeg',
1669
+ fullPage: true
1670
+ });
1671
+
1672
+ await scope.afterStep( scope );
1673
+ }, // Ends screenshot()
1674
+
1675
+ add_json_vars_to_report: async ( scope ) => {
1676
+ /** Uses https://docassemble.org/docs/functions.html#js_get_interview_variables
1677
+ * to get the variable values on the page of the interview and add
1678
+ * them to the report. */
1679
+ let data = await scope.page.evaluate(async function ( elem ) {
1680
+ return await get_interview_variables();
1681
+ });
1682
+
1683
+ // In future, may be saved to artifact file
1684
+ await scope.addToReport( scope, {
1685
+ type: `json`,
1686
+ value: JSON.stringify( data, null, 2 )
1687
+ });
1688
+ }, // Ends add_json_vars_to_report()
1689
+
1690
+ link_works: async ( scope, linkText ) => {
1691
+ let [link] = await scope.page.$x(`//a[contains(text(), "${ linkText }")]`);
1692
+ let prop_obj = await link.getProperty( 'href' );
1693
+ let actual_url = await prop_obj.jsonValue();
1694
+
1695
+ let linkPage = await scope.browser.newPage();
1696
+ let response = await linkPage.goto( actual_url, { waitUntil: 'domcontentloaded' });
1697
+
1698
+ let msg = `The "${ linkText }" link is broken.`;
1699
+ if ( !response.ok() ) { await scope.addToReport( scope, { type: `error`, value: msg }); }
1700
+ expect( response.ok(), msg ).to.be.true;
1701
+
1702
+ linkPage.close()
1703
+
1704
+ await scope.afterStep(scope);
1705
+ }, // Ends link_works()
1706
+
1707
+ set_regular_var: async ( scope, var_name, set_to ) => {
1708
+ /** Set any variable (with or without a choice associated with it) to a value. Set:
1709
+ * - Buttons associated with variables
1710
+ * - Dropdowns
1711
+ * - Checkboxes (multiple choice checkboxes must be set to "true" or "false")
1712
+ * - Radio buttons
1713
+ * - Text inputs
1714
+ * - Textareas
1715
+ */
1716
+ let var_data = await scope.normalizeTable( scope, {
1717
+ var_data: [{ var: var_name, value: set_to, trigger: '' }],
1718
+ });
1719
+ // Don't continue if the variable doesn't navigate and if this variable
1720
+ // cannot be seton this page, trigger an error
1721
+ await scope.setFields( scope, { var_data, ensure_navigation: false, ensure_all_vars_used: true });
1722
+ }, // Ends set_regular_var()
1723
+
1724
+ tap_term: async ( scope, phrase ) => {
1725
+ /** Tap a link that doesn't navigate. Depends on the language of the text. */
1726
+ const [link] = await scope.page.$x(`//a[contains(text(), "${phrase}")]`);
1727
+
1728
+ let msg = `The term "${ phrase }" seems to be missing.`;
1729
+ if ( !link ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
1730
+ expect( link, msg ).to.exist;
1731
+
1732
+ await link.evaluate( elem => { return elem.click(); });
1733
+
1734
+ await scope.afterStep(scope, {waitForShowIf: true});
1735
+ }, // Ends tap_term()
1736
+
1737
+ sign: async ( scope ) => {
1738
+ /** Sign on a signature page. */
1739
+ await scope.sign( scope );
1740
+ await scope.afterStep(scope);
1741
+ }, // Ends sign()
1742
+
1743
+ download: async ( scope, filename ) => {
1744
+ /* Taps the link that leads to the given filename to trigger downloading.
1745
+ * and waits till the file has been downloaded before allowing the tests to continue.
1746
+ * WARNING: Cannot download the same file twice in a single scenario.
1747
+ * WARNING: Must not have any characters that are totally unique to that
1748
+ * page load. It's usually just the name of the file.
1749
+ */
1750
+ let [elem] = await scope.page.$x(`//a[contains(@href, "${ filename }")]`);
1751
+
1752
+ let msg = `"${ filename }" seems to be missing. Cannot find a link to that document.`;
1753
+ if ( !elem ) { await scope.addToReport(scope, { type: `error`, value: msg }); }
1754
+ expect( elem, msg ).to.exist;
1755
+
1756
+ scope.toDownload = filename;
1757
+ await elem.evaluate( elem => { return elem.click(); });
1758
+ await scope.detectDownloadComplete( scope );
1759
+ }, // Ends download()
1760
+
1761
+ set_al_name: async ( scope, var_base, name_str ) => {
1762
+ /** Given a variable name and a space-separated string, fills the
1763
+ * appropriate fields on an Assembly Line name question.
1764
+ * It handles any of:
1765
+ * 1. A one-word value to set name.text (ex: 'LandlordEtAl')
1766
+ * 1. An Individual's name that it will set based on the number of words:
1767
+ * 1. Two words - a first and last name (ex: "Tal Taylor")
1768
+ * 1. Three words - a first, middle, and last name (ex: "Tal Tay Taylor")
1769
+ * 1. Four words - a first, middle, last, and suffix (ex: "Tal Tay Taylor III")
1770
+ *
1771
+ * The string 'the var'/'the variable' part of the step is optional, as you can see below.
1772
+ */
1773
+ let name_patterns = [
1774
+ [ `text` ],
1775
+ [ `first`, `last` ],
1776
+ [ `first`, `middle`, `last` ],
1777
+ [ `first`, `middle`, `last`, `suffix` ],
1778
+ ];
1779
+
1780
+ let name_parts = name_str.split(' ');
1781
+
1782
+ if (name_parts.length > 4) {
1783
+ let name_parts_last = name_parts[name_parts.length - 1];
1784
+ let name_parts_slice = name_parts.slice(0, 3);
1785
+ name_parts_slice.push(name_parts_last);
1786
+ name_parts = name_parts_slice;
1787
+
1788
+ await scope.addToReport(scope, {
1789
+ type: `warning`,
1790
+ value: `The name "${name_str}" has more than 4 parts, but 4 is the maximum allowed. The test will set the name to "${name_parts.join(" ")}".`
1791
+ });
1792
+ }
1793
+
1794
+ let desired_pattern = name_patterns[( name_parts.length - 1 )];
1795
+
1796
+ for ( let parti = 0; parti < name_parts.length; parti++ ) {
1797
+ let var_name = `${ var_base }.name.${ desired_pattern[ parti ] }`
1798
+ let value = name_parts[ parti ];
1799
+ await scope.setVar( scope, var_name, value );
1800
+ }
1801
+
1802
+ // Continue to next page...?
1803
+ await scope.afterStep(scope, {waitForShowIf: true});
1804
+ }, // Ends set_al_name()
1805
+
1806
+ set_al_address: async ( scope, var_base, address_str ) => {
1807
+ /** Given a variable name and a four-part address formatted as below,
1808
+ * sets the appropriate Assembly Line address fields.
1809
+ *
1810
+ * The string 'the var'/'the variable' part of the step is optional,
1811
+ * as you can see below.
1812
+ */
1813
+
1814
+ // Get all the parts of the address properly separated
1815
+ // First, split apart each line of the address to [address line 1, address line 2, city, state zip]...
1816
+ let split = address_str.split(/,\s*/);
1817
+ // ...then, separate the state and the zip code...
1818
+ let last_parts = (split.pop()).split(/\s+/);
1819
+ // ...and add them back in, to end up with [address line 1, address line 2, city, state, zip].
1820
+ let address_parts = split.concat( last_parts );
1821
+ let start = `${ var_base }.address.`;
1822
+
1823
+ let index = 0;
1824
+ await scope.setVar( scope, `${start}address`, address_parts[0] );
1825
+ // Ensure it's without 'Unit '/'unit '
1826
+ await scope.setVar( scope, `${start}unit`, (address_parts[1].toLowerCase()).replace(/unit\s*/, '') );
1827
+ await scope.setVar( scope, `${start}city`, address_parts[2] );
1828
+ await scope.setVar( scope, `${start}state`, address_parts[3] );
1829
+ await scope.setVar( scope, `${start}zip`, address_parts[4] );
1830
+
1831
+ await scope.afterStep(scope, {waitForShowIf: true});
1832
+ }, // Ends set_al_address()
1833
+
1834
+ sign_in: async function ( scope, { email_secret_name, password_secret_name }) {
1835
+ /** Allow the developer to log a user into their server using GitHub
1836
+ * secrets to authenticate.
1837
+ */
1838
+ // If there is no browser open, start a new one
1839
+ if (!scope.browser) {
1840
+ scope.browser = await scope.driver.launch({ headless: !session_vars.DEBUG });
1841
+ }
1842
+ if ( !scope.page ) { scope.page = await scope.browser.newPage(); }
1843
+
1844
+ // Go to the sign in page
1845
+ let login_url = `${ session_vars.get_da_server_url() }/user/sign-in`;
1846
+ await scope.page.goto( login_url, { waitUntil: `domcontentloaded`, timeout: scope.timeout })
1847
+ await scope.page.waitForSelector( `.dabody` );
1848
+
1849
+ let email = process.env[ email_secret_name ];
1850
+ let password = process.env[ password_secret_name ];
1851
+
1852
+ // Give as many useful messages to the user as possible if the env vars
1853
+ // aren't defined. Eventually, just provide a link to the docs because
1854
+ // this is complex
1855
+ let email_msg = `The email address GitHub SECRET "${ email_secret_name }" doesn't exist. Check for a typo and check your workflow file.`
1856
+ if ( email === undefined ) {
1857
+ await scope.addToReport( scope, { type: `error`, value: email_msg })
1858
+ }
1859
+ let password_msg = `The password GitHub SECRET "${ password_secret_name }" doesn't exist. Check for a typo and check your workflow file.`
1860
+ if ( password === undefined ) {
1861
+ await scope.addToReport( scope, { type: `error`, value: password_msg })
1862
+ }
1863
+ expect( email, email_msg ).to.not.equal( undefined );
1864
+ expect( password, password_msg ).to.not.equal( undefined );
1865
+
1866
+ // Try to log in
1867
+ await scope.page.type( `#email`, email );
1868
+ await scope.page.type( `#password`, password );
1869
+ await scope.page.$eval( `button[name="submit"]`, (elem) => { return elem.click(); });
1870
+
1871
+ // Wait to see the success message.
1872
+ // This is a bit of an assumption and needs success and failure testing
1873
+ // TODO: use a Promise.race to fail faster.
1874
+ await scope.page.waitForSelector( `.datopcenter .alert-success` );
1875
+
1876
+ await scope.addToReport( scope, {
1877
+ type: `row`,
1878
+ value: `Logged into ${ session_vars.get_da_server_url() }/user/sign-in as a ${ email } with ${ password }.`
1879
+ });
1880
+ }, // Ends sign_in
1881
+
1882
+ }, // ends scope.steps
1883
+
1884
+
1608
1885
  //#####################################
1609
1886
  //#####################################
1610
1887
  // Framework Development