@suffolklitlab/alkiln 5.13.2 → 5.13.3-feat-random

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