@suffolklitlab/alkiln 5.13.2 → 5.13.3-feat-random-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.
Files changed (51) hide show
  1. package/.github/workflows/github_server.yml +10 -8
  2. package/.github/workflows/playground.yml +5 -2
  3. package/.github/workflows/unit_tests.yml +1 -1
  4. package/CHANGELOG.md +32 -1
  5. package/_alkiln_meta/shift_logs.js +75 -0
  6. package/action.yml +89 -31
  7. package/action_for_github_server/action.yml +65 -29
  8. package/docs/4_environments.md +243 -0
  9. package/docs/artifacts_folder_name.md +135 -0
  10. package/docs/constrained_random_tests.md +254 -0
  11. package/docs/decisions/006_which_log_level_to_pick_2024_09_5.md +70 -0
  12. package/docs/decisions/007_getting_and_storing_logs.md +179 -0
  13. package/docs/decisions/move_runtime_config_to_sources_folder.md +5 -0
  14. package/docs/decisions/reports_and_logs_2024_08_11.md +161 -0
  15. package/docs/kinds_of_errors.md +26 -0
  16. package/docs/prioritize_constrained_random_tests.md +60 -0
  17. package/lib/docassemble/docassemble_api_interface.js +77 -51
  18. package/lib/docassemble/server_install.js +32 -9
  19. package/lib/docassemble/setup.js +40 -25
  20. package/lib/docassemble/takedown.js +33 -10
  21. package/lib/globals.js +10 -0
  22. package/lib/run_cucumber.js +141 -69
  23. package/lib/scope.js +101 -77
  24. package/lib/setup/TestGenerator.js +1755 -0
  25. package/lib/setup/artifacts.js +20 -0
  26. package/lib/setup/generate.js +42 -0
  27. package/lib/setup/get_Gherkin_AST.js +26 -0
  28. package/lib/setup/validate.js +89 -0
  29. package/lib/steps.js +132 -54
  30. package/lib/utils/Log.js +1145 -0
  31. package/lib/utils/_alkiln-constrained_random_tests_old.js +1776 -0
  32. package/lib/utils/files.js +18 -11
  33. package/lib/utils/get_error_stacks.js +16 -0
  34. package/lib/utils/reports.js +100 -34
  35. package/lib/utils/session_vars.js +107 -73
  36. package/lib/utils/set_sources_paths.js +98 -0
  37. package/lib/utils/time.js +44 -0
  38. package/package.json +18 -7
  39. package/tests/log_codes/check_codes.sh +18 -13
  40. package/tests/log_codes/log_code_expected_instances.txt +91 -9
  41. package/tests/unit_tests/constrained_random_answers/invalid.fixtures.js +177 -0
  42. package/tests/unit_tests/constrained_random_answers/invalid.test.js +251 -0
  43. package/tests/unit_tests/constrained_random_answers/valid.fixtures.js +936 -0
  44. package/tests/unit_tests/constrained_random_answers/valid.test.js +541 -0
  45. package/tests/unit_tests/example_of_chai_test_lifecycle.js +78 -0
  46. package/tests/unit_tests/getAllFields.test.js +5 -0
  47. package/tests/unit_tests/getMatchingRows.test.js +5 -0
  48. package/tests/unit_tests/log.test.js +557 -0
  49. package/tests/unit_tests/normalizeTable.test.js +5 -0
  50. package/lib/utils/log.js +0 -96
  51. /package/docs/{expected_inernal_results.md → expected_internal_results.md} +0 -0
@@ -0,0 +1,1755 @@
1
+ const fs = require('fs');
2
+ const fg = require(`fast-glob`);
3
+
4
+ const globals = require(`../globals`);
5
+ const session_vars = require('../utils/session_vars');
6
+ const get_Gherkin_AST = require(`./get_Gherkin_AST`);
7
+ const get_error_stacks = require(`../utils/get_error_stacks`);
8
+
9
+ /**
10
+ * TODO: We might give various levels of categories for warnings. For example,
11
+ * severe warnings (like "will skip the random test") vs. slight warnings
12
+ * (like "missing number in constraints random test")
13
+ * TODO: Post-MVP, don't repeat tests that the author has already created
14
+ * TODO: Post-MVP, write Steps that warn about fore-Steps and constraints Steps
15
+ * when they appear in a regular test. It probably means someone named the
16
+ * file incorrectly.
17
+ * Discuss: Require author to use "Generator Scenario:" as they keyword (Also
18
+ * "Generator Scenario Outline:") (or "Scenario Generator", maybe harder to
19
+ * scan) for 2 reasons. 1. Gets the vocab of "generator" in early (though
20
+ * that doesn't match "constraints" Step, which is a whole other language
21
+ * problem we haven't solved yet.) 2. Saying "MAX_RANDOM_TESTS_PER_SCENARIO"
22
+ * is confusing when "Scenario" means different things - a generator
23
+ * Scenario and a regular Scenario.
24
+ * Discuss: Now that Log is less fragile and duplicated, shall we create a new
25
+ * Log instance in here?
26
+ * Discuss: consider debug types that are for authors. Sort of like warnings,
27
+ * but less loud.
28
+ * Discuss: Log.error that just returns a string formatted like an error with
29
+ * the right icon, etc, without logging it to the console. We can _sort_ of
30
+ * do that now, but without the icon.
31
+ * Discuss: If/when we allow multiple Scenarios, research how we find the end
32
+ * of a Scenario that ends in an `Example`.
33
+ * */
34
+
35
+ let log = null;
36
+ const FOLDER_NAME = globals.generated_files_folder_name;
37
+
38
+ module.exports = class TestGenerator {
39
+ constructor() {
40
+ this.all_Scenarios_bodies_map = {}; // map for O^1 adding operation
41
+ }
42
+
43
+ create({ sources_path, logger }) {
44
+ /** Given files with a certain prefix, use the text in them to generate a
45
+ * feature file or files. A file generates one file with multiple
46
+ * Scenarios.
47
+ *
48
+ * @param {object} obj - Named arguments
49
+ * @param {string} obj.sources_path - Path to the docassemble Sources folder
50
+ *
51
+ * @returns {object} rtn - Named return values
52
+ * @returns {[str]} rtn.new_strings - 0 or more strings that each created a
53
+ * new file.
54
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
55
+ *
56
+ * Details:
57
+ * - Copy sections of text from the old file
58
+ * - Use the Gherkin parser's AST of the constraints Step in each Scenario
59
+ * to generate Story Tables
60
+ * - Combine various sections with the Story Tables
61
+ * - Make a new file with that content
62
+ *
63
+ * We're trying to find the least complex, yet sufficiently robust, system
64
+ *
65
+ * Goal:
66
+ * Stay as true to the author's syntax as possible so they can get useful
67
+ * errors from cucumber itself.
68
+ *
69
+ * Anti-goals:
70
+ * - Be cucumber
71
+ * - Render the whole AST ourselves
72
+ *
73
+ * For examples, see tests.
74
+ *
75
+ * */
76
+ log = logger;
77
+
78
+ let new_files_strings = [];
79
+ let all_errors = [];
80
+
81
+ // Get the paths of the source files that we need to parse
82
+ let generator_paths = fg.sync(`${ sources_path }/${ globals.generators_prefix }*.feature`);
83
+ for ( let generator_path of generator_paths ) {
84
+
85
+ let file_text = fs.readFileSync( generator_path, {encoding: `utf8`});
86
+
87
+ log.info({ code: `ALK0231`, context: `generating files`, },
88
+ `ALKiln is trying to generate the constrained random answers tests for "${ generator_path }"`
89
+ );
90
+
91
+ let { new_contents, errors: parse_errors } = this.parse_file({
92
+ file_text,
93
+ generator_path
94
+ });
95
+ all_errors.push( ...parse_errors );
96
+
97
+ if ( new_contents ) {
98
+ new_files_strings.push( new_contents );
99
+ }
100
+ }
101
+
102
+ log.debug({ code: `ALK0267`, context: `generating files`, }, `Constrained random answers tests:`, new_files_strings );
103
+
104
+ let tests_path = `${ sources_path }/${ FOLDER_NAME }`;
105
+ let artifacts_path = `${ session_vars.get_artifacts_path_name() }/${ FOLDER_NAME }`;
106
+ save_files({ tests_path, artifacts_path, file_strings: new_files_strings });
107
+
108
+ log.info({ code: `ALK0271`, context: `generating files`, }, `Tried to save generated files to ${ artifacts_path }` );
109
+
110
+ return { new_strings: new_files_strings, errors: all_errors };
111
+ }
112
+
113
+ parse_file({ file_text, generator_path, logger }) {
114
+ /** Parse and return one file's contents
115
+ *
116
+ * Discuss: If there's a Gherkin parser error, log something more clear
117
+ * before re-throwing it?
118
+ *
119
+ * Note: The generator file should be valid by the time it gets here.
120
+ *
121
+ * @param {string} file_text - Full text of a generator feature file
122
+ *
123
+ * @returns {object} rtn - Named return values
124
+ * @returns {null|str} rtn.new_contents - `null` or string for the new file
125
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
126
+ * */
127
+ // Unit tests call the parser alone, so they need to create their own logger.
128
+ if ( logger ) {
129
+ log = logger;
130
+ }
131
+
132
+ let errors = [];
133
+
134
+ let { doc_AST, errors: generator_Gherkin_errors } = get_generator_AST({ file_text, generator_path });
135
+ if ( generator_Gherkin_errors.length > 0 ) {
136
+ return { new_contents: null, errors: generator_Gherkin_errors };
137
+ }
138
+
139
+ let { new_contents, errors: generating_errors } = this.get_new_file_contents({ doc_AST, file_text, generator_path });
140
+ if ( generating_errors.length > 0 ) {
141
+ return { new_contents: null, errors: generating_errors };
142
+ }
143
+
144
+ let { errors: generated_parsing_errors } = validate_syntax({ file_text: new_contents, generator_path });
145
+ if ( generated_parsing_errors.length > 0 ) {
146
+ let syntax_error = log.error({ code: `ALK0266`, context: `generating files`, before: `\n\n`, },
147
+ `The .feature file ALKiln generated from "${ generator_path }" had Gherkin syntax error(s) ALKiln did not anticipate. If you have a moment, we would love you to send us your debug.txt.\n`,
148
+ ...get_error_stacks({ errors: generated_parsing_errors })
149
+ );
150
+ return { new_contents, errors: [new Error( syntax_error )] };
151
+ }
152
+
153
+ return { new_contents, errors };
154
+ }
155
+
156
+
157
+ get_new_file_contents({ doc_AST={}, file_text=``, generator_path }) {
158
+ /** Return the text for one new file with tests based on one generator file.
159
+ *
160
+ * Note: Right now it is out of scope to save comments in constraints Steps.
161
+ *
162
+ * @param {object} obj - Named arguments
163
+ * @param {object} obj.doc_AST - Gherkin-parsed abstract syntax tree of
164
+ * generator file
165
+ * @param {string} obj.file_text - String of the whole generator file
166
+ * @param {string} obj.generator_path - Path to the generator file
167
+ *
168
+ * @returns {null|string} - The text (Feature, Scenarios, etc) for the new
169
+ * file
170
+ *
171
+ * @returns {object} rtn - Named return values
172
+ * @returns {null|str} rtn.new_contents - `null` or string for new file
173
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
174
+ * */
175
+
176
+ // `doc_AST.feature.children` seem to always be defined by here
177
+ let scenario_objs = doc_AST.feature.children;
178
+ if ( !scenario_objs[0] ) {
179
+ let syntax_error = log.error({ code: `ALK0235`, context: `generating files`, before: `\n\n`, },
180
+ `The author's generator .feature file at "${ generator_path }" may be missing its Scenarios.`
181
+ );
182
+ return { new_contents: null, errors: [new Error( syntax_error )]};
183
+ }
184
+ // `scenario_objs[0].scenario` seems to always be defined by here
185
+
186
+ if ( scenario_objs.length > 1 ) {
187
+ let too_many_scenarios = log.error({ code: `ALK0236`, context: `generating files`, },
188
+ `A generator file can only have 1 generator Scenario. This one has ${ scenario_objs.length }.`
189
+ );
190
+ return { new_contents: ``, errors: [new Error( too_many_scenarios )] };
191
+ }
192
+
193
+ let generation_errors = [];
194
+
195
+ let generator_lines = file_text.split(`\n`);
196
+
197
+ let feature_text = get_Feature_text({ feature: doc_AST.feature, generator_lines });
198
+
199
+ let { scenarios_strings, errors: scenarios_errors } = this.get_new_Scenarios_strs({
200
+ scenario_objs,
201
+ generator_lines
202
+ });
203
+ generation_errors.push( ...scenarios_errors );
204
+
205
+
206
+ let count_msg = `ALKiln generated ${ scenarios_strings.length } Scenario or Scenarios`;
207
+
208
+ if ( scenarios_strings.length === 0 ) {
209
+ let error = log.error({ code: `ALK0263`, context: `generating files`, }, count_msg );
210
+ generation_errors.push( new Error( error ));
211
+ return { new_contents: null, errors: generation_errors };
212
+ }
213
+
214
+ log.info({ code: `ALK0264`, context: `generating files`, }, count_msg );
215
+
216
+ let scenarios_combined = scenarios_strings.join(`\n\n`);
217
+ let new_file_contents = [ feature_text, scenarios_combined ].join(`\n`);
218
+
219
+ if ( !new_file_contents ) {
220
+ // Fallback. Haven't been able to trigger this
221
+ let msg = `Unable to generate a constrained random answers file from "${ generator_path }". See above for warnings and errors.`;
222
+ let error = log.error({ code: `ALK0265`, context: `generating files`, }, msg );
223
+ return { new_contents: null, errors: [ new Error( error )] };
224
+ }
225
+
226
+ return { new_contents: new_file_contents, errors: generation_errors };
227
+ }
228
+
229
+
230
+ get_new_Scenarios_strs({ scenario_objs, generator_lines }) {
231
+ /** Return all the strings of the generated Scenarios and any Errors we ran
232
+ * into.
233
+ *
234
+ * @param {object} obj - Named arguments
235
+ * @param {object} obj.scenario_objs - 0 or more Gherkin Scenario objects
236
+ * (which are generators)
237
+ * @param {[str]} obj.generator_lines - List of lines in the generator file
238
+ *
239
+ * Starting here, functions' return values should now include lists of
240
+ * warning messages. If a Scenario gets any warnings, we will add a
241
+ * "warnings" Step to that Scenario or to the file. Implement over time.
242
+ *
243
+ * We only allow one Scenario per file now, but in future we can allow more.
244
+ *
245
+ * @returns {object} rtn - Named return values
246
+ * @returns {[str]} rtn.scenarios - 0 or more Scenario strings
247
+ * @returns {[str]} rtn.warnings - 0 or more warning messages
248
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
249
+ * */
250
+ let errs_n_warns = new Misbehaviors();
251
+
252
+ let strs_for_all_new_Scenarios = [];
253
+ // Soon we want to be able to handle multiple generator Scenarios per file
254
+ for ( let scenario_obj_i = 0; scenario_obj_i < scenario_objs.length; scenario_obj_i++ ) {
255
+
256
+ errs_n_warns.reset_warnings();
257
+ /** Start returning warnings here. */
258
+ let generator_Scenario = scenario_objs[ scenario_obj_i ].scenario;
259
+
260
+ let description = scenario_description_for_logs({ scenario: generator_Scenario });
261
+ log.info({ code: `ALK0237`, context: `generating files`, },
262
+ `Generating files for Scenario at ${ description }`
263
+ );
264
+
265
+ let {
266
+ num_to_make,
267
+ file_lines_to_delete
268
+ } = errs_n_warns.add(
269
+ get_fore_Steps({ scenario: generator_Scenario })
270
+ );
271
+ if ( errs_n_warns.errors.length > 0 ) { continue; }
272
+
273
+ let { new_Scenarios_text } = errs_n_warns.add(
274
+ this.get_1_text_block_of_all_new_Scenarios_from_1_generator_Scenario({
275
+ scenario: generator_Scenario,
276
+ generator_lines: generator_lines,
277
+ file_lines_to_delete,
278
+ num_to_make
279
+ })
280
+ );
281
+ if ( errs_n_warns.errors.length > 0 ) { continue; }
282
+
283
+ if ( errs_n_warns.warnings.length > 0 ) {
284
+ new_Scenarios_text = add_warnings_Scenario({
285
+ scenarios_str: new_Scenarios_text, warnings: errs_n_warns.warnings
286
+ });
287
+ }
288
+ log.debug({ code: `ALK0262`, context: `generating files`, }, `new_Scenarios_text with ${ errs_n_warns.warnings.length } warnings:\n`, new_Scenarios_text );
289
+
290
+ strs_for_all_new_Scenarios.push( new_Scenarios_text );
291
+ } // ends for every scenario_container
292
+
293
+ return { scenarios_strings: strs_for_all_new_Scenarios, ...errs_n_warns };
294
+ }
295
+
296
+
297
+ get_1_text_block_of_all_new_Scenarios_from_1_generator_Scenario({
298
+ scenario,
299
+ generator_lines,
300
+ file_lines_to_delete,
301
+ num_to_make,
302
+ }) {
303
+ /** Return a list of 0 or more strings of tests generated from 1 generator
304
+ * Scenario Gherkin object.
305
+ *
306
+ * @param {object} obj - Named arguments
307
+ * @param {object} obj.scenario - Gherkin Scenario object
308
+ * @param {[str]} obj.generator_lines - Lines in the generator file
309
+ * @param {object} obj.file_lines_to_delete - Lines to avoid adding to the
310
+ * new file
311
+ * @param {object} obj.num_to_make - Number of tests to make
312
+ *
313
+ * @returns {object} rtn - Named return values
314
+ * @returns {string} rtn.new_Scenarios_text - Single string containing all
315
+ * the newly generated Scenarios
316
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
317
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
318
+ * */
319
+ let misfits = new Misbehaviors();
320
+
321
+ let indexes_to_delete = file_lines_to_delete;
322
+
323
+ let new_test_bodies = [];
324
+ let actual_test_num = 1;
325
+ for ( let test_num_attempt = 1; test_num_attempt <= num_to_make; test_num_attempt++ ) {
326
+ misfits.reset_warnings();
327
+
328
+ // Example of unique text: "(1 of 2)"
329
+ let { unqiue_Scenario_lines } = misfits.add(
330
+ get_definitely_unqiue_Scenario_lines({
331
+ scenario,
332
+ generator_lines,
333
+ test_num: actual_test_num,
334
+ indexes_to_delete
335
+ })
336
+ );
337
+
338
+ if ( scenario.steps.length === 0 ) { break; } // Discuss: Can this happen here?
339
+
340
+ let { middle_lines, final_significant_Step_index } = misfits.add(
341
+ this.try_to_get_one_unique_middle({
342
+ scenario, generator_lines, indexes_to_delete,
343
+ })
344
+ );
345
+ if ( misfits.errors.length > 0 ) { break; }
346
+ if ( middle_lines.length === 0 ) { continue; }
347
+
348
+ let { final_lines } = misfits.add(
349
+ get_final_lines({
350
+ generator_lines, final_significant_Step_index, indexes_to_delete
351
+ })
352
+ );
353
+ if ( misfits.errors.length > 0 ) { break; }
354
+
355
+ new_test_bodies.push( put_together_new_test_text({
356
+ starting_lines: unqiue_Scenario_lines,
357
+ middle_lines,
358
+ final_lines,
359
+ warnings: misfits.warnings
360
+ }) );
361
+
362
+ actual_test_num++;
363
+ // Discuss: Break if a test was skipped
364
+
365
+ } // End for desired num_tests
366
+
367
+ if ( misfits.errors.length > 0 ) {
368
+ return { new_Scenarios_text: ``, ...misfits };
369
+ }
370
+
371
+ let got_fewer_tests_than_desired = new_test_bodies.length < num_to_make;
372
+ if ( got_fewer_tests_than_desired ) {
373
+ let warn_too_few = log.warn({ code: `ALK0260`, context: `generating files generator-scenario-level`, },
374
+ `ALKiln could only make ${ new_test_bodies.length } *unique* tests out of the ${ num_to_make } the author wanted for this Scenario. Note that ALKiln compares all generated Scenarios with each other to make sure all are unique. This Scenario may be a duplicate of a Scenario generated by a previous generator. The Scenario:\n ${ scenario_description_for_logs({ scenario }) }`,
375
+ )
376
+ // Discuss: Should we add more structure to Misbehaviors to give them
377
+ // context levels (Feature, generator Scenario, generated Scenario)
378
+ misfits.add({ warnings: [ warn_too_few ] });
379
+ }
380
+
381
+ let new_Scenarios_text = new_test_bodies.join(`\n\n`);
382
+ new_Scenarios_text = add_total_tests({
383
+ scenario_group_str: new_Scenarios_text, num_tests: new_test_bodies.length
384
+ });
385
+
386
+ log.debug({ code: `ALK0261`, context: `generating files`, }, `new_Scenarios_text before adding warnings:\n`, new_Scenarios_text );
387
+
388
+ return { new_Scenarios_text, ...misfits };
389
+ }
390
+
391
+ try_to_get_one_unique_middle({
392
+ scenario,
393
+ generator_lines,
394
+ indexes_to_delete
395
+ }) {
396
+ /** Return data for new lines that should be a unique new test body.
397
+ *
398
+ * @param {object} obj - Named arguments
399
+ * @param {object} obj.scenario - Gherkin Scenario object
400
+ * @param {[str]} obj.generator_lines - Lines in the generator file
401
+ * @param {[int]} obj.indexes_to_delete - Lines to avoid adding to the new
402
+ * file
403
+ *
404
+ * @returns {object} rtn - Named return values
405
+ * @returns {[str]} rtn.middle_lines - 0 or more strings for the new test
406
+ * @returns {int} rtn.final_significant_Step_index - Generator file index
407
+ * for the final found significant Step
408
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
409
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
410
+ * */
411
+ let misfits = new Misbehaviors();
412
+
413
+ log.debug({ code: `ALK0249`, context: `generating files`, }, `Trying to get unique middle` );
414
+
415
+ let keyword_index = get_keyword_index({ scenario });
416
+
417
+ // Try to make one unique test
418
+ // Accumulators and state trackers we'll use later
419
+ let attempt_lines = [];
420
+ let prev_significant_Step_index = keyword_index;
421
+ function clear_inner_state() {
422
+ /** Reset scope variables to build new unique test or clear out values */
423
+ attempt_lines = [];
424
+ prev_significant_Step_index = keyword_index;
425
+ };
426
+
427
+ // Loop management
428
+ let attempts = { count: 0, max: 10 };
429
+ let is_unique = false;
430
+ while ( !is_unique && attempts.count < attempts.max ) {
431
+
432
+ attempts.count++;
433
+ misfits.reset_warnings();
434
+
435
+ ({ attempt_lines, prev_significant_Step_index } = misfits.add(
436
+ get_possibly_unique_lines_data({
437
+ scenario,
438
+ generator_lines,
439
+ indexes_to_delete
440
+ })
441
+ ));
442
+
443
+ if ( misfits.errors.length > 0 ) {
444
+ clear_inner_state(); // Clear data before leaving loop
445
+ break;
446
+ }
447
+
448
+ let one_test_body = attempt_lines.join(`\n`);
449
+ log.debug({ code: `ALK0257`, context: `generating files` }, `Generated test:\n${ one_test_body }` );
450
+
451
+ let is_duplicate = this.all_Scenarios_bodies_map[ one_test_body ];
452
+ if ( is_duplicate ) {
453
+ log.debug({ code: `ALK0258`, context: `generating files` }, `Test is a duplicate on attempt ${ attempts.count }.` );
454
+ if ( attempts.count >= attempts.max ) { clear_inner_state(); }
455
+ continue;
456
+ }
457
+
458
+ // Otherwise, add to the global map and finish
459
+ log.debug({ code: `ALK0259`, context: `generating files` }, `This test is unique on attempt number ${ attempts.count }.` );
460
+ is_unique = true;
461
+ this.all_Scenarios_bodies_map[ one_test_body ] = true;
462
+
463
+ } // ends while generated scenario is not unique or pass max attempts
464
+
465
+ return {
466
+ middle_lines: attempt_lines,
467
+ final_significant_Step_index: prev_significant_Step_index,
468
+ ...misfits
469
+ };
470
+ };
471
+
472
+ }; // Ends TestGenerator {}
473
+
474
+
475
+ function get_generator_AST({ file_text, generator_path }) {
476
+ /** Return any errors created when Gherkin tries to parse the generator file.
477
+ * If there are any errors, throw a list of all the errors.
478
+ *
479
+ * @param {object} obj - Named arguments
480
+ * @param {string} obj.file_text - Text of the Gherkin file
481
+ * @param {string} obj.generator_path - Path to the generator file
482
+ *
483
+ * @returns {object} rtn - Named return values
484
+ * @returns {object} rtn.doc_AST - Gherkin AST of a .feature file
485
+ * */
486
+ let { AST: doc_AST, errors: generator_errors } = get_Gherkin_AST({ file_text });
487
+
488
+ if ( generator_errors.length <= 0 && doc_AST.feature ) {
489
+ return { doc_AST, errors: [] };
490
+ }
491
+
492
+ /** Error handling */
493
+
494
+ if ( generator_errors.length > 0 ) {
495
+ let syntax_error = log.error({ code: `ALK0232`, context: `generating files`, before: `\n\n`, },
496
+ `The author's generator .feature file at "${ generator_path }" has Gherkin syntax error(s). You might see more warnings above or below this one.\n`,
497
+ ...get_error_stacks({ errors: generator_errors })
498
+ );
499
+ return { doc_AST: null, errors: [new Error( syntax_error )] };
500
+ }
501
+
502
+ // We have never been able to trigger `!doc_AST`
503
+
504
+ if ( !doc_AST.feature ) {
505
+ let feature_error = log.error({ code: `ALK0233`, context: `generating files`, before: `\n\n`, },
506
+ `The author's generator .feature file at "${ generator_path }" needs to include the \`Feature\` keyword.`
507
+ );
508
+ return { doc_AST: null, errors: [new Error( feature_error )] };
509
+ }
510
+
511
+ // Should not get here
512
+ let unknown_error = log.error({ code: `ALK0234`, context: `generating files`, before: `\n\n`, },
513
+ `The author's generator .feature file at "${ generator_path }" ran into an unanticipated error when ALKiln tried to parse it. If you have a moment, we would love for you to send us your debug.txt.`
514
+ );
515
+ return { doc_AST, errors: [new Error( unknown_error )] };
516
+ }
517
+
518
+
519
+ function get_Feature_text({ feature, generator_lines }) {
520
+ /** Return text of Feature section to put at the top of the new file
521
+ *
522
+ * @param {object} obj - Named arguments
523
+ * @param {object} obj.feature - Gherkin AST `feature` object
524
+ * @param {[string]} obj.generator_lines - Text of the generator file as a
525
+ * list
526
+ *
527
+ * @returns {string} - Text of the new file's Feature section
528
+ * */
529
+ add_Feature_randomization_indicators({ generator_lines, feature });
530
+
531
+ let first_scenario = feature.children[0].scenario;
532
+ let first_Scenario_line = get_Scenario_start_line_num({ scenario: first_scenario });
533
+ let first_Scenario_file_index = as_index( first_Scenario_line );
534
+ let index_of_last_line = first_Scenario_file_index - 1;
535
+
536
+ let feature_text = generator_lines.slice(
537
+ 0, index_of_last_line + 1 // `slice` excludes the last index
538
+ ).join(`\n`);
539
+
540
+ return feature_text;
541
+ }
542
+
543
+
544
+ function add_Feature_randomization_indicators({ feature={}, generator_lines=`` }) {
545
+ /** Mutate `generator_lines`, modify the tag and name of the Feature section.
546
+ *
547
+ * @param {object} obj - Named arguments
548
+ * @param {object} obj.feature - Gherkin AST `feature` object
549
+ * @param {[string]} obj.generator_lines - Text of the generator file as a
550
+ * list
551
+ *
552
+ * @returns {undefined}
553
+ * */
554
+ let feature_index = as_index( feature.location.line );
555
+
556
+ let tag_indicator = `@alkiln @generated @randomized @constraints`;
557
+ let name_indicator = `(generated by ALKiln)`;
558
+ let new_f_name_line = ``;
559
+
560
+ if ( feature.tags.length > 0 ) {
561
+ // Modify existing tags
562
+ let tags_index = as_index( feature.tags[0].location.line );
563
+ let tags_starter = generator_lines[ tags_index ];
564
+
565
+ generator_lines[ tags_index ] = `${ tags_starter } ${ tag_indicator }`;
566
+ new_f_name_line = generator_lines[ feature_index ];
567
+
568
+ } else {
569
+ // If no existing tags, add new tag line
570
+ new_f_name_line = `${ tag_indicator }\n${ generator_lines[ feature_index ] }`;
571
+ }
572
+
573
+ generator_lines[ feature_index ] = `${ new_f_name_line } ${ name_indicator }`;
574
+ }
575
+
576
+
577
+ function get_Scenario_start_line_num({ scenario }) {
578
+ /** Returns the Gherkin line number where the Scenario really starts. That
579
+ * could be where the tags are or where the Scenario keyword is.
580
+ *
581
+ * @param {object} obj - Named arguments
582
+ * @param {object} obj.scenario - The Gherkin AST for a Scenario
583
+ *
584
+ * @returns {int} - Gherkin line number where the Scenario starts
585
+ * */
586
+ if ( scenario.tags.length > 0 ) {
587
+ return scenario.tags[0].location.line;
588
+ } else {
589
+ return scenario.location.line;
590
+ }
591
+ }
592
+
593
+
594
+ function get_fore_Steps({ scenario }) {
595
+ /** Get Steps that must come in the first section of any kind of Scenario that
596
+ * ALKiln manipulates before running the cucumber tests. Does this really
597
+ * need to be abstracted for possible future "first Step"s already?
598
+ *
599
+ * Discuss: If a "first" Step appears multiple times, should that be an error
600
+ * or a warning?
601
+ *
602
+ * This code may be premature. We currently only have one type of fore-Step,
603
+ * though the author is allowed to make multiple of that kind without
604
+ * causing an error.
605
+ *
606
+ * @param {object} obj - Named arguments
607
+ * @param {object} obj.scenario - Gherkin AST `scenario` object
608
+ *
609
+ * @returns {object} rtn - Named return values
610
+ * @returns {int} rtn.num_to_make - Number of tests to generate
611
+ * @returns {[int]} rtn.file_lines_to_delete - Which lines in the file to
612
+ * exclude when creating the new Scenario
613
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
614
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
615
+ * */
616
+ let misfits = new Misbehaviors();
617
+ let steps = scenario.steps;
618
+
619
+ let first_vals = {
620
+ num_to_make: null,
621
+ };
622
+
623
+ let last_valid_fore_step_index = -1;
624
+ let file_lines_to_delete = [];
625
+
626
+ for ( let step_i = 0; step_i < steps.length; step_i++ ) {
627
+ let step = steps[ step_i ];
628
+
629
+ let all_results = [];
630
+
631
+ /** Number of tests to generate */
632
+ let { num_to_make } = misfits.add(
633
+ get_number_of_Scenarios_to_generate2({
634
+ step,
635
+ prev_num_to_make: first_vals.num_to_make
636
+ })
637
+ );
638
+ first_vals.num_to_make = num_to_make || first_vals.num_to_make;
639
+ if ( num_to_make !== null ) {
640
+ file_lines_to_delete.push(as_index( step.location.line ));
641
+ }
642
+
643
+ /** Other possible fore Steps */
644
+ // TBS
645
+
646
+ /** Possibly the end */
647
+ // If none of the "first Step" values were satisfied, stop here
648
+ if ( [ num_to_make ].every(function ( item ) { return item === null; }) ) {
649
+ break;
650
+ }
651
+
652
+ last_valid_fore_step_index = step_i;
653
+ }
654
+
655
+ let there_were_no_fore_Steps = last_valid_fore_step_index === -1;
656
+ let description = scenario_description_for_logs({ scenario });
657
+ if ( there_were_no_fore_Steps ) {
658
+ misfits.add({ errors: [
659
+ new Error( log.error({ code: `ALK0242`, context: `generating files`, },
660
+ `A generator Scenario's first Step must say how many constrained random answers tests to make. The generator Scenario on line ${ scenario.location.line } is missing that Step. The Scenario's description is:\n${ description }`
661
+ ) )
662
+ ] });
663
+ } else {
664
+ log.debug({ code: `ALK0243`, context: `generating files` },
665
+ `The final "first Step" Gherkin index was ${ last_valid_fore_step_index }. The final file index of that Step was ${ as_index(steps[ last_valid_fore_step_index ].location.line) }. The Scenario description:\n${ description }`
666
+ );
667
+ }
668
+
669
+ if ( first_vals.num_to_make ) {
670
+ log.debug({ code: `ALK0244`, context: `generating files` },
671
+ `Will try to make ${ first_vals.num_to_make } tests.`
672
+ );
673
+ }
674
+
675
+ return {
676
+ num_to_make: first_vals.num_to_make,
677
+ file_lines_to_delete,
678
+ ...misfits
679
+ };
680
+ }
681
+
682
+
683
+ const ALKILN_REGEX = /ALKiln/i;
684
+ const CONSTRAIN_REGEX = /constrain/i; // constrained/constraint/constraints
685
+ const RAND_REGEX = /rand/i; // random/randomness/randomized
686
+ const GENERAT_REGEX = /generat|create/i; // generate/generates/generated/generating/create/creates
687
+ const MAKE_REGEX = /make|made/i; // make/makes/made/making
688
+ const CAPTURE_NUMBERS_REGEX = /(\d+)/g;
689
+ function get_number_of_Scenarios_to_generate2({ step, prev_num_to_make }) {
690
+ /** Return null or a constraints Step.
691
+ *
692
+ * @param {object} obj - Named arguments
693
+ * @param {object} obj.step - A valid constraints Step Gherkin object
694
+ * @param {int|undefined} obj.prev_num_to_make - A previously found number
695
+ * if there was one, or `undefined`
696
+ *
697
+ * @returns {null|object} `null` or a constraints Step
698
+ * */
699
+ // Given ALKiln should make 10 constrained random tests
700
+ let warnings = [];
701
+ let errors = [];
702
+ let text = step.text;
703
+ let user_num = null;
704
+
705
+ // Use `.test()` without `g` flag. See
706
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test#using_test_on_a_regex_with_the_global_flag
707
+ let is_num_Step = ALKILN_REGEX.test( text )
708
+ && CONSTRAIN_REGEX.test( text )
709
+ && ( RAND_REGEX.test( text )
710
+ || GENERAT_REGEX.test( text )
711
+ || MAKE_REGEX.test( text )
712
+ );
713
+
714
+ /** Not a numbers Step, which errors elsewhere if needed */
715
+ if ( !is_num_Step ) {
716
+ return { num_to_make: null, warnings, errors };
717
+ }
718
+
719
+ /** Conflicts with previous number Step */
720
+ if ( prev_num_to_make ) {
721
+ // Discuss: If there are multiple number Steps, the author ends up seeing
722
+ // multiple messages about what ALKiln will use to set the number and
723
+ // also possibly repeat this message. Can we do something about that in
724
+ // a way that is also maintainable?
725
+ warnings.push(
726
+ log.warn({ code: `ALK0238`, context: `generating files`, },
727
+ `A generator Scenario has too many Steps that set the number of tests to make. ALKiln will use the final number Step it finds or calculates. The current Step:\n ${ text }`
728
+ )
729
+ );
730
+ }
731
+
732
+ let number_matcher = text.match( CAPTURE_NUMBERS_REGEX );
733
+
734
+ /** Missing number */
735
+ if ( number_matcher === null ) {
736
+ warnings.push(
737
+ log.warn({ code: `ALK0239`, context: `generating files`, },
738
+ `A generator Scenario is missing the number of tests to make. ALKiln will now make 1 constrained random answers test. The current Step:\n ${ text }`
739
+ )
740
+ );
741
+ return { num_to_make: 1, warnings, errors };
742
+ }
743
+
744
+ /** Too many numbers */
745
+ if ( number_matcher && number_matcher.length > 1 ) {
746
+ // Some feedback indicated using the last number makes more sense to people
747
+ // than the first
748
+ user_num = parseInt( number_matcher[ number_matcher.length - 1 ]);
749
+ warnings.push(
750
+ log.warn({ code: `ALK0240`, context: `generating files`, },
751
+ `This constrained random answers Step is ambiguous because it has more than 1 number. ALKiln will now use the last number in the Step (${ user_num }). The Step:\n ${ text }`,
752
+ )
753
+ );
754
+ }
755
+
756
+ /** Happy path */
757
+ if ( number_matcher && !user_num ) {
758
+ user_num = parseInt( number_matcher[ 0 ]);
759
+ }
760
+
761
+ let num_to_make = Math.min(
762
+ session_vars.get_max_tests_per_generator_scenario(),
763
+ user_num
764
+ );
765
+
766
+ if ( num_to_make !== user_num ) {
767
+ warnings.push(
768
+ log.warn({ code: `ALK0241`, context: `generating files`, },
769
+ `The author asked to make ${ user_num } tests, but maximum allowed is ${ num_to_make }. ALKiln will now make ${ num_to_make } tests. If you want to increase the maximum, use the MAX_TESTS_PER_GENERATOR_SCENARIO environment variable. Read more about environment variables at https://assemblyline.suffolklitlab.org/docs/alkiln/writing/#env-vars.`
770
+ )
771
+ );
772
+ }
773
+
774
+ return { num_to_make, warnings, errors };
775
+ }
776
+
777
+
778
+ function get_definitely_unqiue_Scenario_lines({
779
+ scenario,
780
+ generator_lines,
781
+ test_num,
782
+ indexes_to_delete
783
+ }) {
784
+ /** Return the lines in the Scenario that will definitely be unique - they
785
+ * have counters and such.
786
+ *
787
+ * Example of unique text: `(1 of 2)`
788
+ *
789
+ * @param {object} obj - Named arguments
790
+ * @param {Gherkin object} obj.scenario - Gherkin AST scenario object
791
+ * @param {[str]} obj.generator_lines - Full list of strings from the
792
+ * generator file.
793
+ * @param {int} obj.test_num - What number test this is out of all the tests
794
+ * we need to generate for this scenario
795
+ * @param {[int]} obj.indexes_to_delete - Lines to avoid adding to the new
796
+ * file
797
+ *
798
+ * @returns {object} rtn - Named return values
799
+ * @returns {[str]} rtn.unqiue_Scenario_lines - As the name says
800
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
801
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
802
+ * */
803
+ let misfits = new Misbehaviors();
804
+
805
+ let unqiue_Scenario_lines = [];
806
+
807
+ log.debug({ code: `ALK0245`, context: `generating files`, },
808
+ `Trying to get get_definitely_unqiue_Scenario_lines`
809
+ );
810
+
811
+ let tags_index = get_tag_index({ scenario });
812
+ unqiue_Scenario_lines.push( get_tag_text({
813
+ generator_lines, tags_index, test_num
814
+ }) );
815
+
816
+ let keyword_index = get_keyword_index({ scenario });
817
+ let keyword_text = get_keyword_text({
818
+ generator_lines, keyword_index, test_num
819
+ });
820
+
821
+ let tags_tweens = [];
822
+ if ( tags_index !== null ) {
823
+ let { tweens } = misfits.add(
824
+ get_tweens({
825
+ end_index_of_prev: tags_index,
826
+ start_index_of_next: keyword_index,
827
+ generator_lines: generator_lines,
828
+ indexes_to_delete
829
+ })
830
+ );
831
+ tags_tweens = tweens;
832
+ }
833
+
834
+ unqiue_Scenario_lines.push( ...tags_tweens, keyword_text );
835
+
836
+ log.debug({ code: `ALK0248`, context: `generating files`, },
837
+ `Unique lines:`, unqiue_Scenario_lines
838
+ );
839
+
840
+ return { unqiue_Scenario_lines, ...misfits };
841
+ };
842
+
843
+
844
+ function get_tag_index({ scenario }) {
845
+ /** Return the generator file index of the Scenario's tag line or null if the
846
+ * tag line is non-existent
847
+ *
848
+ * @param {object} obj - Named arguments
849
+ * @param {object} obj.scenario - Gherkin Scenario object
850
+ *
851
+ * @returns {null|int} - null or tag line index
852
+ * */
853
+ if ( scenario.tags.length > 0 ) {
854
+ return as_index( scenario.tags[0].location.line );
855
+ }
856
+ return null;
857
+ }
858
+
859
+
860
+ const TOTAL_TESTS_FOR_THIS_SCENARIO_PLACEHOLDER = `ALKILN_TOTAL_TESTS_PLACEHOLDER`;
861
+ const TOTAL_TESTS_REGEX = new RegExp( TOTAL_TESTS_FOR_THIS_SCENARIO_PLACEHOLDER, `g` );
862
+ const WARNING_TAG_PLACEHOLDER = `ALKILN_MAYBE_WARNING_TAG_PLACEHOLDER`;
863
+ function get_tag_text({ generator_lines=[], tags_index=null, test_num=1 }) {
864
+ /** Edit or create the tag line of the new Scenario to include test-specific
865
+ * information
866
+ *
867
+ * @param {object} obj - Named arguments
868
+ * @param {[str]} obj.generator_lines - Full list of strings from the
869
+ * generator file.
870
+ * @param {int} obj.tags_index - Index of the keyword line
871
+ * @param {int} obj.test_num - Which iteration of the test this is
872
+ *
873
+ * @returns {str} - New text for the tag line of the new Scenario
874
+ * */
875
+ let tags = [];
876
+ if ( tags_index !== null ) {
877
+ let tags_as_the_author_wrote_them = generator_lines[ tags_index ];
878
+ tags.push( tags_as_the_author_wrote_them );
879
+ }
880
+
881
+ tags.push([ `@scenario${ test_num }_of_${ TOTAL_TESTS_FOR_THIS_SCENARIO_PLACEHOLDER }${ WARNING_TAG_PLACEHOLDER }` ]);
882
+ return tags.join(` `);
883
+ }
884
+
885
+
886
+ // Note: `get_keyword_index()` is used repeatedly and thus is at the bottom
887
+
888
+
889
+ function get_keyword_text({ generator_lines=[], keyword_index=null, test_num=1 }) {
890
+ /** Edit the text in the keyword line to include test-specific information.
891
+ *
892
+ * @param {object} obj - Named arguments
893
+ * @param {[str]} obj.generator_lines - Full list of strings from the
894
+ * generator file.
895
+ * @param {int} obj.keyword_index - Index of the keyword line
896
+ * @param {int} obj.test_num - Which iteration of the test this is
897
+ *
898
+ * @returns {str} - New text for the keyword line
899
+ * */
900
+ let keyword_text = `${ generator_lines[ keyword_index ] } (${ test_num } of ${ TOTAL_TESTS_FOR_THIS_SCENARIO_PLACEHOLDER })`;
901
+ return keyword_text;
902
+ }
903
+
904
+
905
+ function get_possibly_unique_lines_data({
906
+ scenario,
907
+ generator_lines,
908
+ indexes_to_delete
909
+ }) {
910
+ /** Return a list of lines and other data for one randomized test.
911
+ *
912
+ * @param {object} obj - Named arguments
913
+ * @param {object} obj.scenario - Gherkin Scenario object
914
+ * @param {[str]} obj.generator_lines - Lines in the generator file
915
+ * @param {[int]} obj.indexes_to_delete - Lines to avoid adding to the new
916
+ * file
917
+ *
918
+ * @returns {object} rtn - Named return values
919
+ * @returns {[str]} rtn.attempt_lines - 0 or more possibly unique lines for
920
+ * the new test
921
+ * @returns {int} rtn.final_significant_Step_index - Generator file index for
922
+ * the final found significant Step
923
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
924
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
925
+ * */
926
+ let misfits = new Misbehaviors();
927
+
928
+ let attempt_lines = [];
929
+ let prev_significant_Step_index = get_keyword_index({ scenario });
930
+ let found_at_least_1_significant_step = false;
931
+ for ( let step of scenario.steps ) {
932
+
933
+ let is_significant = is_constraints_Step({ step });
934
+ if ( !is_significant ) {
935
+ continue;
936
+ }
937
+
938
+ found_at_least_1_significant_step = true;
939
+
940
+ // Get the lines since the last line we got (comments/blanks/etc)
941
+ let significant_Step_start_index = as_index( step.location.line );
942
+ let { tweens } = misfits.steal_misbehaviors(
943
+ get_tweens({
944
+ end_index_of_prev: prev_significant_Step_index,
945
+ start_index_of_next: significant_Step_start_index,
946
+ generator_lines,
947
+ indexes_to_delete
948
+ })
949
+ ); // If error, keep gathering errors
950
+
951
+ let { table: story_table_str } = misfits.add(
952
+ get_random_Story_Table({ step, generator_lines })
953
+ );
954
+
955
+ if ( misfits.errors.length > 0 ) { break; }
956
+ attempt_lines.push( ...tweens, story_table_str );
957
+
958
+ prev_significant_Step_index = get_final_file_index_of_constraints_or_fore_Step({ step });
959
+ } // Ends loop for every step
960
+
961
+ if ( !found_at_least_1_significant_step ) {
962
+ let error = log.error({ code: `ALK0256`, context: `generating files`, },
963
+ `ALKiln found 0 valid constraints Steps for the Scenario at line ${ scenario.location.line }: "${ scenario_description_for_logs({ scenario }) }". Check the Step docs at https://assemblyline.suffolklitlab.org/docs/alkiln/writing#constrained-random-answers for the correct language and syntax.`
964
+ );
965
+ misfits.add({ errors: [new Error( error )] });
966
+ }
967
+
968
+ return {
969
+ attempt_lines,
970
+ prev_significant_Step_index,
971
+ ...misfits
972
+ }
973
+ }
974
+
975
+
976
+ const GET_TO_REGEX = /get|gets|end|reach|stop/i;
977
+ const WITH_REGEX = /with|when/i;
978
+ const PICK_REGEX = /pick|choose/i;
979
+ function is_constraints_Step({ step }) {
980
+ /** Return `true` if the Step is a constraints Step, otherwise return `false`.
981
+ *
982
+ * @param {object} obj - Named arguments
983
+ * @param {Gherkin object} obj.step - A valid constraints Step Gherkin object
984
+ *
985
+ * @returns {bool} `true` if it's a constraints Step, otherwise false
986
+ * */
987
+ // Use `.test()` without `g` flag. See
988
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test#using_test_on_a_regex_with_the_global_flag
989
+ let matches_constraints_Step = ALKILN_REGEX.test( step.text )
990
+ && (
991
+ GET_TO_REGEX.test( step.text )
992
+ || PICK_REGEX.test( step.text )
993
+ );
994
+ return matches_constraints_Step;
995
+ }
996
+
997
+
998
+ function get_random_Story_Table({
999
+ step, generator_lines, indexes_to_delete
1000
+ }) {
1001
+ /** Return a randomized Story Table string.
1002
+ *
1003
+ * TODO: Abstract this further
1004
+ *
1005
+ * @param {object} obj - Named arguments
1006
+ * @param {string} obj.ids - The ids this Story Table can reach
1007
+ * @param {object} obj.step - Gherkin AST of a Step
1008
+ * @param {[str]} obj.generator_lines - List of lines from the generator file
1009
+ *
1010
+ * @returns {object} rtn - Named return values
1011
+ * @returns {string} rtn.table - String of the whole Story Table Step
1012
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
1013
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
1014
+ * */
1015
+ let misfits = new Misbehaviors();
1016
+ let story_table_parts = [];
1017
+
1018
+ let { ids } = misfits.add(
1019
+ get_final_ids({ step_text: step.text })
1020
+ );
1021
+ if ( misfits.errors.length > 0 ) { return { table: null, ...misfits }; }
1022
+
1023
+ if ( !step.dataTable ) {
1024
+ let error = log.error({ code: `ALK0251`, context: `generating files`, },
1025
+ `The table is missing for the constrained random answers Step at line ${ step.location.line }. The Step:`,
1026
+ get_flatter_table_Step({ step })
1027
+ );
1028
+ misfits.add({ errors: [ new Error( error ) ] });
1029
+
1030
+ } else if ( step.dataTable.rows.length === 1 ) {
1031
+ let error = log.error({ code: `ALK0252`, context: `generating files`, },
1032
+ `The body of the table is missing for the table of the constrained random answers Step at line ${ step.location.line }. There is only a header row. The Step:`,
1033
+ get_flatter_table_Step({ step }) );
1034
+ misfits.add({ errors: [new Error( error )] });
1035
+
1036
+ }
1037
+ if ( misfits.errors.length > 0 ) { return { table: null, ...misfits }; }
1038
+
1039
+ let step_indent = get_authors_indent({ located: step });
1040
+ let table_start = `${ step_indent }And I get to any of the question ids [${ ids.join(`, `) }] with this data:`;
1041
+ story_table_parts.push( table_start );
1042
+
1043
+ let step_bottom_index = as_index( step.location.line );
1044
+ let previous_significant_index = step_bottom_index;
1045
+ for ( let row_i = 0; row_i < step.dataTable.rows.length; row_i++ ) {
1046
+
1047
+ let row = step.dataTable.rows[ row_i ];
1048
+
1049
+ let row_file_index = as_index( row.location.line )
1050
+ let { tweens, errors } = misfits.add(
1051
+ get_tweens({
1052
+ end_index_of_prev: previous_significant_index,
1053
+ start_index_of_next: row_file_index,
1054
+ generator_lines: generator_lines,
1055
+ indexes_to_delete: []
1056
+ })
1057
+ );
1058
+ if ( misfits.errors.length > 0 ) { break; }
1059
+ story_table_parts.push( ...tweens );
1060
+
1061
+ if ( row_i === 0 ) {
1062
+ story_table_parts.push( get_table_header({ row }) );
1063
+ } else {
1064
+ story_table_parts.push( get_random_table_row({ row }) );
1065
+ }
1066
+
1067
+ previous_significant_index = row_file_index;
1068
+ }
1069
+
1070
+ let table = story_table_parts.join(`\n`);
1071
+ log.debug({ code: `ALK0255`, context: `generating files`, }, `Table:`, table );
1072
+ return { table, ...misfits };
1073
+ }
1074
+
1075
+
1076
+ let CAPTURE_QUESTION_IDS_REGEX = /("[^"]+")/g;
1077
+ function get_final_ids({ step_text }) {
1078
+ /** Return a list of docassemble question block ids (in double quotes) that
1079
+ * are in an ALKiln constraints Step. Every generated Story Table Step
1080
+ * must reach one of these ids.
1081
+ *
1082
+ * @param {object} obj - Named arguments
1083
+ * @param {string} obj.step_text - The text of a constraints Step
1084
+ *
1085
+ * Example:
1086
+ * get_final_ids({ step_text: `And I make 1 constrained random test that gets to "end1" or "end 2" with:` });
1087
+ * // [`"end1"`, `"end2"`]
1088
+ *
1089
+ * @returns {object} rtn - Named return values
1090
+ * @returns {["str"]} rtn.ids - 0 or more double-quoted docassemble question
1091
+ * block ids. Each generated Story Table Step must end at one of these
1092
+ * ids. this Scenario.
1093
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
1094
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
1095
+ * */
1096
+ let errors = [];
1097
+ let warnings = [];
1098
+
1099
+ let ids = step_text.match( CAPTURE_QUESTION_IDS_REGEX );
1100
+
1101
+ if ( ids === null ) {
1102
+ let missing_ids_msg = `The constrained random answers Step is missing its question ids. ALKiln is unable to create random tests for this generator Scenario. The Step:\n${ step_text }`;
1103
+ let error = log.error({ code: `ALK0250`, context: `generating files`, }, missing_ids_msg );
1104
+ errors.push(new Error( error ));
1105
+ }
1106
+
1107
+ return { ids, warnings, errors };
1108
+ }
1109
+
1110
+
1111
+ function get_flatter_table_Step({ step }) {
1112
+ /** Given a Gherkin `step` object, returns an object that is a less nested
1113
+ * version of the `step` object. If the Step doesn't have a table, return
1114
+ * the Step's text
1115
+ *
1116
+ * Yes, this is annoying to test, but it's a fairly simple way to show enough
1117
+ * info about the Step to be useful.
1118
+ *
1119
+ * Discuss: Should this go in the Log class?
1120
+ *
1121
+ * @param {object} obj - Named arguments
1122
+ * @param {object} obj.step - Valid Gherkin `step` object
1123
+ *
1124
+ * @returns {{ text, rows: [[str1, str2], ...] }} - Flatter version of `step`
1125
+ * */
1126
+ let simple = { text: step.text, rows: [] };
1127
+
1128
+ if ( !step.dataTable ) { return simple; }
1129
+
1130
+ for ( let row of step.dataTable.rows ) {
1131
+ let cells = [];
1132
+ for ( let cell of row.cells ) {
1133
+ cells.push( cell.value );
1134
+ }
1135
+ simple.rows.push( cells );
1136
+ }
1137
+ return simple;
1138
+ }
1139
+
1140
+
1141
+ function get_table_header({ row }) {
1142
+ /** Return the string for the Story Table header row. 2 or 3 column tables are
1143
+ * allowed.
1144
+ *
1145
+ * Discuss: Warn about wrong number of columns?
1146
+ *
1147
+ * @param {object} obj - Named arguments
1148
+ * @param {object} obj.row Valid Gherkin dataTable.row object
1149
+ *
1150
+ * @returns {string} A markdown table row with 2 or 3 columns
1151
+ * */
1152
+ let indent = get_authors_indent({ located: row });
1153
+ let num_cols = row.cells.length;
1154
+ let header = `${ indent }| var | value |`;
1155
+ if ( num_cols >= 3 ) { header += ` trigger |`; }
1156
+ return header;
1157
+ };
1158
+
1159
+
1160
+ function get_random_table_row({ row }) {
1161
+ /** Return the string of one Story Table row with a randomly chosen answer.
1162
+ *
1163
+ * @param {object} obj - Named arguments
1164
+ * @param {object} obj.row - Valid Gherkin dataTable.row object
1165
+ *
1166
+ * @returns {string} A markdown table row with 2 or 3 columns
1167
+ * */
1168
+ let indent = get_authors_indent({ located: row });
1169
+ let cells = row.cells;
1170
+ let num_cols = cells.length;
1171
+
1172
+ let answer = pick_random_answer({ answers_str: cells[1].value });
1173
+
1174
+ let row_str = `${ indent }| ${ cells[0].value } | ${ answer } |`;
1175
+ if ( cells.length >= 3 ) { row_str += ` ${ cells[2].value } |`; }
1176
+ return row_str;
1177
+ };
1178
+
1179
+
1180
+ function pick_random_answer({ answers_str=`` }) {
1181
+ /** Return one of the answers from the given string of possible answers.
1182
+ *
1183
+ * @param {object} obj - Named arguments
1184
+ * @param {str} obj.answers_str - A string representing one or more possible
1185
+ * answers. The possible answers are separated by this character
1186
+ * combination: `;;`
1187
+ *
1188
+ * Example:
1189
+ * pick_random_answer({ answers_str: `yes;; no;; maybe` });
1190
+ * // `no`
1191
+ *
1192
+ * TODO: Add a warning in the docs that Gherkin ignores whitespace at the
1193
+ * start and end of columns in a table.
1194
+ *
1195
+ * Discuss: let authors wrap content in backticks or some other delimiter.
1196
+ *
1197
+ * @returns {string} - One of the possible answers
1198
+ * */
1199
+ let choices = answers_str.replace(`;; `, `;;`).split(`;;`);
1200
+ if ( choices.includes(``) ) {
1201
+ // Discuss: Should we get rid of silent log warnings? They could help us
1202
+ // help authors troubleshoot their tests while not disturbing the author
1203
+ // with problems that may not exist. We can always make the warnings
1204
+ // louder later. Should we make them loud instead? And suggest the author
1205
+ // should put the empty one in the middle?
1206
+ log.debug({ code: `ALK0254`, context: `generating files` },
1207
+ `**Quiet warning** - One of the constrained random answer choices in a row in this table is "". It could be on purpose or it could be a misplaced separator (";;"). The choices: "${ answers_str }"`
1208
+ );
1209
+ }
1210
+ let choice_index = Math.floor( Math.random() * choices.length );
1211
+ return choices[ choice_index ];
1212
+ }
1213
+
1214
+
1215
+ function get_final_file_index_of_constraints_or_fore_Step({ step }) {
1216
+ /** Returns the index of the last possible item in a Gherkin Step object. That
1217
+ * includes the file index of the last row in a `dataTable`.
1218
+ *
1219
+ * Research: How to get the final row of a Step with a multi-line string?
1220
+ * Answer: Maybe it's how to look for the end of any Step. Look for the
1221
+ * starting line of the next Step. Look above that to the first line that
1222
+ * isn't a comment or blank line (though the other way around).
1223
+ *
1224
+ * Research: How do find the line of the end of a Scenario considering a final
1225
+ * Step could be a multi-line quote or an examples table.
1226
+ *
1227
+ * Research: How to get the final row of an `Examples` table?
1228
+ * Answer: `scenario.examples` then `.tableHeader.cells` or `.tableBody.cells`
1229
+ * Maybe we need to go back to just parsing strings. That would make the
1230
+ * explanation easier too... On the other hand, we have to account for
1231
+ * other details, like empty lines or comment lines or who knows what else
1232
+ *
1233
+ * Research: When there's a repeated line in the Story Table, do we use the
1234
+ * first line we find or the last one? Then make the behavior in here
1235
+ * match that behavior. Or just leave it to its own devices.
1236
+ *
1237
+ * @param {object} obj - Named arguments
1238
+ * @param {object} obj.step - Gherkin AST `step` object
1239
+ *
1240
+ * @returns {int} - Index of the final line as an index in the generator file
1241
+ * */
1242
+ if ( !step.dataTable ) { return as_index( step.location.line ); }
1243
+ let rows = step.dataTable.rows;
1244
+ return as_index(rows[ rows.length - 1 ].location.line);
1245
+ }
1246
+
1247
+
1248
+ function get_final_lines({
1249
+ generator_lines,
1250
+ final_significant_Step_index,
1251
+ indexes_to_delete
1252
+ }) {
1253
+ /** Return the rest of the lines till the end of the file
1254
+ *
1255
+ * Discuss: Alternatively, get all the remaining lines until the next Scenario
1256
+ * that are before comments and blank lines. Maybe that's not possible
1257
+ * given descriptions and docstrings (That might have the word "Scenario:"
1258
+ * in it). Still, may be able to combine Gherkin AST and parsing. to
1259
+ * achieve the result we want.
1260
+ *
1261
+ * @param {object} obj - Named arguments
1262
+ * @param {[str]} obj.generator_lines - List of lines from the generator file
1263
+ * @param {int} rtn.final_significant_Step_index - Generator file index for
1264
+ * the final found significant Step
1265
+ *
1266
+ * @returns {[str]} - 0 or more lines for the new file.
1267
+ * */
1268
+
1269
+ let { tweens, errors, warnings } = get_tweens({
1270
+ end_index_of_prev: final_significant_Step_index,
1271
+ start_index_of_next: generator_lines.length,
1272
+ generator_lines,
1273
+ indexes_to_delete
1274
+ });
1275
+
1276
+ return { final_lines: tweens, errors, warnings };
1277
+ }
1278
+
1279
+
1280
+ function put_together_new_test_text({
1281
+ starting_lines, middle_lines, final_lines, warnings
1282
+ }) {
1283
+ /** Return the combined text with its final modifications
1284
+ *
1285
+ * @param {object} obj - Named arguments
1286
+ * @param {[str]} obj.starting_lines - 0 or more lines to start the new test
1287
+ * @param {[str]} obj.middle_lines - 0 or more middle lines of the new test
1288
+ * @param {[str]} obj.final_lines - 0 or more lines to end the new test
1289
+ * @param {[str]} obj.warnings - 0 or more warning messages
1290
+ *
1291
+ * @returns {str} - Block of text of the new test
1292
+ * */
1293
+ let one_new_test_text = [
1294
+ ...starting_lines, ...middle_lines, ...final_lines,
1295
+ ].join(`\n`);
1296
+
1297
+ let edited_for_warnings = edit_for_warning_tag({
1298
+ new_text: one_new_test_text,
1299
+ has_warnings: warnings.length > 0
1300
+ });
1301
+
1302
+ return edited_for_warnings;
1303
+ }
1304
+
1305
+
1306
+ function edit_for_warning_tag({ new_text, has_warnings }) {
1307
+ /** Paste in a warning tag if needed, otherwise remove the placeholder text.
1308
+ *
1309
+ * @param {object} obj - Named arguments
1310
+ * @param {str} obj.new_text - The text to edit
1311
+ * @param {boolean} obj.has_warnings - Whether this Scenario has warning
1312
+ * messages
1313
+ *
1314
+ * @returns {str} - The edited text
1315
+ * */
1316
+ if ( has_warnings ) {
1317
+ new_text = add_warning_tag({ new_text });
1318
+ } else {
1319
+ new_text = remove_warning_tag_placeholder({ new_text });
1320
+ }
1321
+ return new_text;
1322
+ }
1323
+
1324
+
1325
+ const ALKILN_WARNING_TAG = `@alkiln_generator_warning`;
1326
+ function add_warning_tag({ new_text }) {
1327
+ /** Paste in the warning tag wherever the placeholders are
1328
+ *
1329
+ * @param {object} obj - Named arguments
1330
+ * @param {str} obj.new_text - The text to edit
1331
+ *
1332
+ * @returns {str} - Text with the warning tag added
1333
+ * */
1334
+ return new_text.replace( WARNING_TAG_PLACEHOLDER, ` ${ ALKILN_WARNING_TAG }` );
1335
+ }
1336
+
1337
+
1338
+ function remove_warning_tag_placeholder({ new_text }) {
1339
+ /** Remove the text of the warning tag placeholder to keep things purty.
1340
+ *
1341
+ * @param {object} obj - Named arguments
1342
+ * @param {str} obj.new_text - The text to clean up
1343
+ *
1344
+ * @returns {str} - The cleaned text
1345
+ * */
1346
+ return new_text.replace( WARNING_TAG_PLACEHOLDER, `` );
1347
+ }
1348
+
1349
+
1350
+ function add_total_tests({ scenario_group_str, num_tests }) {
1351
+ /** Paste in the total number of tests generated for this generator Scenario.
1352
+ *
1353
+ * @param {object} obj - Named arguments
1354
+ * @param {str} obj.scenario_group_str - The combined string of all the
1355
+ * Scenarios for this particular generator
1356
+ * @param {[str]} obj.num_tests - Number of tests that ALKiln created
1357
+ *
1358
+ * @returns {str} - The updated string
1359
+ * */
1360
+ let with_num_total_tests = scenario_group_str.replaceAll(
1361
+ TOTAL_TESTS_REGEX, num_tests
1362
+ );
1363
+ return with_num_total_tests;
1364
+ }
1365
+
1366
+
1367
+ function add_warnings_Scenario({ scenarios_str, warnings }) {
1368
+ /** Add the given warnings as a new Scenario before all the generated
1369
+ * Scenarios. Basically, a whole-generator-scope warning.
1370
+ *
1371
+ * Discuss: Add a pre-group Scenario for every group of generated Scenarios
1372
+ * identifying the generator Scenario of the subsequent new Scenarios.
1373
+ * That is, even when there is no warning to give.
1374
+ *
1375
+ * TODO: If/when we allow multiple generator Scenarios, use the ID here too
1376
+ *
1377
+ * @param {object} obj - Named arguments
1378
+ * @param {str} obj.scenarios_str - The str of the already existing Scenarios
1379
+ * @param {[str]} obj.warnings - 0 or more warning messages
1380
+ *
1381
+ * @returns {str} - The text of the combined Scenarios.
1382
+ * */
1383
+ let tags = `@alkiln_extra_intro ${ ALKILN_WARNING_TAG }`;
1384
+ let keyword_line = `Scenario: ALKiln humbly begs the author's forgiveness. The next tests might be flawed.`;
1385
+ let step_text = get_goofy_warnings_Step_text();
1386
+
1387
+ let warnings_lines = warnings_as_list_of_lines({ warnings });
1388
+ let warnings_str = get_warnings_docstring({ warnings_lines, indent: 2 });
1389
+
1390
+ let new_Scenario_str = [
1391
+ tags,
1392
+ keyword_line,
1393
+ step_text,
1394
+ warnings_str,
1395
+ ].join(`\n`);
1396
+
1397
+ let original_str = scenarios_str;
1398
+ let all_Scenarios_str = [ new_Scenario_str, original_str ].join(`\n\n`);
1399
+
1400
+ // Discuss: Clarity of `join()` expression. Alternatives.
1401
+ // `${new_Scenario_str}\n\n${original_str}`
1402
+ // new_Scenario_str + `\n\n` + original_str
1403
+ // `join()` gets the delimiter out of the way of the items being joined.
1404
+
1405
+ return all_Scenarios_str;
1406
+ }
1407
+
1408
+
1409
+ function get_goofy_warnings_Step_text() {
1410
+ /** Return the text of the "warnings" Step. It's only a bit goofy. We need
1411
+ * more goofs in the world. 0 arguments needed.
1412
+ *
1413
+ * Discuss: A global for the goofy strings would help to keep tests aligned.
1414
+ *
1415
+ * @returns {str} - The text of the "warnings" Step.
1416
+ * */
1417
+ let fun_ones = [
1418
+ `hiccup`, `snafu`, `bumble`, `stumble`, `fumble`, `slip`, `goof`, `flub`
1419
+ ];
1420
+ let fun_one = fun_ones[ Math.floor( Math.random() * fun_ones.length ) ];
1421
+ let step_text = ` Then ALKiln warns the author about a generator ${ fun_one }:`;
1422
+ return step_text;
1423
+ };
1424
+
1425
+
1426
+ function get_warnings_docstring({ warnings_lines, indent }) {
1427
+ /** Return the docstring for the warnings Step, including delimiters (`"""`)
1428
+ *
1429
+ * @param {object} obj - Named arguments
1430
+ * @param {[str]} obj.warnings - 0 or more strings
1431
+ * @param {int} obj.indent - How indentation levels to start each line. Note:
1432
+ * Each indentation level currently gets 2 spaces
1433
+ *
1434
+ * @returns {str} - docstring for the warnings Step
1435
+ * */
1436
+ let doc_str_lines = [
1437
+ `"""`,
1438
+ `――――― Generator file warnings ―――――`,
1439
+ ...warnings_lines,
1440
+ `―――――`,
1441
+ `"""`,
1442
+ ];
1443
+
1444
+ let indented_lines = [];
1445
+ for ( let line of doc_str_lines ) {
1446
+ indented_lines.push( ` `.repeat( indent ) + line );
1447
+ }
1448
+
1449
+ return indented_lines.join(`\n`);
1450
+ }
1451
+
1452
+
1453
+ function warnings_as_list_of_lines({ warnings }) {
1454
+ /** Flatten the list of single- or multi-line warnings into single lines.
1455
+ *
1456
+ * @param {object} obj - Named arguments
1457
+ * @param {[str]} obj.warnings - 0 or more single- or multi-line strings
1458
+ *
1459
+ * @returns {[str]} - 0 or more single-line strings
1460
+ * */
1461
+ let all_lines = [];
1462
+ for ( let warning of warnings ) {
1463
+ all_lines.push( ...warning.split(`\n`) );
1464
+ }
1465
+ return all_lines;
1466
+ }
1467
+
1468
+
1469
+ function validate_syntax({ file_text }) {
1470
+ /** Return any errors we get from running the text through the Gherkin parser
1471
+ *
1472
+ * @param {object} obj - Named arguments
1473
+ * @param {str} obj.file_text - Text of the Gherkin file
1474
+ *
1475
+ * @returns {object} rtn - Named return values
1476
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
1477
+ * */
1478
+ let { errors: generated_parsing_errors } = get_Gherkin_AST({ file_text });
1479
+ return { errors: generated_parsing_errors || [] };
1480
+ }
1481
+
1482
+
1483
+ function save_files({ tests_path, artifacts_path, file_strings }) {
1484
+ /** Save each test file string to an artifacts folder and a folder that
1485
+ * cucumber will use to run tests.
1486
+ *
1487
+ * @param {object} obj - Named arguments
1488
+ * @param {str} tests_path - Path where cucumberjs will find these files
1489
+ * @param {str} artifacts_path - Artifacts folder path
1490
+ * @param {[str]} file_strings - Each string will be a test file
1491
+ *
1492
+ * @returns {undefined}
1493
+ * */
1494
+ // Delete the old files no matter what
1495
+ delete_previous_tests_from_sources_folders_for_ALKiln_developers({ tests_path });
1496
+
1497
+ if ( file_strings.length === 0 ) { return; }
1498
+
1499
+ // Implementation note: This has no error if the folder already exists
1500
+ fs.mkdirSync( tests_path, { recursive: true });
1501
+ fs.mkdirSync( artifacts_path, { recursive: true });
1502
+
1503
+ log.debug({ code: `ALK0269`, context: `generating files`, },
1504
+ `Saving ${ file_strings.length } newly generated files in "${ tests_path }"`
1505
+ );
1506
+
1507
+ let count = 1;
1508
+ for ( let file_str of file_strings ) {
1509
+ let filename = `${ count }_${ Date.now() }.feature`;
1510
+ count++;
1511
+ // Implementation note: If the files don't exist, this creates them
1512
+ fs.appendFileSync( `${ tests_path }/${ filename }`, file_str );
1513
+ fs.appendFileSync( `${ artifacts_path }/${ filename }`, file_str );
1514
+ }
1515
+
1516
+ log.debug({ code: `ALK0270`, context: `generating files`, },
1517
+ `Saved new generated files:`, fs.readdirSync( tests_path )
1518
+ );
1519
+ }
1520
+
1521
+
1522
+ function delete_previous_tests_from_sources_folders_for_ALKiln_developers({ tests_path }) {
1523
+ /** Delete previously generated files from this folder if they exist because
1524
+ * otherwise those tests will run again too (like for ALKiln devs).
1525
+ *
1526
+ * Note: All the relevant tests that cucumber will run will be on the paths
1527
+ * that come in here, so all *relevant* old files will get deleted, even if
1528
+ * there are previous files hanging around somewhere else.
1529
+ *
1530
+ * Research: How will this folder behave on the Playground? Do we need to
1531
+ * clean up at the end too?
1532
+ *
1533
+ * @param {object} obj - Named arguments
1534
+ * @param {str} tests_path - Path where cucumberjs will find these files
1535
+ *
1536
+ * @returns {undefined}
1537
+ * */
1538
+ if ( !fs.existsSync( tests_path )) { return; }
1539
+ log.debug({ code: `ALK0268`, context: `generating files`, },
1540
+ `Deleting previously generated files:`, fs.readdirSync( tests_path )
1541
+ );
1542
+ fs.rmSync( tests_path, { recursive: true, force: true });
1543
+ }
1544
+
1545
+
1546
+ /**
1547
+ * ====================
1548
+ * Shared functions
1549
+ * _In order of first runtime call appearance_
1550
+ * ====================
1551
+ * */
1552
+
1553
+ function as_index( line_num ) {
1554
+ /** Given a Gherkin line number, return the file-lines-list index for that
1555
+ * line number. This is here mostly as a name that helps clarify what the
1556
+ * code is doing.
1557
+ *
1558
+ * Each Gherkin line number matches a line in the file and therefore an item
1559
+ * in the `generator_lines` list. The Gherkin line numbers are 1-indexed.
1560
+ * That means the location of `{line: 1}` in the AST refers to
1561
+ * `generator_lines[0]`.
1562
+ *
1563
+ * @param {int} line_num - A 1-indexed Gherkin line number
1564
+ *
1565
+ * @returns {int} - The corresponding index in the list of lines in the
1566
+ * generator file
1567
+ * */
1568
+ return line_num - 1;
1569
+ }
1570
+
1571
+
1572
+ class Misbehaviors {
1573
+ /** Accumulates and manages errors and warnings lists. This lets you
1574
+ * accumulate errors for a whole function. A loop in that function, though,
1575
+ * can build up warnings just for that loop, after which you can reset the
1576
+ * warnings list for the next loop. Each loop can add up its own warnings.
1577
+ *
1578
+ * Example:
1579
+ *
1580
+ * function do_important_things() {
1581
+ * let misfits = new Misbehaviors();
1582
+ *
1583
+ * let { result, errors } = func();
1584
+ * misfits.add({ errors });
1585
+ *
1586
+ * for ( let item of generator_lines ) {
1587
+ *
1588
+ * misfits.reset_warnings();
1589
+ *
1590
+ * let { foo, warnings1, errors1 } = func1( item );
1591
+ * misfits.add({ warnings: warnings1, errors: errors1 });
1592
+ *
1593
+ * let { bar, warnings2, errors2 } = func2( item );
1594
+ * misfits.add({ warnings: warnings2, errors: errors2 });
1595
+ *
1596
+ * console.log( misfits.warnings );
1597
+ * // Loop 1: [ "oops", "darn" ]
1598
+ * // Loop 2: [ "shoot", "zoinks" ]
1599
+ * }
1600
+ *
1601
+ * console.log( misfits.errors );
1602
+ * // [ "ahh!", "gorramit", "that's done it" ]
1603
+ * }
1604
+ * */
1605
+ constructor() {
1606
+ this.errors = [];
1607
+ this.warnings = [];
1608
+ }
1609
+
1610
+ reset_warnings() {
1611
+ /** Sets warnings to an empty list. This is useful inside a loop that still
1612
+ * needs to keep
1613
+ *
1614
+ * @returns {undefined}
1615
+ * */
1616
+ this.warnings = [];
1617
+ }
1618
+
1619
+ add({ errors=[], warnings=[], ...return_vals }) {
1620
+ /** Adds errors and/or warnings to their list. Returns the same values.
1621
+ *
1622
+ * @param {obj} obj - Named arguments
1623
+ * @param {[str]} obj.warnings - (Optional) 0 or more warning messages
1624
+ * @param {[Error]} obj.errors - (Optional) 0 or more Errors
1625
+ * @param {Any} obj...return_vals - (Optional) Any other values
1626
+ *
1627
+ * @returns {any} - The values it was given
1628
+ * */
1629
+ if ( this.errors ) { this.errors.push( ...errors ); }
1630
+ if ( this.warnings ) { this.warnings.push( ...warnings ); }
1631
+ return { errors, warnings, ...return_vals };
1632
+ }
1633
+
1634
+ steal_misbehaviors() {
1635
+ return this.add( ...arguments );
1636
+ }
1637
+ }
1638
+
1639
+
1640
+ function scenario_description_for_logs({ scenario }) {
1641
+ /** Combines metadata to return a fuller description of the Scenario.
1642
+ *
1643
+ * @param {object} obj - Named arguments
1644
+ * @param {object} obj.scenario - Gherkin AST `scenario` object
1645
+ *
1646
+ * @returns {string} - Description of the Scenario
1647
+ * */
1648
+ let description = `line ${ scenario.location.line }: "${ scenario.name }"`;
1649
+ if ( scenario.tags.length > 0 ) {
1650
+ let tag_names = scenario.tags.map((tag)=>{ return tag.name; });
1651
+ description += `, ${ tag_names.join(' ') }`;
1652
+ }
1653
+
1654
+ return description;
1655
+ }
1656
+
1657
+
1658
+ function get_keyword_index({ scenario }) {
1659
+ /** Return the generator file index of the Scenario's keyword line.
1660
+ *
1661
+ * @param {object} obj - Named arguments
1662
+ * @param {object} obj.scenario - Gherkin Scenario object
1663
+ *
1664
+ * @returns {int} - Keyword line index
1665
+ * */
1666
+ return as_index( scenario.location.line );
1667
+ }
1668
+
1669
+
1670
+ function get_tweens({
1671
+ end_index_of_prev=0,
1672
+ start_index_of_next=0,
1673
+ generator_lines=[],
1674
+ indexes_to_delete=[]
1675
+ }) {
1676
+ /** Return a list of strings of the lines in between start and end index,
1677
+ * EXCLUSIVE OF BOTH. If `generator_lines` does not have enough lines for
1678
+ * `start_index_of_next`, it will return values until the end of the list.
1679
+ * This is intended to get comments, blank lines, descriptions, and Steps
1680
+ * that the generator will leave alone.
1681
+ *
1682
+ * IMPORTANT: Remember it excludes both the starting line and the final line
1683
+ *
1684
+ * @Example
1685
+ * let tweens = get_tweens({ end_index_of_prev: 3, start_index_of_next: 6,
1686
+ * [
1687
+ * `Feature: I go the store`, ``, `Scenario: I get apples`,
1688
+ * ` Given ALKiln makes 5 random tests`, `And I start the interview at go.yml`,
1689
+ * ` And I set "groceries" to True`, ` Alkiln can get to "end" with:`
1690
+ * ]
1691
+ * });
1692
+ * // console.log( tweens );
1693
+ * // { tweens: [
1694
+ * // `And I start the interview at go.yml`, ` And I set "groceries" to True`,
1695
+ * // ]}
1696
+ *
1697
+ * @param {object} obj - Named arguments
1698
+ * @param {int} obj.end_index_of_prev - The line that ended the previous
1699
+ * significant file lines (like a fore-Step)
1700
+ * @param {int} obj.start_index_of_next - The line that will start the next
1701
+ * significant file line (like a constraints Step)
1702
+ * @param {[Any]} obj.generator_lines - List of generator_lines
1703
+ * @param {[int|undefined]} obj.indexes_to_delete - 0 or more indexes of
1704
+ * generator_lines to delete from the list
1705
+ *
1706
+ * @returns {obj} rtn - Named return values
1707
+ * @returns {[str]} - 0 or more lines in between the given lines (exclusive)
1708
+ * @returns {[str]} rtn.warnings - 0 or more warning messages
1709
+ * @returns {[Error|str]} rtn.errors - 0 or more strings or Errors
1710
+ * */
1711
+ let errors = [];
1712
+ let warnings = [];
1713
+ let tweens = [];
1714
+ try {
1715
+ let start = end_index_of_prev + 1; // exclude previous significant line
1716
+ for ( let line_i = start; line_i < start_index_of_next; line_i++ ) {
1717
+ if ( indexes_to_delete.includes( line_i )) { continue; }
1718
+ tweens.push( generator_lines[ line_i ]);
1719
+ }
1720
+
1721
+ log.debug({ code: `ALK0246`, context: `generating files`, },
1722
+ `Got tween lines. Starting line (excluded):`, generator_lines[ end_index_of_prev ],
1723
+ `\nTweens:`, tweens,
1724
+ `\nEnding line (excluded):`, generator_lines[ start_index_of_next ]
1725
+ );
1726
+ } catch ( tween_error ) {
1727
+ errors.push( new Error( log.error({ code: `ALK0247`, context: `generating files`, },
1728
+ `ALKiln got an error it is unfamiliar with when generating lines in between other lines starting on Gherkin AST line ${ start }. We would love to see your debug.txt`
1729
+ )));
1730
+ errors.push( tween_error );
1731
+ }
1732
+
1733
+ return { tweens, errors, warnings };
1734
+ }
1735
+
1736
+
1737
+ function get_authors_indent({ located }) {
1738
+ /** Return a whitespace string representing the indentation for a given
1739
+ * Gherkin location object.
1740
+ *
1741
+ * @param {object} obj - Named arguments
1742
+ * @param {object} obj.located - Gherkin AST object with a location property
1743
+ *
1744
+ * @returns {string} - A whitespace string representing the indentation for a
1745
+ * given Gherkin location object.
1746
+ * */
1747
+ if ( typeof located !== `object` || !located.location ) {
1748
+ log.debug({ code: `ALK0253`, context: `generating files`, }
1749
+ `Skipped get_authors_indent. Given argument:\n`,
1750
+ located
1751
+ );
1752
+ return ``;
1753
+ }
1754
+ return ` `.repeat( located.location.column - 1 );
1755
+ }