@suffolklitlab/alkiln 5.3.0 → 5.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -44,6 +44,19 @@ Format:
44
44
  -->
45
45
  <!-- ## [Unreleased] -->
46
46
 
47
+ ## [5.4.1] - 2023-10-18
48
+
49
+ ### Internal
50
+ - BREAKS ALKilnInThePlayground v1.2.0 and below (as we discussed) as that project is still exploratory. Can now take the location of the "sources" folder or folders as an argument in the command line. The formats are `npm run cucumber @tags -- --sources=./foo` and `alkiln-run @tags --sources=./foo`.
51
+
52
+ ## [5.4.0] - 2023-10-14
53
+
54
+ ### Added
55
+
56
+ - New step to compare PDFs!
57
+ - An example: `I expect the baseline PDF "something-in-sources.pdf" and the new PDF "downloaded.pdf" to be the same`
58
+ - See [#774](https://github.com/SuffolkLITLab/ALKiln/pull/774) for more details.
59
+
47
60
  ## [5.3.0] - 2023-10-14
48
61
 
49
62
  ### Added
@@ -6,64 +6,97 @@ const session_vars = require('./utils/session_vars');
6
6
 
7
7
  const cucumber_api = require('@cucumber/cucumber/api');
8
8
 
9
- (async function main() {
10
- let argv = process.argv;
11
-
12
- const environment = { cwd: process.cwd() };
13
- console.log("alkiln-run: environment: %o", environment);
14
- const provided_config = {
15
- "publishQuiet": true,
16
- "require": "./lib/index.js",
17
- "format": ["progress", "summary:cucumber-report.txt"],
18
- "paths": ["docassemble/*/data/sources/*.feature"],
19
- "retry": 1,
20
- }
21
-
22
-
23
- // If they're running it from an interview on their server instead of from GitHub
24
- if ( session_vars.get_origin() === `playground` ) {
25
- provided_config.paths.push(`/usr/share/docassemble/files/playgroundsources/${session_vars.get_user_id()}/${session_vars.get_user_project_name()}/*.feature`);
26
- // For S3
27
- provided_config.paths.push(`/tmp/playgroundsources/${session_vars.get_user_id()}/${session_vars.get_user_project_name()}/*.feature`);
9
+ function build_config( command_args ) {
10
+ // Turn all the paths to the sources folders into paths to the feature files
11
+ let sources_paths = session_vars.get_sources_paths();
12
+ let feature_paths = [];
13
+ for ( let source of sources_paths ) {
14
+ feature_paths.push( `${ source }/*.feature` );
28
15
  }
29
16
 
30
- let tags = argv.slice(2);
31
- if ( session_vars.get_origin() === `playground` ) {
32
- tags = session_vars.get_tags();
17
+ const config = {
18
+ publishQuiet: true,
19
+ require: `./lib/index.js`,
20
+ format: [`progress`, `summary:cucumber-report.txt`],
21
+ paths: feature_paths,
22
+ retry: 1,
33
23
  }
34
24
 
25
+ // Normalize tag expressions, if there are any
26
+ let tags = command_args[`_`];
35
27
  if (tags.length > 0) {
36
28
  let joined_tags = '';
37
29
  // Cucumber expects tags in boolean expressions, like (@a0 or @a1) and @random.
38
30
  // If we get a list of only tags, then "or" them all together
39
- if (tags.every((val) => val.startsWith("@"))) {
40
- joined_tags = tags.join(' or ');
31
+ if (tags.every((val) => val.startsWith(`@`))) {
32
+ joined_tags = tags.join(` or `);
41
33
  }
42
34
  // If we get a list of seemingly no tags, assume they meant to use tags
43
- else if (tags.every((val) => !val.startsWith("@"))) {
44
- joined_tags = tags.map((val) => "@" + val).join(' or ');
35
+ else if (tags.every((val) => !val.startsWith(`@`))) {
36
+ joined_tags = tags.map((val) => `@` + val).join(` or `);
45
37
  }
46
38
  // If it's a mix of tags and no tags, it's probably a boolean expression!
47
39
  else {
48
- joined_tags = tags.join(' ');
40
+ joined_tags = tags.join(` `);
49
41
  }
50
42
 
51
- provided_config.tags = joined_tags;
52
- } // end if there are tags
43
+ config.tags = joined_tags;
44
+ }
45
+
46
+ return config;
47
+ }; // Ends build_config()
48
+
49
+
50
+ (async function main() {
51
+ /** Create the cucumber config, run the tests, create and
52
+ * manipluate the output. */
53
+ const environment = { cwd: process.cwd() };
54
+ console.log("alkiln-run: environment: %o", environment);
55
+
56
+ // We'll use the user info to construct the possible paths to /sources
57
+ let id = session_vars.get_user_id();
58
+ // `project` is the name in the server docassemble "Project" page
59
+ let project = session_vars.get_user_project_name();
60
+
61
+ // process.argv is a list of strings of commands and args.
62
+ // If using `npm run cucumber`, use `--` before `--sources=./foo`
63
+ let argv = require(`minimist`)( process.argv.slice(2), {
64
+ default: {
65
+ sources: [
66
+ `./docassemble/*/data/sources`, // In GitHub folder
67
+ `/usr/share/docassemble/files/playgroundsources/${id}/${project}/*.feature`, // From playground without S3
68
+ `/tmp/playgroundsources/${id}/${project}/*.feature` // From playground with S3
69
+ ],
70
+ }
71
+ });
72
+
73
+ // Make sure `sources` is always a list
74
+ let sources_list = argv.sources;
75
+ if ( typeof argv.sources === `string` ) {
76
+ sources_list = [ argv.sources ];
77
+ }
53
78
 
79
+ let one_dir_exists = session_vars.set_sources_paths( sources_list );
80
+ if ( !one_dir_exists ) {
81
+ // If we don't throw, it'll be more confusing when no tests run
82
+ throw new ReferenceError( `ALKiln ERROR: ALKiln can't find your "sources" folder(s). See above for details.`);
83
+ }
84
+
54
85
  const { runConfiguration } = await cucumber_api.loadConfiguration({
55
- provided: provided_config
86
+ provided: build_config( argv ),
56
87
  }, environment);
88
+
57
89
  console.log("alkiln-run: runConfiguration: %o", runConfiguration);
90
+
58
91
  var path = require('path');
59
92
  var codeDir = path.dirname(require.main.filename);
93
+ console.log("alkiln-run: working directory: %o", codeDir);
60
94
  const support = await cucumber_api.loadSupport({ support: {
61
- requireModules: [`${ codeDir }/index.js`],
62
- requirePaths: [ `${ codeDir }/index.js`],
95
+ requireModules: [ `${ codeDir }/index.js` ],
96
+ requirePaths: [ `${ codeDir }/index.js` ],
63
97
  importPaths: [],
64
98
  }, sources: {paths: []}}, environment);
65
99
 
66
- console.log("alkiln-run: working directory: %o", codeDir);
67
100
  console.log(`\n\n`);
68
101
 
69
102
  const { success } = await cucumber_api.runCucumber({...runConfiguration, support});
@@ -86,6 +119,6 @@ const cucumber_api = require('@cucumber/cucumber/api');
86
119
 
87
120
  // Put it in the correct file in the correct folder
88
121
  let artifacts_path = session_vars.get_artifacts_path_name();
89
- fs.appendFileSync( `${ artifacts_path}/${ log.debug_log_file }`, data);
122
+ fs.appendFileSync( `${ artifacts_path }/${ log.debug_log_file }`, data);
90
123
  });
91
124
  })();
package/lib/scope.js CHANGED
@@ -9,6 +9,7 @@ const safe_filename = require("sanitize-filename");
9
9
  const faker = require('@faker-js/faker/locale/en').faker;
10
10
 
11
11
  // Ours
12
+ const pdf_reader = require('./utils/pdf_reader');
12
13
  const session_vars = require('./utils/session_vars');
13
14
  const get_base_interview_url = require(`./docassemble/get_base_interview_url`);
14
15
  const time = require('./utils/time');
@@ -1679,90 +1680,77 @@ module.exports = {
1679
1680
  return valid_date;
1680
1681
  }, // Ends scope.today_to_date()
1681
1682
 
1682
- uploadFiles: async function ( scope, { handle, answer }) {
1683
- /* Try to upload one or more files to a file-type input field.
1684
- * Does not submit, so no danger of timeout with server reload.
1683
+ findFiles: async function( scope, {to_find_names}) {
1684
+ /** Returns a list of paths to the sources folder associated
1685
+ * with the given list of file names.
1685
1686
  *
1686
- * Returns whether the row was used or not.
1687
+ * @param to_find_names { [str] } List of file names
1688
+ *
1689
+ * @returns { [str] } sources folder paths
1687
1690
  */
1688
-
1689
1691
  // First make sure the list of files was defined. Don't remove spaces yet -
1690
1692
  // allow a typo where someone forgot to include a space after a comma.
1691
- let uploads_names = answer.split(`,`);
1692
- if ( uploads_names.length === 0 ) {
1693
- reports.addToReport( scope, {
1694
- type: `error`,
1695
- value: `Your list of files to upload is empty.`
1696
- });
1697
- return false; // The row wasn't used
1698
- }
1699
-
1700
- let dirs = [];
1701
- if ( session_vars.get_origin() === `playground` ) {
1702
- let files_playground = fg.sync(
1703
- [ `/usr/share/docassemble/files/playgroundsources/${session_vars.get_user_id()}/${session_vars.get_user_project_name()}` ],
1704
- { onlyFiles: false, suppressErrors: true }
1705
- );
1706
- let files_s3 = fg.sync(
1707
- [ `/tmp/playgroundsources/${session_vars.get_user_id()}/${session_vars.get_user_project_name()}` ],
1708
- { onlyFiles: false, suppressErrors: true }
1709
- );
1710
-
1711
- dirs = files_playground.concat( files_s3 );
1712
- } else {
1713
- dirs = fg.sync(
1714
- [ `**/docassemble/**/data/sources` ],
1715
- { onlyFiles: false, suppressErrors: true }
1716
- );
1717
- }
1718
-
1719
- if ( session_vars.get_debug() ) {
1720
- console.log( `All discovered dirs to upload from:`, dirs );
1721
- }
1722
-
1723
- // Make sure sources directory exists and has files in it
1724
- if ( dirs.length === 0 ) {
1693
+ // TODO: wish we had the var name here that folks are trying to set
1694
+ if ( to_find_names.length === 0 ) {
1725
1695
  reports.addToReport( scope, {
1726
1696
  type: `warning`,
1727
- value: `The test files need to be in the "sources" directory, `
1728
- + `but ALKiln couldn't find the directory or it was empty.`
1697
+ value: `Your Step is missing the names of which file(s) to use.`
1729
1698
  });
1730
- return false; // The row wasn't used
1699
+ return [];
1731
1700
  }
1732
1701
 
1733
- // Get the full paths of all the files
1734
- let dir = null;
1702
+ let sources_dirs = session_vars.get_sources_paths();
1703
+ // Get the full paths of all the existing files
1735
1704
  let paths = [];
1736
1705
  let missing_files = []; // For any user warnings
1737
- for ( let name of uploads_names ) {
1738
-
1706
+ for ( let name of to_find_names ) {
1707
+
1739
1708
  let trimmed_name = name.trim();
1740
1709
  let matching_paths = [];
1741
-
1742
- for ( let one_dir of dirs ) {
1743
- // accumulate all matching paths
1744
- let new_paths = fg.sync([ `${ one_dir }/${ trimmed_name }` ]);
1710
+ // Look for the file in all "sources" folders
1711
+ for ( let one_dir of sources_dirs ) {
1712
+ let new_paths = fg.sync(
1713
+ [ `${ one_dir }/${ trimmed_name }` ],
1714
+ { suppressErrors: true }
1715
+ );
1745
1716
  matching_paths.push( ...new_paths );
1746
- } // ends for each dir
1717
+ }
1747
1718
 
1748
1719
  if ( matching_paths.length === 0 ) {
1749
1720
  missing_files.push( trimmed_name );
1750
1721
  } else {
1751
1722
  paths.push( ...matching_paths );
1752
1723
  }
1753
-
1754
1724
  } // ends for each file
1755
1725
 
1756
1726
  if ( missing_files.length > 0 ) {
1757
1727
  reports.addToReport( scope, {
1758
1728
  type: `warning`,
1759
- value: `ALKiln could not find "${ missing_files.join('", "') }" in any of these folders: "${ dirs.join('", "') }".`
1729
+ value: `ALKiln could not find "${ missing_files.join('", "') }" in the folder(s) at "${ sources_dirs.join('", "') }".`
1760
1730
  });
1761
1731
  }
1762
1732
 
1763
- // If all files missing, return that the row wasn't used
1764
- if ( missing_files.length === uploads_names.length ) {
1765
- return false; // The row wasn't used
1733
+ // If all files missing, return an empty array
1734
+ if ( missing_files.length === to_find_names.length ) {
1735
+ return [];
1736
+ }
1737
+ return paths;
1738
+ }, // Ends scope.findFiles()
1739
+
1740
+ uploadFiles: async function ( scope, { handle, answer }) {
1741
+ /* Try to upload one or more files to a file-type input field.
1742
+ * Does not submit, so no danger of timeout with server reload.
1743
+ *
1744
+ * Returns whether the row was used or not.
1745
+ */
1746
+
1747
+ // First make sure the list of files was defined. Don't remove spaces yet -
1748
+ // allow a typo where someone forgot to include a space after a comma.
1749
+ let to_find_names = answer.split(`,`);
1750
+ let paths = await scope.findFiles(scope, {to_find_names});
1751
+ if (paths.length == 0) {
1752
+ // The row wasn't used
1753
+ return false;
1766
1754
  }
1767
1755
 
1768
1756
  // Prepare to wait for upload to complete
@@ -1988,7 +1976,7 @@ module.exports = {
1988
1976
  }
1989
1977
  if ( num_unused_rows > 0 ) {
1990
1978
  let { id } = await scope.examinePageID( scope, 'none to match' );
1991
- let msg = `Missing variable or variables on page "${id}"`;
1979
+ let msg = `Error on page "${id}". See screenshot and see text above for more details.`;
1992
1980
  reports.addToReport(scope, { type: `error`, value: msg });
1993
1981
  expect( num_unused_rows, msg ).to.equal( 0 );
1994
1982
  }
@@ -2503,6 +2491,32 @@ module.exports = {
2503
2491
  await scope.detectDownloadComplete( scope );
2504
2492
  }, // Ends scope.steps.download()
2505
2493
 
2494
+ compare_pdfs: async function (scope, {existing_pdf_path, new_pdf_path}) {
2495
+ let existing_paths = await scope.findFiles(scope, {to_find_names: [existing_pdf_path]});
2496
+ if (existing_paths.length == 0) {
2497
+ // The row wasn't used
2498
+ let msg = `Could not find the existing PDF at ${ existing_pdf_path }`;
2499
+ reports.addToReport(scope, { type: `error`, value: msg });
2500
+ scope.failed_pdf_compares.push(msg);
2501
+ // Return early since we couldn't find the PDF
2502
+ return;
2503
+ }
2504
+ let full_existing_path = existing_paths[0];
2505
+ let full_new_pdf_path = `${scope.paths.scenario}/${new_pdf_path}`;
2506
+ let diffs = await pdf_reader.compare_pdfs(full_existing_path, full_new_pdf_path);
2507
+ if (diffs) {
2508
+ let added_str = diffs.filter(part => part.added).reduce((err_str, part) => {
2509
+ return err_str + `- ${ part.value }\n`
2510
+ }, 'The new PDF added: \n');
2511
+ let removed_str = diffs.filter(part => part.removed).reduce((err_str, part) => {
2512
+ return err_str + `- ${ part.value }\n`
2513
+ }, 'The new PDF removed: \n');
2514
+ let msg = `The PDFs were not the same.\n${ added_str }\n${removed_str}\n\n You can see the full PDFs at ${ full_existing_path } and ${ full_new_pdf_path}`;
2515
+ reports.addToReport(scope, { type: `error`, value: msg });
2516
+ scope.failed_pdf_compares.push(msg);
2517
+ }
2518
+ },
2519
+
2506
2520
  set_al_name: async ( scope, var_base, name_str ) => {
2507
2521
  /** Given a variable name and a space-separated string, fills the
2508
2522
  * appropriate fields on an Assembly Line name question.
@@ -2930,4 +2944,5 @@ module.exports = {
2930
2944
 
2931
2945
  return none_included;
2932
2946
  },
2947
+
2933
2948
  };
package/lib/steps.js CHANGED
@@ -147,6 +147,7 @@ Before(async (scenario) => {
147
147
  scope.page_id = null;
148
148
  scope.check_all_for_a11y = false;
149
149
  scope.passed_all_for_a11y = true;
150
+ scope.failed_pdf_compares = [];
150
151
 
151
152
  // Reset default timeout
152
153
  scope.timeout = default_timeout;
@@ -705,6 +706,19 @@ Then(/I get the(?: page's)?(?: JSON)? var(?:iable)?s and val(?:ue)?s/i, { timeou
705
706
  );
706
707
  });
707
708
 
709
+ Then(/I expect the baseline PDF "([^"]+)" and the new PDF "([^"]+)" to be the same/i, async (existing_pdf_path, new_pdf_path) => {
710
+ /** Compare the text or fields of a baseline PDF and the newly downloaded PDF.
711
+ *
712
+ * Variations:
713
+ * Then I expect the baseline PDF "simple-doc-Baseline.pdf" and the new PDF "simple-doc.pdf" to be the same
714
+ */
715
+
716
+ return await wrapPromiseWithTimeout(
717
+ scope.steps.compare_pdfs(scope, {existing_pdf_path, new_pdf_path}),
718
+ scope.timeout
719
+ );
720
+ });
721
+
708
722
  //#####################################
709
723
  //#####################################
710
724
  // Actions
@@ -1104,12 +1118,21 @@ After(async function(scenario) {
1104
1118
  if ( scope.check_all_for_a11y && !scope.passed_all_for_a11y) {
1105
1119
  // The fact that the scenario failed has already been noted earlier in the report
1106
1120
  changeable_test_status = `FAILED`;
1107
- scope.addToReport(scope, {
1121
+ reports.addToReport(scope, {
1108
1122
  type: `error`,
1109
1123
  value: `Accessibility standards weren't met. See information above.`
1110
1124
  });
1111
1125
  }
1112
1126
 
1127
+ if ( scope.failed_pdf_compares.length > 0) {
1128
+ let msg = scope.failed_pdf_compares.reduce((str, new_msg) => `${ str }\n---\n${ new_msg }`)
1129
+ changeable_test_status = `FAILED`;
1130
+ reports.addToReport(scope, {
1131
+ type: `error`,
1132
+ value: `PDF comparison failed ${ scope.failed_pdf_compares.length } time(s)\n---\n${ msg }\n---\n`
1133
+ });
1134
+ }
1135
+
1113
1136
  // Record status before internal report tests can interfere with values
1114
1137
  reports.setReportScenarioStatus( scope, { status: changeable_test_status });
1115
1138
 
@@ -0,0 +1,113 @@
1
+ // All functions dealing with getting information from PDFs
2
+
3
+ const pdfjs_lib = require("pdfjs-dist/legacy/build/pdf.js");
4
+ const diff_lib = require("diff");
5
+
6
+ let _load_pdf = async function(pdf_path) {
7
+ // Simple wrapper to get the document
8
+ return await pdfjs_lib.getDocument({
9
+ url: pdf_path,
10
+ // https://github.com/mozilla/pdf.js/issues/4244#issuecomment-1479534301
11
+ useSystemFonts: true
12
+ }).promise;
13
+ };
14
+
15
+ let _get_content = async function(pdf_doc) {
16
+ let num_pages = pdf_doc.numPages;
17
+ let contents = "";
18
+ for (var i = 1; i <= num_pages; i++) {
19
+ const page_contents = await (await pdf_doc.getPage(i)).getTextContent();
20
+ contents += "\n" + page_contents.items.map((item) => item.str).join(" ");
21
+ }
22
+ return contents;
23
+ };
24
+
25
+ let pdf_reader = {};
26
+
27
+ pdf_reader.get_content = async function(pdf_path) {
28
+ /* Gets the full text content from the PDF.
29
+ *
30
+ * @returns str
31
+ */
32
+
33
+ let doc = await _load_pdf(pdf_path);
34
+ return await _get_content(doc);
35
+ }; // Ends get_content()
36
+
37
+ pdf_reader.get_fields = async function(pdf_path) {
38
+ /* Gets all of the fields in the PDF
39
+ *
40
+ * Example:
41
+ * {
42
+ * "users1_name_first": [
43
+ * {
44
+ * "id": "6R",
45
+ * "value": "aeu",
46
+ * "defaultValue": "",
47
+ * "multiline": false,
48
+ * "password": false,
49
+ * "charLimit": 0,
50
+ * "comb": false,
51
+ * "editable": true,
52
+ * "hidden": false,
53
+ * "name": "users1_name_first",
54
+ * "rect": [
55
+ * 143.52,
56
+ * 660.72,
57
+ * 315.11,
58
+ * 674.88
59
+ * ],
60
+ * "actions": null,
61
+ * "page": 0,
62
+ * "strokeColor": null,
63
+ * "fillColor": null,
64
+ * "rotation": 0,
65
+ * "type": "text"
66
+ * }
67
+ * ],
68
+ */
69
+ let doc = await _load_pdf(pdf_path);
70
+ const field_objects = await doc.getFieldObjects();
71
+ return field_objects;
72
+ }; // Ends get_fields
73
+
74
+ pdf_reader.get_field_value = async function(pdf_path, field_name) {
75
+ let field_objects = await pdf_reader.get_fields(pdf_path);
76
+ if (field_name in field_objects) {
77
+ for (let inst of field_objects[field_name]) {
78
+ if ("value" in inst) {
79
+ return inst["value"];
80
+ }
81
+ }
82
+ }
83
+ return null;
84
+ }; // Ends get_field
85
+
86
+ pdf_reader.compare_pdfs = async function(existing_pdf_path, new_pdf_path) {
87
+ /**
88
+ * Returns the change objects between the PDF fields, if there's any diff
89
+ */
90
+ let existing_doc = await _load_pdf(existing_pdf_path);
91
+ let new_doc = await _load_pdf(new_pdf_path);
92
+ let existing_content = await _get_content(existing_doc);
93
+ let new_content = await _get_content(new_doc);
94
+
95
+ // Assumption: authors won't be changing their PDF templates programmatically;
96
+ // so if the text of the PDFs are different, then it's actually the fields that are different,
97
+ // but they aren't editable / are flattened into the PDF.
98
+ let change = diff_lib.diffWords(existing_content, new_content);
99
+ if (change.some(diff => diff.added || diff.removed)) {
100
+ return change;
101
+ }
102
+
103
+ const existing_fields = await existing_doc.getFieldObjects();
104
+ const new_fields = await new_doc.getFieldObjects();
105
+
106
+ let changeObjs = diff_lib.diffJson(existing_fields, new_fields);
107
+ if (changeObjs.some(diff => diff.added || diff.removed)) {
108
+ return changeObjs;
109
+ }
110
+ return null;
111
+ }; // End compare_pdfs
112
+
113
+ module.exports = pdf_reader;
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs');
2
+ const fg = require('fast-glob');
2
3
  const path = require('path');
3
4
  require('dotenv').config();
4
5
 
@@ -37,13 +38,6 @@ session_vars.get_origin = function () {
37
38
  };
38
39
  session_vars.get_user_project_name = function () { return process.env._PROJECT_NAME || null; };
39
40
  session_vars.get_user_id = function () { return process.env._USER_ID || null; };
40
- session_vars.get_tags = function () {
41
- if ( process.env._TAGS ) {
42
- return process.env._TAGS.split(` `);
43
- } else {
44
- return [];
45
- }
46
- };
47
41
 
48
42
  // More complex logic
49
43
  session_vars.get_server_reload_timeout = function () {
@@ -210,6 +204,61 @@ session_vars.get_artifacts_path_name = function () {
210
204
  }
211
205
  }; // Ends get_artifacts_path_name()
212
206
 
207
+ session_vars.set_sources_paths = function ( paths ) {
208
+ /** Returns whether at least one of the given path exists. Store
209
+ * a list of the existing paths. Warn of any missing paths. Can be
210
+ * used for getting, e.g .feature files, files to upload, etc.
211
+ *
212
+ * Our code should ensure this gets run before cucumber tests get triggered.
213
+ *
214
+ * @param paths { [str] } List of paths to the 'sources' folder.
215
+ *
216
+ * @returns bool
217
+ */
218
+
219
+ // Check every path, tracking which exist and which are missing
220
+ let existing_paths = [];
221
+ let missing_paths = [];
222
+ for ( let path of paths ) {
223
+ let dirs = fg.sync(
224
+ [ path ],
225
+ { onlyFiles: false, suppressErrors: true }
226
+ );
227
+
228
+ if ( dirs.length > 0 ) {
229
+ existing_paths.push( ...dirs );
230
+ } else {
231
+ missing_paths.push( path );
232
+ }
233
+ }
234
+
235
+ // Warn about missing paths
236
+ if ( missing_paths.length > 0 ) {
237
+ console.warn(
238
+ `ALKiln WARNING: ALKiln can't find folders at "${ paths.join(", ") }".`
239
+ );
240
+ }
241
+
242
+ // If no paths were valid
243
+ if ( existing_paths.length <= 0 ) {
244
+ session_vars._sources_paths = null;
245
+ return false;
246
+ }
247
+
248
+ // Otherwise, store valid paths
249
+ session_vars._sources_paths = existing_paths;
250
+ return true;
251
+ };
252
+
253
+ session_vars.get_sources_paths = function () {
254
+ /** Get the "sources" folder path. Should never be undefined during
255
+ * a run of the test. run_cucumber.js should make sure it has
256
+ * a default value.
257
+ *
258
+ * @returns str || null */
259
+ return session_vars._sources_paths;
260
+ };
261
+
213
262
  session_vars.validateEnvironment = function() {
214
263
  /** Throw a useful error for the developer if any of the env vars
215
264
  * required by absolutely all tests are missing. Gather all