@suffolklitlab/alkiln 5.13.4 → 5.15.0

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