@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,1776 @@
1
+
2
+ /**
3
+ *
4
+ * | var | possible_values |
5
+ * | favorite_color | blue;; green;; red |
6
+ *
7
+ * @param raw_var_data { [{ var, possible_values }] } List of table row objects
8
+ * @param raw_var_data[i].var {str} Name of the variable to set
9
+ * @param raw_var_data[i].possible_values {str} Format is ";;" separated
10
+ * possible values for the variable (lawyers use ";" grammatically)
11
+ * */
12
+
13
+ /** Need a sentence for:
14
+ * And I will go to the interview at the address ("")
15
+ * And I will run (100) constrained random tests
16
+ * And those tests should get to one of these page ids - [("lsdjf", "sldkjf")]
17
+ * And I will get there with random choices from this data:
18
+ * | var | possible_values |
19
+ * | favorite_color | blue;; green;; red |
20
+ *
21
+ * Write files
22
+ * 1. Make an object from the given constraints table (split by single _and_
23
+ * double quotes? Just double quotes? Document author _must_ use double
24
+ * quotes?)
25
+ * 1. Make sure that it's not the same as previous objects
26
+ * 1. Far in the future: Make sure it's not the same as a test the author
27
+ * already wrote (need to wrap cucumber for this to run all random tests
28
+ * last)
29
+ * 1. Make a Scenario description using the iteration number
30
+ * 1. Add to one big file (name includes datetime) for all these Scenarios
31
+ * 1. Increment iteration number
32
+ * 1. Leave them there to get run later
33
+ *
34
+ * Supposedly more performant to write one big file [^citation needed]. Not
35
+ * sure it matters that much yet. If we do want to optimize, maybe using
36
+ * a write stream directly is the way to go.
37
+ *
38
+ * Command:
39
+ * I run (\d*) random tests for/at "interview.yml" that will reach one of these question/page ids (\[".*"]) with these constraints: (table)
40
+ * (\d*) random at "interview.yml" will reach one/any of (\[.*]) with: (table)
41
+ *
42
+ * Given I generate 100 random tests
43
+ * And I test the interview at ""
44
+ * And I reach any of these question/page ids: ""
45
+ * And I answer with/give these possible answers: (table)
46
+ * I generate 100 random tests for the interview at "" to reach any of the question ids [] with these possible answers
47
+ *
48
+ */
49
+
50
+ /**
51
+ * If not doing a 1-Scenario requirement
52
+ *
53
+ * Find end of previous Scenario by going backwards in the list of file lines
54
+ * until we reach a non-comment/blank line.
55
+ * */
56
+
57
+ /** What do I want this AST to look like?
58
+ * Gherkin AST does validating
59
+ *
60
+ * {
61
+ * *
62
+ * feature tags str,
63
+ * *
64
+ * feature str,
65
+ * *
66
+ * scenarios: [
67
+ * scenario1 tags str,
68
+ * *
69
+ * scenario2 str
70
+ * *
71
+ * steps: [
72
+ * *
73
+ * first step 1,
74
+ * *
75
+ * first step 2,
76
+ * *
77
+ * constraints step 1 {
78
+ * ids, table
79
+ * step str,
80
+ * table str
81
+ * (or whole thing)
82
+ * (gherkin AST parser for fake file to get the table. what then?)
83
+ * }
84
+ * *
85
+ * constraints step 2 {}
86
+ * *
87
+ * ]
88
+ * *
89
+ * scenario 2...
90
+ * ]
91
+ * }
92
+ *
93
+ * # or empty line = maybe_scenario_end
94
+ * unless we hit
95
+ * Not something else that is not "Scenario:" (or tags) and `scenario end` is canceled
96
+ * if "Scenario:" (or tags)
97
+ * From the previous maybe_scenario_end and here is the tween scenarios part
98
+ *
99
+ * Except! If we're in quotes... how? What can be delimiters? """ or ```
100
+ *
101
+ *
102
+ * */
103
+
104
+
105
+ const COMMENT_REGEX = /^[\s\t]*#/;
106
+ const BLANK_LINE_REGEX = /^[\s\t]*$/;
107
+
108
+ const ALKILN_REGEX = /ALKiln/i;
109
+ const CONSTRAIN_REGEX = /constrain/i; // constrained/constraint/constraints
110
+ const RAND_REGEX = /rand/i; // random/randomness/randomized
111
+ const GENERAT_REGEX = /generat/i; // generate/generates/generated/generating
112
+ const MAKE_REGEX = /make|made/i; // make/makes/made/making
113
+ const NUMBER_REGEX = /\d+/i;
114
+ const CAPTURE_NUMBERS_REGEX = /(\d+)/g;
115
+ const DOULBE_QUOTES_REGEX = /"[^"]+"/;
116
+ const SINGLE_QUOTES_REGEX = /'[^']+'/;
117
+
118
+ class Parser {
119
+ /** step_tweens (between significant steps - fore_step, constraints_step)
120
+ *
121
+ * Need: current type/mode/context and the current line
122
+ *
123
+ * What's the difference between the text of a step and the stuff that comes
124
+ * after like a docstring or datatable
125
+ *
126
+ * modes:
127
+ * - fore_step
128
+ * - constraints_step (do we add tween steps to the top? as long as we don't
129
+ * want to use the gherkin ast parser)
130
+ * - constraints_table
131
+ * - x table_tween
132
+ * - doc_string
133
+ * - doc_string_end ("""/```)
134
+ * - x step_tween
135
+ * - non_mode (everything is a tween unless it starts a new mode)
136
+ *
137
+ * */
138
+ increment(return_obj, index) { return_obj.next = index + 1; return return_obj; }
139
+
140
+ comment({ old_lines=[], index=0, new_lines=[] }) {
141
+ /** Add line */
142
+ let result = { match: false, error: null, lines: new_lines };
143
+ if ( COMMENT_REGEX.test(old_lines[ index ]) ) {
144
+ result.match = true;
145
+ new_lines.push(old_lines[ index ]);
146
+ }
147
+ return this.increment( result, index );
148
+ }
149
+
150
+ blank({ old_lines=[], index=0, new_lines=[] }) {
151
+ /** */
152
+ let result = { match: false, error: null, lines: new_lines };
153
+ if ( BLANK_LINE_REGEX.test(old_lines[ index ]) ) {
154
+ result.match = true;
155
+ new_lines.push(old_lines[ index ]);
156
+ }
157
+ return this.increment( result, index );
158
+ }
159
+
160
+ description({ old_lines=[], index=0, new_lines=[], contexts={} }) {
161
+ /** Do we need this? Should we just outlaw descriptions? */
162
+ if ( !context.scenario ) {
163
+ }
164
+ }
165
+
166
+ num_tests_Step({ old_lines=[], index=0, new_lines=[] }) {
167
+ let result = { match: false, error: null, lines: new_lines };
168
+ let text = old_lines[ index ];
169
+
170
+ // Use `.test()` without `g` flag. See
171
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test#using_test_on_a_regex_with_the_global_flag
172
+ let is_num_Step = NUMBER_REGEX.test( text )
173
+ && ALKILN_REGEX.test( text )
174
+ && CONSTRAIN_REGEX.test( text )
175
+ && ( RAND_REGEX.test( text )
176
+ || GENERAT_REGEX.test( text )
177
+ || MAKE_REGEX.test( text )
178
+ );
179
+ if ( is_num_Step ) {
180
+ result.match = true;
181
+ results.value = { num_to_make: text.match( CAPTURE_NUMBERS_REGEX ) };
182
+ }
183
+
184
+ return this.increment( result, index );
185
+ }
186
+
187
+ constraints_Step({ old_lines=[], index=0 }) {
188
+ let result = { match: false, error: null };
189
+ let text = old_lines[ index ];
190
+ let step_matches = ALKILN_REGEX.test( text )
191
+ && DOULBE_QUOTES_REGEX.test( text )
192
+ && SINGLE_QUOTES_REGEX.test( text );
193
+ if ( !step_matches ) { return result; }
194
+
195
+ let table_lines = old_lines.split( index + 1 );
196
+ for ( let line of table_lines ) {
197
+ if ( this.table({ line }) ) {
198
+ result.matches = true;
199
+ result.value = { line: text }; // Maybe get ids here
200
+ break;
201
+ }
202
+ // Need to change signatures
203
+ if ( this.comment({ line }) || this.blank({ line }) ) {
204
+ continue;
205
+ } else {
206
+ break;
207
+ }
208
+ }
209
+
210
+ return this.increment( result, index );
211
+ }
212
+
213
+ table({ line, index=0 }) {
214
+ let result = { match: false, error: null };
215
+ if ( /^[\s\t]*\|/.test( line )) {
216
+ result.match = true;
217
+ result.value = { line: line }; // modify line to change header or choose unique value
218
+ }
219
+ return this.increment( result, index );
220
+ }
221
+
222
+ other({ old_lines=[], index=0, new_lines=[] }) {
223
+ /**
224
+ * When in between feature and scenario keyword or between scenario keyword
225
+ * and steps, this is a description */
226
+ let result = { match: false, error: null, lines: new_lines };
227
+ new_lines.push(old_lines[ index ]);
228
+ return this.increment( result, index );
229
+ }
230
+
231
+ run( input_lines ) {
232
+ // let result = this.char( input );
233
+ let ast = {};
234
+ // let starting_function = {
235
+ // constraints, ...
236
+ // }
237
+ let mode_functions = {
238
+ constraints: {
239
+ is_start: function () {
240
+ // ???? Not associated with the mode you're in, but is associated with modes
241
+ },
242
+ is_tween: function () {
243
+ return !input.startsWith(`|`) && !input.startsWith(/[A-z]/); // #
244
+ },
245
+ is_end: function () {
246
+ return input.startsWith(/[A-z]/);
247
+ }
248
+ // just the below?
249
+ is_mode: function ( line, current_mode ) {
250
+
251
+ }
252
+ },
253
+ constraints_table: {
254
+ is_start: function () {
255
+ if ( current_mode === `constraints` ) {
256
+ return input.startsWith(`|`)...
257
+ }
258
+ },
259
+ }
260
+ }
261
+ let current_ending_function = null;
262
+ let tweens = [];
263
+ for ( let line of input_lines ) { // is this repeat_until?
264
+
265
+ let { location: { start, end }, mode, is_a_tween, str, ...stoof } = this.parse_one_line( line, this.char, this.something );
266
+ // is pre_end also tween? Leave as empty array?
267
+ if ( is_a_tween ) {
268
+ tweens.push(line);
269
+ } else if ( tweens.length > 0 ) {
270
+ if ( !is_new_mode ) {
271
+ current_line_list.push( ...tweens );
272
+ } else {
273
+ current_line_list = [ ...tweens, line ];
274
+ }
275
+ tweens = [];
276
+ } else {
277
+ if ( !is_new_mode ) {
278
+ current_line_list.push( line );
279
+ } else {
280
+ current_line_list = [ line ];
281
+ }
282
+ }
283
+ if ( mode === `constraints_step` ) {
284
+ current_ending_function = ending_functions.constraints_end;
285
+ }
286
+ }
287
+
288
+ // Maybe do other stuff
289
+ return result;
290
+ }
291
+
292
+ nested_run() {
293
+ /**
294
+ * instead of switching between contexts
295
+ * if enter a new "context" before the end of the previous "context", you
296
+ * go one level deeper - a sub-context. Maybe a little recursive.
297
+ *
298
+ *
299
+ * */
300
+ }
301
+
302
+ composable_run() {
303
+ // ???
304
+ }
305
+
306
+ char( input ) {
307
+ if ( input.startsWith(`(`) ) {
308
+ return {error: null, value: input[0], original: input}
309
+ } else {
310
+ return {error: new Error(`Not a left paren`), value: null, original: input}
311
+ }
312
+ }
313
+
314
+ repeat_until( input, to_do, is_end ) {
315
+ let result;
316
+ let done = is_end( input )
317
+ while ( !done ) {
318
+ ({ result, done } = to_do(input))
319
+ }
320
+ }
321
+
322
+
323
+ }
324
+
325
+ let parser = new Parser();
326
+ console.log(parser.run(`(thing`));
327
+ console.log(parser.run(`frog`));
328
+ /** Additional complications
329
+ * 1. Step keywords
330
+ *
331
+ * */
332
+
333
+ /** Options with original parsing to reduce complications
334
+ * 1. Duplicate comments at the end of Scenarios
335
+ * 1. Remove comments :(
336
+ * 1. Only allow 1 Scenario
337
+ * */
338
+
339
+ fixts.made = {};
340
+ fixts.made.arg =
341
+ `@go_places
342
+ Feature: I generate constrained random tests
343
+
344
+ This doesn't do anything
345
+
346
+ I make 5 random answer tests
347
+
348
+ @shop
349
+ Here?
350
+ Scenario: I use minimal text (constrain and made)
351
+ This is maybe a description?
352
+ I make 5 random answer tests
353
+ # neither does this
354
+ Given 1 made ALKiln constrain
355
+ And I start the interview at "test_kickout"
356
+ And 1 made "end" constrain
357
+ | var | possible_values |
358
+ # blah
359
+
360
+ | user_choice | correct;; wrong |
361
+ Then I reach "kickout" with:
362
+ | var | possible_values |
363
+ | stoof | ye |
364
+ Then I get to "blah" with this data:
365
+ | var | value |
366
+ | foo | bar |
367
+
368
+ Description here too?`;
369
+ fixts.made.expected =
370
+ `@alkiln @generated @randomized @constraints
371
+ Feature: I generate constrained random tests (generated by ALKiln)
372
+
373
+ @generator1_scenario1_of_3
374
+ Scenario: I use minimal text (constrain and made) (1 of 1)
375
+ Given I start the interview at "test_kickout"
376
+ And I get to any of the question ids ["end"] with this data:
377
+ | var | value |
378
+ ${ fixts.RANDOM_TABLE_PLACEHOLDER }
379
+
380
+ @generator1_scenario2_of_3
381
+ Scenario: I use minimal text (constrain and made) (1 of 1)
382
+ Given I start the interview at "test_kickout"
383
+ And I get to any of the question ids ["end"] with this data:
384
+ | var | value |
385
+ ${ fixts.RANDOM_TABLE_PLACEHOLDER }
386
+
387
+ @generator1_scenario3_of_3
388
+ Scenario: I use minimal text (constrain and made) (1 of 1)
389
+ Given I start the interview at "test_kickout"
390
+ And I get to any of the question ids ["end"] with this data:
391
+ | var | value |
392
+ ${ fixts.RANDOM_TABLE_PLACEHOLDER }
393
+ `;
394
+
395
+
396
+
397
+
398
+
399
+
400
+
401
+
402
+
403
+
404
+ const fs = require('fs');
405
+ const fg = require(`fast-glob`);
406
+
407
+ const globals = require(`../globals`);
408
+ const session_vars = require('./session_vars');
409
+ const get_Gherkin_AST = require(`./get_Gherkin_AST`);
410
+ const get_error_stacks = require(`./get_error_stacks`);
411
+
412
+ /**
413
+ * TODO: We might give various levels of categories for warnings. For example,
414
+ * severe warnings (like "will skip the random test") vs. slight warnings
415
+ * (like "missing number in constraints random test")
416
+ * TODO: Save the warnings until the tests actually run so they can show up
417
+ * where other warnings show up. Does that mean making a "message to author"
418
+ * internal Step of some kind?
419
+ * TODO: Post-MVP, don't repeat tests that the author has already created
420
+ * Discuss: Require author to use "Generator Scenario:" as they keyword (Also
421
+ * "Generator Scenario Outline:") (or "Scenario Generator", maybe harder to
422
+ * scan) for 2 reasons. 1. Gets the vocab of "generator" in early (though
423
+ * that doesn't match "constraints" Step, which is a whole other language
424
+ * problem we haven't solved yet.) 2. Saying "MAX_RANDOM_TESTS_PER_SCENARIO"
425
+ * is confusing when "Scenario" means different things - a generator Scenario
426
+ * and a regular Scenario.
427
+ * Discuss: Now that Log is less fragile and duplicated, shall we create a new
428
+ * Log instance in here?
429
+ * Discuss: consider debug types that are for authors. Sort of like warnings,
430
+ * but less loud.
431
+ * Discuss: Log.error that just returns a string formatted like an error with
432
+ * the right icon, etc, without logging it to the console. We can _sort_ of
433
+ * do that now, but without the icon.
434
+ * */
435
+
436
+
437
+ /** Note: Keep this file non-executable so we can test it more easily. */
438
+
439
+
440
+ module.exports = constrained_random_tests = {};
441
+
442
+ let log = null;
443
+
444
+ const FOLDER_NAME = globals.generated_files_folder_name;
445
+ constrained_random_tests.create = function({
446
+ sources_path,
447
+ logger
448
+ }) {
449
+ /** Given files with a certain prefix, use the text in them to generate a
450
+ * feature file or files. A file generates one file with multiple
451
+ * Scenarios.
452
+ *
453
+ * @param {object} obj - Named arguments
454
+ * @param {string} obj.sources_path - Path to the docassemble Sources folder
455
+ *
456
+ * @returns {object} rtn - Named return values
457
+ * @returns {[str]} rtn.new_strings - 0 or more strings that each created a
458
+ * new file.
459
+ * @returns {[Error]} rtn.errors - 0 or more Errors.
460
+ *
461
+ * Details:
462
+ * - Copy sections of text from the old file
463
+ * - Use the Gherkin parser's AST of the constraints Step in each Scenario to
464
+ * generate Story Tables
465
+ * - Combine various sections with the Story Tables
466
+ * - Make a new file with that content
467
+ *
468
+ * We're trying to find the least complex, yet sufficiently robust, system
469
+ *
470
+ * Goal:
471
+ * Stay as true to the author's syntax as possible so they can get useful
472
+ * errors from cucumber itself.
473
+ *
474
+ * Anti-goals:
475
+ * - Be cucumber
476
+ * - Render the whole AST ourselves
477
+ *
478
+ * For examples, see tests.
479
+ *
480
+ * */
481
+ log = logger;
482
+
483
+ let new_files_strings = [];
484
+ let all_errors = [];
485
+
486
+ // Get the paths of the source files that we need to parse
487
+ let generator_paths = fg.sync(`${ sources_path }/${ globals.generators_prefix }*.feature`);
488
+ for ( let generator_path of generator_paths ) {
489
+
490
+ let file_text = fs.readFileSync( generator_path, {encoding: `utf8`});
491
+
492
+ log.info({ code: `ALK0231`, context: `generating files`, },
493
+ `Starting to generate the constrained random answers tests for "${ generator_path }"`
494
+ );
495
+
496
+ let { new_contents, errors: parse_errors } = constrained_random_tests.parse_file({
497
+ file_text,
498
+ generator_path
499
+ });
500
+ all_errors.push( ...parse_errors );
501
+
502
+ if ( new_contents ) {
503
+ new_files_strings.push( new_contents );
504
+ }
505
+ }
506
+
507
+ let tests_path = `${ sources_path }/${ FOLDER_NAME }`;
508
+ let artifacts_path = `${ session_vars.get_artifacts_path_name() }/${ FOLDER_NAME }`;
509
+ save_files({ tests_path, artifacts_path, file_strings: new_files_strings });
510
+
511
+ return { new_strings: new_files_strings, errors: all_errors };
512
+ }; // Ends make_random_input_files()
513
+
514
+
515
+ constrained_random_tests.parse_file = function({ file_text, generator_path, logger }) {
516
+ /** Parse and return one file's contents
517
+ *
518
+ * TODO: If there's a Gherkin parser error, log something more clear before
519
+ * re-throwing it?
520
+ *
521
+ * Note: The generator file should be valid by the time it gets here.
522
+ *
523
+ * @param {string} file_text - Full text of a generator feature file
524
+ *
525
+ * @returns {object} rtn - Named return values
526
+ * @returns {null | str} rtn.new_contents - `null` or string for the new file
527
+ * @returns {[Error]} rtn.errors - 0 or more Errors.
528
+ * */
529
+ // Unit tests call the parser alone, so they need to create their own logger.
530
+ if ( logger ) {
531
+ log = logger;
532
+ }
533
+
534
+ let errors = [];
535
+
536
+ let { doc_AST, errors: generator_Gherkin_errors } = get_generator_AST({ file_text, generator_path });
537
+ if ( generator_Gherkin_errors.length > 0 ) {
538
+ return { new_contents: null, errors: generator_Gherkin_errors };
539
+ }
540
+
541
+ let { new_contents, errors: generating_errors } = get_new_file_contents({ doc_AST, file_text, generator_path });
542
+ if ( generating_errors.length > 0 ) {
543
+ return { new_contents: null, errors: generating_errors };
544
+ }
545
+
546
+ let { errors: generated_parsing_errors } = validate_syntax({ file_text: new_contents, generator_path });
547
+ if ( generated_parsing_errors.length > 0 ) {
548
+ let syntax_error = log.warn({ code: `ALK0249`, context: `generating files`, before: `\n\n`, },
549
+ `The .feature file ALKiln generated from "${ generator_path }" had Gherkin syntax error(s)\n`,
550
+ ...get_error_stacks({ errors: generated_parsing_errors })
551
+ );
552
+ return { new_contents, errors: [ new Error( syntax_error )] };
553
+ }
554
+
555
+ return { new_contents, errors };
556
+ }
557
+
558
+
559
+ function get_generator_AST({ file_text, generator_path }) {
560
+ /** Return any errors created when Gherkin tries to parse the generator file.
561
+ * If there are any errors, throw a list of all the errors.
562
+ *
563
+ * @param {object} obj - Named arguments
564
+ * @param {string} obj.file_text - Text of the Gherkin file
565
+ * @param {string} obj.generator_path - Path to the generator file
566
+ *
567
+ * @returns {object} rtn - Named return values
568
+ * @returns {object} rtn.doc_AST - Gherkin AST of a .feature file
569
+ * */
570
+ let { AST: doc_AST, errors: generator_errors } = get_Gherkin_AST({ file_text });
571
+
572
+ if ( generator_errors.length <= 0 && doc_AST.feature ) {
573
+ return { doc_AST, errors: [] };
574
+ }
575
+
576
+ /** Error handling */
577
+ if ( generator_errors.length > 0 ) {
578
+ let syntax_error = log.warn({ code: `ALK0236`, context: `generating files`, before: `\n\n`, },
579
+ `The author's generator .feature file at "${ generator_path }" Gherkin syntax error(s). You might see more warnings above or below this one.\n`,
580
+ ...get_error_stacks({ errors: generator_errors })
581
+ );
582
+ return { doc_AST: null, errors: [ new Error( syntax_error )] };
583
+ }
584
+
585
+ // We have never been able to trigger `!doc_AST`
586
+
587
+ if ( !doc_AST.feature ) {
588
+ let feature_error = log.warn({ code: `ALK0254`, context: `generating files`, before: `\n\n`, },
589
+ `The author's generator .feature file at "${ generator_path }" needs to include the \`Feature\` keyword.\n`
590
+ );
591
+ return { doc_AST: null, errors: [ new Error( feature_error )] };
592
+ }
593
+
594
+ // Should not get here
595
+ let unknown_error = log.debug({ code: `ALK0255`, context: `generating files`, before: `\n\n`, },
596
+ `The author's generator .feature file at "${ generator_path }" ran into an unknown error when ALKiln tried to parse it.\n`
597
+ );
598
+ return { doc_AST, errors: [ new Error( unknown_error )] };
599
+ }
600
+
601
+
602
+ function validate_syntax({ file_text }) {
603
+ /** Return any errors we get from running the text through the Gherkin parser
604
+ *
605
+ * @param {object} obj - Named arguments
606
+ * @param {str} obj.file_text - Text of the Gherkin file
607
+ *
608
+ * @returns {object} rtn - Named return values
609
+ * @returns {[Error]} rtn.errors - 0 or more Errors.
610
+ * */
611
+ let { errors: generated_parsing_errors } = get_Gherkin_AST({ file_text });
612
+ return { errors: generated_parsing_errors || [] };
613
+ }
614
+
615
+
616
+ function save_files({ tests_path, artifacts_path, file_strings }) {
617
+ /** Save each test file string to an artifacts folder and a folder that
618
+ * cucumber will use to run tests.
619
+ *
620
+ * @param { object } obj - Named arguments
621
+ * @param { str } tests_path - Path where cucumberjs will find these files
622
+ * @param { str } artifacts_path - Artifacts folder path
623
+ * @param { [str] } file_strings - Each string will be a test file
624
+ *
625
+ * @returns { undefined }
626
+ * */
627
+ // Delete the old files no matter what
628
+ delete_previous_tests_from_sources_folders_for_ALKiln_developers({ tests_path });
629
+
630
+ if ( file_strings.length === 0 ) { return; }
631
+
632
+ // Implementation note: This has no error if the folder already exists
633
+ fs.mkdirSync( tests_path, { recursive: true });
634
+ fs.mkdirSync( artifacts_path, { recursive: true });
635
+
636
+ // TODO: For internal testing, create a separate GitHub workflow to test the
637
+ // number of files that were generated
638
+ log.debug({ code: `ALK0250`, context: `generating files`, },
639
+ `Saving ${ file_strings.length } newly generated files in "${ tests_path }"`
640
+ );
641
+
642
+ let count = 1;
643
+ for ( let file_str of file_strings ) {
644
+ let filename = `${ count }_${ Date.now() }.feature`;
645
+ count++;
646
+ // Implementation note: If the files don't exist, this creates them
647
+ fs.appendFileSync( `${ tests_path }/${ filename }`, file_str );
648
+ fs.appendFileSync( `${ artifacts_path }/${ filename }`, file_str );
649
+ }
650
+
651
+ log.debug({ code: `ALK0252`, context: `generating files`, },
652
+ `Saved new generated files:`, fs.readdirSync( tests_path )
653
+ );
654
+
655
+ }
656
+
657
+
658
+ function delete_previous_tests_from_sources_folders_for_ALKiln_developers({ tests_path }) {
659
+ /** Delete previously generated files from this folder if they exist because
660
+ * otherwise those tests will run again too (like for ALKiln devs).
661
+ *
662
+ * Note: All the relevant tests that cucumber will run will be on the paths
663
+ * that come in here, so all *relevant* old files will get deleted, even if
664
+ * there are previous files hanging around somewhere else.
665
+ *
666
+ * Research: How will this folder behave on the Playground? Do we need to
667
+ * clean up at the end too?
668
+ * */
669
+ if ( !fs.existsSync( tests_path )) { return; }
670
+ log.debug({ code: `ALK0251`, context: `generating files`, },
671
+ `Deleting previously generated files:`, fs.readdirSync( tests_path )
672
+ );
673
+ fs.rmSync( tests_path, { recursive: true, force: true });
674
+ }
675
+
676
+
677
+ function get_new_file_contents({ doc_AST={}, file_text=``, generator_path }) {
678
+ /** Return the text for one new file with tests based on one generator file.
679
+ *
680
+ * Note: Right now it is out of scope to save comments in constraints Steps.
681
+ *
682
+ * @param {object} obj - Named arguments
683
+ * @param {object} obj.doc_AST - Gherkin-parsed abstract syntax tree of
684
+ * generator file
685
+ * @param {string} obj.file_text - String of the whole generator file
686
+ * @param {string} obj.generator_path - Path to the generator file
687
+ *
688
+ * @returns {null | string} - The text (Feature, Scenarios, etc) for the new
689
+ * file
690
+ *
691
+ * @returns {object} rtn - Named return values
692
+ * @returns {null | str} rtn.new_contents - `null` or string for the new file
693
+ * @returns {[Error]} rtn.errors - 0 or more Errors.
694
+ * */
695
+
696
+ // `doc_AST.feature.children` seem to always be defined by here
697
+ let scenario_objs = doc_AST.feature.children;
698
+ if ( !scenario_objs[0] ) {
699
+ let syntax_error = log.warn({ code: `ALK0256`, context: `generating files`, before: `\n\n`, },
700
+ `The author's generator .feature file at "${ generator_path }" may be missing its Scenarios.\n`
701
+ );
702
+ return { new_contents: null, errors: [ new Error( syntax_error ) ]};
703
+ }
704
+ // `scenario_objs[0].scenario` seems to always be defined by here
705
+
706
+
707
+ let generation_errors = [];
708
+
709
+ let generator_lines = file_text.split(`\n`);
710
+
711
+ let feature_text = get_Feature_text({
712
+ feature: doc_AST.feature,
713
+ generator_lines
714
+ });
715
+
716
+ let { scenarios_strings, errors: scenarios_errors } = get_Scenarios({
717
+ scenario_objs,
718
+ generator_lines
719
+ });
720
+ generation_errors.push( ...scenarios_errors );
721
+
722
+
723
+ let count_msg = `ALKiln generated ${ scenarios_strings.length } Scenarios`;
724
+
725
+ if ( scenarios_strings.length === 0 ) {
726
+ let error = log.warn({ code: `ALK0246`, context: `generating files`, }, count_msg );
727
+ generation_errors.push( new Error( error ));
728
+ return { new_contents: null, errors: generation_errors };
729
+ }
730
+
731
+ log.info({ code: `ALK0247`, context: `generating files`, }, count_msg );
732
+
733
+ let scenarios_combined = scenarios_strings.join(`\n`);
734
+ let new_file_contents = [ feature_text, scenarios_combined ].join(`\n`);
735
+
736
+ if ( !new_file_contents ) {
737
+ let msg = `Unable to generate a constrained random answers file from "${ generator_path }". See above for warnings and errors.`;
738
+ let error = log.warn({ code: `ALK0248`, context: `generating files`, }, msg );
739
+ return { new_contents: null, errors: [ new Error( error )] };
740
+ }
741
+
742
+ return { new_contents: new_file_contents, errors: generation_errors };
743
+ }
744
+
745
+
746
+ function get_Feature_text({ feature, generator_lines }) {
747
+ /** Return text of Feature section to put at the top of the new file
748
+ *
749
+ * @param {object} obj - Named arguments
750
+ * @param {object} obj.feature - Gherkin AST `feature` object
751
+ * @param {[string]} obj.generator_lines - Text of the generator file as a
752
+ * list
753
+ *
754
+ * @returns {string} - Text of the new file's Feature section
755
+ * */
756
+ add_Feature_randomization_indicators({ generator_lines, feature });
757
+
758
+ let first_scenario = feature.children[0].scenario;
759
+ let first_Scenario_line = get_Scenario_start_line_num({ scenario: first_scenario });
760
+ let first_Scenario_file_index = as_index( first_Scenario_line );
761
+ let index_of_last_line = first_Scenario_file_index - 1;
762
+
763
+ let feature_text = generator_lines.slice(
764
+ 0, index_of_last_line + 1 // `slice` excludes the last index
765
+ ).join(`\n`);
766
+
767
+ return feature_text;
768
+ }
769
+
770
+
771
+ function get_Scenarios({ scenario_objs, generator_lines }) {
772
+ /** Return all the strings of the generated Scenarios and any Errors we ran
773
+ * into.
774
+ *
775
+ * @param {object} obj - Named arguments
776
+ * @param {object} obj.scenario_objs - Gherkin Scenario objects
777
+ * @param {[str]} obj.generator_lines - List of lines in the generator file
778
+ *
779
+ * Starting here, functions' return values should now include lists of warning
780
+ * messages. If a Scenario gets any warnings, we will add a "warnings"
781
+ * Step to that Scenario. Implement over time.
782
+ *
783
+ * @returns {object} rtn - Named return values
784
+ * @returns {[str]} rtn.scenarios - 0 or more Scenario strings
785
+ * @returns {[Error]} rtn.errors - 0 or more Error objects
786
+ * */
787
+ let errs_n_warns = new Misbehaviors();
788
+
789
+ let all_Scenarios_new_strings = [];
790
+ for ( let scenario_obj_i = 0; scenario_obj_i < scenario_objs.length; scenario_obj_i++ ) {
791
+
792
+ errs_n_warns.reset_warnings();
793
+
794
+ /** Start returning warnings here. */
795
+
796
+ // Get all randomized Scenarios from one generator Scenario
797
+ let generator_Scenario = scenario_objs[ scenario_obj_i ].scenario;
798
+
799
+ // Get the values for the various Steps that have to come before any other
800
+ // Steps. Right now, that's only num_to_generate. Premature? Ask Will.
801
+ let {
802
+ num_to_generate,
803
+ warnings: first_Steps_warnings,
804
+ errors: first_Steps_errors
805
+ } = get_first_Steps({ scenario: generator_Scenario });
806
+ errs_n_warns.add({
807
+ warnings: first_Steps_warnings, errors: first_Steps_errors
808
+ });
809
+
810
+ // // Examples of constraints Steps
811
+ // // And I generate 34 tests that get to any of ["end"] when I pick from these constraints:
812
+ // // And I make 1 constrained random test that get to "end1", "end 2", or "end-3" with:
813
+ // let {
814
+ // constraints_Step,
815
+ // warnings: constraints_warnings,
816
+ // errors: constraints_errors
817
+ // } = get_constraints_Step({ generator_Scenario });
818
+ // errs_n_warns.add({ warnings: constraints_warnings, errors: constraints_errors });
819
+ // if ( errs_n_warns.errors.length > 0 ) { continue; }
820
+
821
+ // // let {
822
+ // // num_to_generate,
823
+ // // warnings: num_warnings,
824
+ // // errors: num_errors
825
+ // // } = get_number_of_Scenarios_to_generate({ step_text: constraints_Step.text });
826
+ // // errs_n_warns.add({ warnings: num_warnings, errors: num_errors });
827
+
828
+ // let {
829
+ // ids: final_page_ids,
830
+ // warning: ids_warnings,
831
+ // errors: ids_errors
832
+ // } = get_final_ids({ step_text: constraints_Step.text });
833
+ // errs_n_warns.add({ warnings: ids_warnings, errors: ids_errors });
834
+ // if ( final_page_ids === null ) { continue; }
835
+
836
+ // let {
837
+ // story_tables: generated_story_tables_strs,
838
+ // warnings: story_tables_warnings,
839
+ // errors: story_tables_errors
840
+ // } = get_unique_Story_Tables_strings({
841
+ // num_to_generate,
842
+ // ids: final_page_ids,
843
+ // step: constraints_Step
844
+ // });
845
+ // errs_n_warns.add({ warnings: story_tables_warnings, errors: story_tables_errors });
846
+
847
+ // /** Stop returning warnings here. */
848
+
849
+ // let { warnings_Step, errors: warning_step_errors } = get_warnings_Step({
850
+ // warnings: errs_n_warns.warnings,
851
+ // step_text: constraints_Step.text
852
+ // });
853
+ // errs_n_warns.add({ errors: warning_step_errors });
854
+
855
+ // // The Scenario top and bottom will wrap every generated Story Table for
856
+ // // this generator Scenario
857
+ // let all_scenarios_top = get_pre_constraints({
858
+ // generator_Scenario,
859
+ // constraints_Step,
860
+ // generator_lines,
861
+ // has_warnings: errs_n_warns.warnings.length > 0
862
+ // });
863
+ // let all_scenarios_bottom = get_post_constraints({
864
+ // next_Scenario: (scenario_objs[ scenario_obj_i + 1 ] || {}).scenario,
865
+ // constraints_Step,
866
+ // generator_lines
867
+ // });
868
+
869
+ // let generated_Scenarios_str = get_generated_Scenarios_str({
870
+ // all_scenarios_top,
871
+ // warnings_Step,
872
+ // generator_id: scenario_obj_i,
873
+ // story_tables: generated_story_tables_strs,
874
+ // all_scenarios_bottom
875
+ // });
876
+
877
+ // all_Scenarios_new_strings.push( generated_Scenarios_str );
878
+ } // ends for every scenario_container
879
+
880
+ // return { scenarios_strings: all_Scenarios_new_strings, errors: errs_n_warns.errors }
881
+ return { scenarios_strings: ['Feature: deving it'], errors: [] };
882
+ } // Ends get_Scenarios()
883
+
884
+
885
+ class Misbehaviors {
886
+ /** Accumulates and manages errors and warnings lists. This lets you
887
+ * accumulate errors for a whole function. A loop in that function, though,
888
+ * can build up warnings just for that loop, after which you can reset the
889
+ * warnings list for the next loop. Each loop can add up its own warnings.
890
+ *
891
+ * Example:
892
+ *
893
+ * function do_important_things() {
894
+ * let misfits = new Misbehaviors();
895
+ *
896
+ * let { result, errors } = func();
897
+ * misfits.add({ errors });
898
+ *
899
+ * for ( let item of items ) {
900
+ *
901
+ * misfits.reset_warnings();
902
+ *
903
+ * let { foo, warnings1, errors1 } = func1( item );
904
+ * misfits.add({ warnings: warnings1, errors: errors1 });
905
+ *
906
+ * let { bar, warnings2, errors2 } = func2( item );
907
+ * misfits.add({ warnings: warnings2, errors: errors2 });
908
+ *
909
+ * console.log( misfits.warnings );
910
+ * // Loop 1: [ "oops", "darn" ]
911
+ * // Loop 2: [ "shoot", "zoinks" ]
912
+ * }
913
+ *
914
+ * console.log( misfits.errors );
915
+ * // [ "ahh!", "gorramit", "that's done it" ]
916
+ * }
917
+ * */
918
+ constructor() {
919
+ this.errors = [];
920
+ this.warnings = [];
921
+ }
922
+
923
+ reset_warnings() {
924
+ /** Sets warnings to an empty list. This is useful inside a loop that still
925
+ * needs to keep
926
+ *
927
+ * @returns {undefined}
928
+ * */
929
+ this.warnings = [];
930
+ }
931
+
932
+ add({ errors=[], warnings=[] }) {
933
+ /** Adds errors and/or warnings to their list
934
+ *
935
+ * @param {obj} obj - Named arguments
936
+ * @param {[str]} obj.warnings - (Optional) 0 or more warning messages
937
+ * @param {[Error]} obj.errors - (Optional) 0 or more Error objects
938
+ *
939
+ * @returns {undefined}
940
+ * */
941
+ if ( this.errors ) { this.errors.push( ...errors ); }
942
+ if (this.warnings ) { this.warnings.push( ...warnings ); }
943
+ }
944
+ }
945
+
946
+
947
+ function get_first_Steps({ scenario }) {
948
+ /**
949
+ *
950
+ * Discuss: If a "first" Step appears multiple times, should that be an error
951
+ * or a warning?
952
+ * */
953
+ let misfits = new Misbehaviors();
954
+
955
+ let first_vals = {
956
+ num_to_make: null,
957
+ };
958
+
959
+ // let index_of_first_non_first_Step = null;
960
+ let step_index = -1;
961
+ for ( let step of steps ) {
962
+
963
+ step_index++;
964
+ let all_results = [];
965
+
966
+ /** Number of tests to generate */
967
+ let num_results = get_number_of_Scenarios_to_generate2({
968
+ step,
969
+ prev_num_to_make: first_vals.num_to_make
970
+ });
971
+ misfits.add({ warnings: num_results.warnings, errors: num_results.errors });
972
+ if ( num_results.num_to_make !== null && first_vals.num_to_make !== null ) {
973
+ misfits.add({ warnings: log.warn({ code: `ALK0260`, context: `generating files`, },
974
+ `A generator Scenario has too many Steps that set the number of tests to generate. The Scenario's name:\n${ scenario.name }`
975
+ )
976
+ })
977
+ }
978
+
979
+ first_vals.num_to_make = num_results.num_to_make || first_vals.num_to_make;
980
+ all_results.push( first_vals.num_to_make );
981
+
982
+ /** Other possible first Steps */
983
+ // TBA
984
+
985
+ /** Possibly the end */
986
+ // If none of the "first Step" values were satisfied, stop here
987
+ if ( all_results.every(function ( item ) { return item === null; }) ) {
988
+ break;
989
+ }
990
+
991
+ }
992
+
993
+ return { num_to_make, final_index: step_index, warnings: misfits.warnings, errors: misfits.errors };
994
+ }
995
+
996
+
997
+ // TODO: Consider adding "pick" and "choose" as alternatives for "constrain"
998
+ const CONSTRAIN_REGEX = /constrain/i; // constrained/constraint/constraints
999
+ const RAND_REGEX = /rand/i; // random/randomness/randomized
1000
+ const GENERAT_REGEX = /generat/i; // generate/generates/generated/generating
1001
+ const MAKE_REGEX = /mak|mad/i; // make/makes/made/making
1002
+ const CAPTURE_NUMBERS_REGEX = /(\d+)/g;
1003
+ function get_number_of_Scenarios_to_generate2({ step }) {
1004
+ /** Return null or a constraints Step.
1005
+ *
1006
+ * @param {object} obj - Named arguments
1007
+ * @param {object} obj.step - A valid constraints Step Gherkin object
1008
+ *
1009
+ * @returns {null | object} `null` or a constraints Step
1010
+ * */
1011
+
1012
+ // And ALKiln's generated/new tests can definitely get to one of "" "" with:
1013
+ // And the author promises that the generated tests can get to one of "" "" with:
1014
+
1015
+ // Given ALKiln should make 10 constrained random tests
1016
+ // mak/mad/generat, \d, constrain, rand, test???
1017
+ let user_num = null;
1018
+ let warnings = [];
1019
+ let errors = [];
1020
+
1021
+ // Use `.test()` without `g` flag. See
1022
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test#using_test_on_a_regex_with_the_global_flag
1023
+ let is_num_Step = CONSTRAIN_REGEX.test( step.text )
1024
+ && ( RAND_REGEX.test( step.text )
1025
+ || GENERAT_REGEX.test( step.text )
1026
+ || MAKE_REGEX.test( step.text )
1027
+ );
1028
+
1029
+ let number_matcher = step.text.match( CAPTURE_NUMBERS_REGEX );
1030
+
1031
+ /** No numbers */
1032
+ if ( !is_num_Step || number_matcher === null ) {
1033
+ warnings.push(
1034
+ log.warn({ code: `ALK0237`, context: `generating files`, },
1035
+ `A constrained random answers Step is missing the number of tests to generate. ALKiln will create 1 test. The Step:\n${ step.text }`
1036
+ )
1037
+ );
1038
+ return { num_to_generate: null, warnings, errors };
1039
+ }
1040
+
1041
+ /** Too many numbers */
1042
+ if ( number_matcher && number_matcher.length > 1 ) {
1043
+ // Some feedback indicated using the last number makes more sense to people
1044
+ // than the first
1045
+ user_num = parseInt( number_matcher[ number_matcher.length - 1 ]);
1046
+ warnings.push(
1047
+ log.warn({ code: `ALK0238`, context: `generating files`, },
1048
+ `This constrained random answers Step is ambiguous because it has more than 1 number. ALKiln will use the last number the author gave ("${ user_num }"). The Step:\n${ step.text }`,
1049
+ )
1050
+ );
1051
+ }
1052
+
1053
+ /** Happy path */
1054
+ if ( number_matcher && !user_num ) {
1055
+ user_num = parseInt( number_matcher[ 0 ]);
1056
+ }
1057
+
1058
+ let num_to_generate = Math.min(
1059
+ session_vars.get_max_tests_per_generator_scenario(),
1060
+ user_num
1061
+ );
1062
+
1063
+ if ( num_to_generate !== user_num ) {
1064
+ warnings.push(
1065
+ log.warn({ code: `ALK0253`, context: `generating files`, },
1066
+ `The author asked to generate ${ user_num } tests, but maximum allowed is ${ num_to_generate }. 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.`
1067
+ )
1068
+ );
1069
+ }
1070
+
1071
+ return { num_to_generate, warnings, errors };
1072
+ }
1073
+
1074
+
1075
+ function add_Feature_randomization_indicators({ generator_lines=``, feature={} }) {
1076
+ /** Mutates `generator_lines`. Modifies the tag and name of the Feature section.
1077
+ *
1078
+ * @param {object} obj - Named arguments
1079
+ * @param {object} obj.feature - Gherkin AST `feature` object
1080
+ * @param {[string]} obj.generator_lines - Text of the generator file as a list
1081
+ *
1082
+ * @returns {undefined}
1083
+ * */
1084
+ let feature_index = as_index( feature.location.line );
1085
+
1086
+ let tag_indicator = `@alkiln @generated @randomized @constraints`;
1087
+ let name_indicator = `(generated by ALKiln)`;
1088
+ let new_f_name_line = ``;
1089
+
1090
+ if ( feature.tags.length > 0 ) {
1091
+ // Modify existing tags
1092
+ let tags_index = as_index( feature.tags[0].location.line );
1093
+ let tags_starter = generator_lines[ tags_index ];
1094
+
1095
+ generator_lines[ tags_index ] = `${ tags_starter } ${ tag_indicator }`;
1096
+ new_f_name_line = generator_lines[ feature_index ];
1097
+
1098
+ } else {
1099
+ // If no existing tags, add new tag line
1100
+ new_f_name_line = `${ tag_indicator }\n${ generator_lines[ feature_index ] }`;
1101
+ }
1102
+
1103
+ generator_lines[ feature_index ] = `${ new_f_name_line } ${ name_indicator }`;
1104
+ }
1105
+
1106
+
1107
+ function get_constraints_Step({ generator_Scenario }) {
1108
+ /** Return all the constraint Steps objects in a generator Scenario. Give
1109
+ * warnings if needed. There should only be 1, but we all make mistakes. If
1110
+ * there is an error, return the ALKiln error object.
1111
+ *
1112
+ * @param {object} obj - Named arguments
1113
+ * @param {object} obj.generator_Scenario - A valid Gherkin AST object of one Scenario
1114
+ *
1115
+ * @returns {object} rtn - Named return values
1116
+ * @returns {[object]} rtn.constraints_Steps - 0 or more constraints Steps in
1117
+ * this Scenario.
1118
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
1119
+ * @returns {[Error]} rtn.errors - 0 or more Errors.
1120
+ * */
1121
+ let constraints_Steps = [];
1122
+ let errors = [];
1123
+ let warnings = [];
1124
+
1125
+ for ( let step of generator_Scenario.steps ) {
1126
+ let possible_constraints_Step = get_if_is_constraints_Step({ step });
1127
+ if ( possible_constraints_Step === null ) { continue; }
1128
+
1129
+ constraints_Steps.push( possible_constraints_Step );
1130
+
1131
+ if ( !possible_constraints_Step.dataTable ) {
1132
+
1133
+ let msg = `The **table** of the constrained random answers Step is missing in the Scenario at line ${ generator_Scenario.location.line } - "${ generator_Scenario.name }"`;
1134
+ let error = log.warn({ code: `ALK0239`, context: `generating files`, }, msg );
1135
+ errors.push( new Error( error ));
1136
+
1137
+ } else if ( possible_constraints_Step.dataTable.rows.length === 1 ) {
1138
+
1139
+ let msg = `There is only a header row in the **table** of the constrained random answers Step in the Scenario at line ${ generator_Scenario.location.line } - "${ generator_Scenario.name }"`;
1140
+ let error = log.warn({ code: `ALK0242`, context: `generating files`, }, msg );
1141
+ errors.push( new Error( error ));
1142
+
1143
+ }
1144
+
1145
+ } // ends for every step
1146
+
1147
+ let num_Steps_found = constraints_Steps.length;
1148
+
1149
+ if ( num_Steps_found > 1 ) {
1150
+ let msg = `A generator Scenario can only have 1 constraints Step. The Scenario at line ${ generator_Scenario.location.line } has ${ num_Steps_found } constraints Steps. The Scenario description: "${ generator_Scenario.name }"`
1151
+ let error = log.warn({ code: `ALK0240`, context: `generating files`, }, msg );
1152
+ return { constraints_Step: null, errors: [ ...errors, new Error( error )] };
1153
+ }
1154
+
1155
+ if ( num_Steps_found === 0 ) {
1156
+ // TODO: link to docs for the right syntax
1157
+ let msg = `The generator Scenario is missing a constrained random answers Step. Is there a typo in the Step? The Scenario starts at line ${ generator_Scenario.location.line } - "${ generator_Scenario.name }"`;
1158
+ let error = log.warn({ code: `ALK0241`, context: `generating files`, }, msg );
1159
+ return { constraints_Step: null, errors: [ ...errors, new Error( error )] };
1160
+ }
1161
+
1162
+ return { constraints_Step: constraints_Steps[0], warnings, errors };
1163
+ }
1164
+
1165
+
1166
+ // // Tests to find the step
1167
+ // // TODO: Consider adding "pick" and "choose" as alternatives for "constrain"
1168
+ // const CONSTRAIN_REGEX = /constrain/i; // constrained/constraint/constraints
1169
+ // const RAND_REGEX = /rand/i;
1170
+ // const GENERAT_REGEX = /generat/i; // generate/generates/generated/generating
1171
+ // const MAKE_REGEX = /mak|mad/i; // make/makes/made/making
1172
+ function get_if_is_constraints_Step({ step }) {
1173
+ /** Return null or a constraints Step.
1174
+ *
1175
+ * @param {object} obj - Named arguments
1176
+ * @param {object} obj.step - A valid constraints Step Gherkin object
1177
+ *
1178
+ * @returns {null | object} `null` or a constraints Step
1179
+ * */
1180
+ // Use `.test()` without `g` flag. See
1181
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test#using_test_on_a_regex_with_the_global_flag
1182
+ let is_constraints_step = CONSTRAIN_REGEX.test( step.text )
1183
+ && ( RAND_REGEX.test( step.text )
1184
+ || GENERAT_REGEX.test( step.text )
1185
+ || MAKE_REGEX.test( step.text )
1186
+ );
1187
+ // Example: And I generate 34 tests that get to any of ["end"] when I pick from these constraints:
1188
+ // Example: And I make 1 constrained random test that get to "end1", "end 2", or "end-3" with:
1189
+ if ( is_constraints_step ) {
1190
+ return step;
1191
+ }
1192
+
1193
+ return null;
1194
+ }
1195
+
1196
+
1197
+ function get_unique_Story_Tables_strings({ num_to_generate, ids, step }) {
1198
+ /** Return all the necessary randomized Story Tables as strings. Make sure
1199
+ * they are unique.
1200
+ *
1201
+ * @param {object} obj - Named arguments
1202
+ * @param {int} obj.num_to_generate Number of Story tables to generate
1203
+ * @param {[string]} obj.ids The question ids that any of the Story Tables can
1204
+ * reach
1205
+ * @parm {object} obj.step A valid constraints Step Gherkin object
1206
+ *
1207
+ * @returns {object} rtn - Named return values
1208
+ * @returns {[str]} rtn.constraints_Steps - 0 or more Story Tables (see docs).
1209
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
1210
+ * @returns {[Error]} rtn.errors - 0 or more Errors.
1211
+ * */
1212
+ let story_tables = [];
1213
+ let warnings = [];
1214
+ let errors = [];
1215
+
1216
+ // Each new Story Table Step starts the same way
1217
+ let step_indent = get_authors_indent({ located: step });
1218
+ let table_start = `${ step_indent }And I get to any of the question ids [${ ids.join(`, `) }] with this data:`;
1219
+
1220
+ for ( let table_i = 0; table_i < num_to_generate; table_i++ ) {
1221
+
1222
+ let num_attempts = 0;
1223
+ let max_attempts = 10;
1224
+ let is_unique = false;
1225
+
1226
+ while ( !is_unique && num_attempts < max_attempts ) {
1227
+
1228
+ let story_table = get_random_Story_Table({
1229
+ table_start,
1230
+ rows: step.dataTable.rows
1231
+ });
1232
+
1233
+ if ( story_tables.includes( story_table )) {
1234
+ num_attempts += 1;
1235
+ continue;
1236
+ }
1237
+
1238
+ is_unique = true;
1239
+ story_tables.push( story_table );
1240
+
1241
+ } // ends while table is not unique
1242
+ } // ends for number of tests
1243
+
1244
+ // // Discuss: Warn of missing table?
1245
+ // if ( story_tables.length === 0 ) {}
1246
+
1247
+ if ( story_tables.length < num_to_generate ) {
1248
+ // Discuss: Debug log or warning to developer? If the latter, need to make it pretty
1249
+ warnings.push(
1250
+ log.warn({ code: `ALK0243`, context: `generating files`, },
1251
+ `ALKiln only managed to make ${ story_tables.length } unique tests out of the ${ num_to_generate } asked for given the options:`,
1252
+ get_flatter_Step({ step })
1253
+ )
1254
+ );
1255
+ }
1256
+
1257
+ return { story_tables, warnings, errors };
1258
+ };
1259
+
1260
+
1261
+ function get_random_Story_Table({ table_start, rows }) {
1262
+ /** Return a randomized Story Table string
1263
+ *
1264
+ * @param {object} obj - Named arguments
1265
+ * @param {string} obj.table_start - The start of the Story Table Step
1266
+ * @param {object} obj.rows - Gherkin AST of a `.dataTable.rows`
1267
+ *
1268
+ * @returns {string} - String of the whole Story Table Step
1269
+ * */
1270
+ let story_table_parts = [ table_start ];
1271
+
1272
+ for ( let row_i = 0; row_i < rows.length; row_i++ ) {
1273
+
1274
+ let row = rows[ row_i ];
1275
+
1276
+ if ( row_i === 0 ) {
1277
+ story_table_parts.push( get_table_header({ row }) );
1278
+ continue;
1279
+ }
1280
+
1281
+ story_table_parts.push( get_random_table_row({ row }) );
1282
+
1283
+ }
1284
+
1285
+ let story_table = story_table_parts.join(`\n`);
1286
+ return story_table;
1287
+ }
1288
+
1289
+
1290
+ function get_flatter_Step({ step }) {
1291
+ /** Given a Gherkin `step` object, returns an object that is a less nested
1292
+ * version of the `step` object.
1293
+ *
1294
+ * Yes, this is annoying to test, but it's a fairly simple way to show enough
1295
+ * info about the Step to be useful.
1296
+ *
1297
+ * Discuss: Should this go in the Log class?
1298
+ *
1299
+ * @param {object} obj - Named arguments
1300
+ * @param {object} obj.step - Valid Gherkin `step` object
1301
+ *
1302
+ * @returns {{ text, rows: [[str1, str2], ...] }} - Flatter version of `step`
1303
+ * */
1304
+ let simple = { text: step.text, rows: [] };
1305
+ for ( let row of step.dataTable.rows ) {
1306
+ let cells = [];
1307
+ for ( let cell of row.cells ) {
1308
+ cells.push( cell.value );
1309
+ }
1310
+ simple.rows.push( cells );
1311
+ }
1312
+ return simple;
1313
+ }
1314
+
1315
+
1316
+ function get_table_header({ row }) {
1317
+ /** Return the string for the Story Table header row. 2 or 3 column tables are
1318
+ * allowed.
1319
+ *
1320
+ * Discuss: Warn about wrong number of columns?
1321
+ *
1322
+ * @param {object} obj - Named arguments
1323
+ * @param {object} obj.row Valid Gherkin dataTable.row object
1324
+ *
1325
+ * @returns {string} A markdown table row with 2 or 3 columns
1326
+ * */
1327
+ let indent = get_authors_indent({ located: row });
1328
+ let num_cols = row.cells.length;
1329
+ let header = `${ indent }| var | value |`;
1330
+ if ( num_cols >= 3 ) { header += ` trigger |`; }
1331
+ return header;
1332
+ };
1333
+
1334
+
1335
+ function get_random_table_row({ row }) {
1336
+ /** Return the string of one Story Table row with a randomly chosen answer.
1337
+ *
1338
+ * @param {object} obj - Named arguments
1339
+ * @param {object} obj.row - Valid Gherkin dataTable.row object
1340
+ *
1341
+ * @returns {string} A markdown table row with 2 or 3 columns
1342
+ * */
1343
+ let indent = get_authors_indent({ located: row });
1344
+ let cells = row.cells;
1345
+ let num_cols = cells.length;
1346
+
1347
+ let answer = pick_random_answer({ answers_str: cells[1].value });
1348
+
1349
+ let row_str = `${ indent }| ${ cells[0].value } | ${ answer } |`;
1350
+ if ( cells.length >= 3 ) { row_str += ` ${ cells[2].value } |`; }
1351
+ return row_str;
1352
+ };
1353
+
1354
+
1355
+ // Make a placeholder where we will make the scenario unique
1356
+ let ID_PLACEHOLDER = `ALKILN_SCENARIO_RANDOM_ID_PLACEHOLDER`;
1357
+ function get_generated_Scenarios_str({
1358
+ all_scenarios_top,
1359
+ warnings_Step = null,
1360
+ generator_id,
1361
+ story_tables,
1362
+ all_scenarios_bottom
1363
+ }) {
1364
+ /** Return the string for the combined Scenarios.
1365
+ *
1366
+ * @param {object} obj - Named arguments
1367
+ * @param {string} obj.all_scenarios_top - Scenario text that comes before the
1368
+ * constraints Step
1369
+ * @param {string} obj.warnings_Step - Optional. Default is `null`. Step to
1370
+ * show the author a warning message.
1371
+ * @param {int | string} obj.generator_id - ID of the generating Scenario
1372
+ * @param {[string]} obj.story_tables - Story Table text for every Scenario
1373
+ * that we need to generate
1374
+ * @param {string} obj.all_scenarios_bottom - Scenario text that comes after the
1375
+ * constraints Step
1376
+ *
1377
+ * @returns {[string]} - List of complete Scenario strings
1378
+ * */
1379
+ let scenarios = [];
1380
+ for ( let table_i = 0; table_i < story_tables.length; table_i++ ) {
1381
+
1382
+ let generator_num = generator_id + 1;
1383
+ let scenario_num = table_i + 1;
1384
+ let total_scenarios = story_tables.length;
1385
+ let scenario_tag = `generator${ generator_num }_scenario${ scenario_num }_of_${ total_scenarios }`;
1386
+ let with_tag = all_scenarios_top.replaceAll( ID_PLACEHOLDER, scenario_tag );
1387
+ let random_top = with_tag.replaceAll( SCENARIO_COUNT_PLACEHOLDER, `${ scenario_num } of ${ total_scenarios }` );
1388
+
1389
+ let story_table = story_tables[ table_i ];
1390
+
1391
+ let scenario_items = [ random_top ];
1392
+ if ( warnings_Step ) { scenario_items.push( warnings_Step ); }
1393
+ scenario_items.push( story_table, all_scenarios_bottom );
1394
+
1395
+ let scenario_str = scenario_items.join(`\n`);
1396
+ scenarios.push( scenario_str );
1397
+ }
1398
+
1399
+ let this_Scenarios_strings = scenarios.join(`\n`);
1400
+ return this_Scenarios_strings;
1401
+ };
1402
+
1403
+
1404
+ let SCENARIO_COUNT_PLACEHOLDER = `ALKILN_GENEREATED_SCENARIO_COUNT_PLACEHOLDER`;
1405
+ function get_pre_constraints({
1406
+ generator_Scenario,
1407
+ constraints_Step,
1408
+ generator_lines,
1409
+ has_warnings
1410
+ }) {
1411
+ /** Return the Scenario text that comes before the first constraints Step.
1412
+ *
1413
+ * @param {object} obj - Named arguments
1414
+ * @param {object} obj.generator_Scenario - AST of the Scenario
1415
+ * @param {object} obj.constraints_Step - AST of the constraints Step
1416
+ * Step
1417
+ * @param {[string]} obj.generator_lines - List of lines from the generator
1418
+ * file
1419
+ * @param {boolean} obj.has_warnings - `true` if generating the Scenario
1420
+ * created warnings
1421
+ *
1422
+ * @returns {string} - Scenario text that comes before the first constraints
1423
+ * Step
1424
+ * */
1425
+ // Use a shorter name as there are a lot of long lines of code
1426
+ let lines = generator_lines;
1427
+ set_tags_and_description({ scenario: generator_Scenario, lines, has_warnings });
1428
+ let start_line_num = get_Scenario_start_line_num({ scenario: generator_Scenario });
1429
+ let top_start_index = as_index( start_line_num );
1430
+
1431
+ let top_end_index = get_index_before({ line_num: constraints_Step.location.line });
1432
+
1433
+ // Turn the list of lines for the pre-constraints section of the Scenario into
1434
+ // a text block
1435
+ let scenario_top_text = lines.slice(
1436
+ top_start_index,
1437
+ top_end_index + 1 // `slice` excludes the terminal number
1438
+ ).join(`\n`);
1439
+
1440
+ return scenario_top_text;
1441
+ };
1442
+
1443
+
1444
+ function set_tags_and_description({ scenario, lines, has_warnings }) {
1445
+ /** Mutate the file lines to add placeholders for scenario text that will
1446
+ * show it is a generated test and give it unique identifiers.
1447
+ *
1448
+ *
1449
+ * @param {object} obj - Named arguments
1450
+ * @param {object} obj.scenario - AST of a Gherkin scenario
1451
+ * @param {object} obj.lines - File text lines to mutate with the new text
1452
+ * @param {object} obj.has_warnings - `true` if the Scenario creation caused
1453
+ * warning messages
1454
+ *
1455
+ * @returns {undefined}
1456
+ * */
1457
+
1458
+ /**
1459
+ * Add ALKiln tags by mutating the tag line if it exists. If not, mutate the
1460
+ * keyword/description line by adding ALKiln's tags above the existing
1461
+ * text.
1462
+ * */
1463
+ let pre_description = ``;
1464
+
1465
+ let alkiln_tags = [ `@${ ID_PLACEHOLDER }` ];
1466
+ if ( has_warnings ) { alkiln_tags.push(`@alkiln_warning`); }
1467
+
1468
+ if ( scenario.tags.length > 0 ) {
1469
+ // Mutate the tags line to add new tags
1470
+ let tags_index = as_index( scenario.tags[0].location.line );
1471
+ let new_tags = [ lines[ tags_index ], ...alkiln_tags ];
1472
+ lines[ tags_index ] = new_tags.join(` `);
1473
+
1474
+ } else {
1475
+ // Add an artificial new line with alkiln's tags
1476
+ pre_description = `${ alkiln_tags.join(" ")}\n`;
1477
+ }
1478
+
1479
+ let description_index = as_index( scenario.location.line );
1480
+ let new_description = `${ lines[ description_index ] } (${ SCENARIO_COUNT_PLACEHOLDER })`;
1481
+ // Potentially mutate the new_description line
1482
+ lines[ description_index ] = `${ pre_description }${ new_description }`;
1483
+ };
1484
+
1485
+
1486
+ function get_post_constraints({ next_Scenario, constraints_Step, generator_lines }) {
1487
+ /** Return the Scenario text that comes after the last constraints Step.
1488
+ *
1489
+ * @param {object} obj - Named arguments
1490
+ * @param {object | undefined} obj.next_Scenario - AST of the Scenario after this Scenario
1491
+ * @param {object} obj.constraints_Step - AST of the constraints Step
1492
+ * @param {[string]} obj.generator_lines - List of lines from the generator file
1493
+ *
1494
+ * @returns {string} - Scenario text that comes after the last constraints
1495
+ * Step
1496
+ * */
1497
+ let rows = constraints_Step.dataTable.rows;
1498
+ let constraints_end_index = as_index( rows[ rows.length - 1].location.line );
1499
+ let bottom_start_index = constraints_end_index + 1;
1500
+
1501
+ if ( !next_Scenario ) {
1502
+ // Get lines till the end of the file
1503
+ let scenario_bottom_text = generator_lines.slice(
1504
+ bottom_start_index
1505
+ ).join(`\n`);
1506
+ return scenario_bottom_text;
1507
+ }
1508
+
1509
+ // Get lines till the start of the next Scenario
1510
+ let next_Scenario_start_line = get_Scenario_start_line_num({ scenario: next_Scenario });
1511
+ let bottom_end_index = get_index_before({ line_num: next_Scenario_start_line });
1512
+
1513
+ let scenario_bottom_text = generator_lines.slice(
1514
+ bottom_start_index,
1515
+ bottom_end_index + 1 // `slice` excludes the terminal number
1516
+ ).join(`\n`);
1517
+
1518
+ return scenario_bottom_text;
1519
+ };
1520
+
1521
+
1522
+ function get_index_before({ line_num }) {
1523
+ /** Return the index in the file line list that corresponds to the given
1524
+ * Gherkin 1-indexed line number. This is mostly here as a name that
1525
+ * clarifies what the code is doing and hides a little bit of the
1526
+ * complexity.
1527
+ *
1528
+ * Discuss: is get_index_before() worth abstracting?
1529
+ *
1530
+ * Each object in the Gherkin AST will have a `location` object that includes
1531
+ * the item's line number (`{line: 1}`). Each line number represents a line
1532
+ * in the file and therefore an item in the `generator_lines` list. The line
1533
+ * numbers are 1-indexed. That means the location of `{line: 1}` in the AST
1534
+ * refers to `generator_lines[0]`.
1535
+ *
1536
+ * @param {object} obj - Named arguments
1537
+ * @param {int} obj.line_num - Integer of a Gherkin 1-indexed line number
1538
+ *
1539
+ * @returns {int} - File-line-list index that corresponds to the Gherkin line
1540
+ * number
1541
+ * */
1542
+ let line_as_index = as_index( line_num );
1543
+ return line_as_index - 1;
1544
+ };
1545
+
1546
+
1547
+ // function get_index_after({ line_num }) {};
1548
+
1549
+
1550
+ function get_authors_indent({ located }) {
1551
+ /** Return a whitespace string representing the indentation for a given
1552
+ * Gherkin location object.
1553
+ *
1554
+ * @param {object} obj - Named arguments
1555
+ * @param {object} obj.located - Gherkin AST object with a location property
1556
+ *
1557
+ * @returns {string} - A whitespace string representing the indentation for a
1558
+ * given Gherkin location object.
1559
+ * */
1560
+ if ( typeof located !== 'object' || !located.location ) {
1561
+ return ``;
1562
+ }
1563
+ return ` `.repeat( located.location.column - 1 );
1564
+ };
1565
+
1566
+
1567
+ // const CAPTURE_NUMBERS_REGEX = /(\d+)/g;
1568
+ function get_number_of_Scenarios_to_generate({ step_text }) {
1569
+ /** Return the number of random constraints tests to generate with a minimum
1570
+ * of 1 test.
1571
+ *
1572
+ * Gives warnings instead of errors, defaulting to returning `1`.
1573
+ *
1574
+ * @param {object} obj - Named arguments
1575
+ * @param {string} obj.step_text - The text of a constraints Step
1576
+ *
1577
+ * @returns {object} rtn - Named return values
1578
+ * @returns {int} rtn.num_to_generate - Number of tests to generate. Min is 1.
1579
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
1580
+ * @returns {[Error]} rtn.errors - 0 or more Errors.
1581
+ * */
1582
+ let user_num = null;
1583
+ let warnings = [];
1584
+ let errors = [];
1585
+ let number_matcher = step_text.match( CAPTURE_NUMBERS_REGEX );
1586
+
1587
+ /** No numbers */
1588
+ if ( number_matcher === null ) {
1589
+ warnings.push(
1590
+ log.warn({ code: `ALK0237`, context: `generating files`, },
1591
+ `A constrained random answers Step is missing the number of tests to generate. ALKiln will create 1 test. The Step:\n${ step_text }`
1592
+ )
1593
+ );
1594
+ user_num = 1;
1595
+ }
1596
+
1597
+ /** Too many numbers */
1598
+ if ( number_matcher && number_matcher.length > 1 ) {
1599
+ // Some feedback indicated using the last number makes more sense to people
1600
+ // than the first
1601
+ user_num = parseInt( number_matcher[ number_matcher.length - 1 ]);
1602
+ warnings.push(
1603
+ log.warn({ code: `ALK0238`, context: `generating files`, },
1604
+ `This constrained random answers Step is ambiguous because it has more than 1 number. ALKiln will use the last number the author gave ("${ user_num }"). The Step:\n${ step_text }`,
1605
+ )
1606
+ );
1607
+
1608
+ }
1609
+
1610
+ /** Happy path */
1611
+ if ( number_matcher && !user_num ) {
1612
+ user_num = parseInt( number_matcher[ 0 ]);
1613
+ }
1614
+
1615
+ let num_to_generate = Math.min(
1616
+ session_vars.get_max_tests_per_generator_scenario(),
1617
+ user_num
1618
+ );
1619
+
1620
+ if ( num_to_generate !== user_num ) {
1621
+ warnings.push(
1622
+ log.warn({ code: `ALK0253`, context: `generating files`, },
1623
+ `The author asked to generate ${ user_num } tests, but maximum allowed is ${ num_to_generate }. 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.`
1624
+ )
1625
+ );
1626
+ }
1627
+
1628
+ return { num_to_generate, warnings, errors };
1629
+ }
1630
+
1631
+
1632
+ function get_warnings_Step({ warnings = [], step_text }) {
1633
+ /** Returns the text for any warnings that need to be added to the Scenario
1634
+ *
1635
+ * @param {object} obj - Named arguments
1636
+ * @param {[string]} obj.warnings - 0 or more warning messages
1637
+ * @param {string} obj.step_text - Constraints Step text (to match indent)
1638
+ *
1639
+ * @returns {object} rtn - Named return values
1640
+ * @returns {str} rtn.warnings_Step - cucumberjs Step to show author a warning
1641
+ * @returns {[Error]} rtn.errors - 0 or more Errors.
1642
+ * */
1643
+ let errors = [];
1644
+
1645
+ if ( warnings.length === 0 ) { return { warnings_Step: null, errors }; }
1646
+
1647
+ let indent_match = step_text.match(/$\s*/);
1648
+ let indent = ( indent_match && indent_match[ 0 ]) || ` `;
1649
+
1650
+ let new_warnings_lines = [];
1651
+ new_warnings_lines.push(`${ indent }Then ALKiln warns the author about a generator problem with:`);
1652
+ new_warnings_lines.push(`${ indent } """`, `${ indent } ----- Generator file warning -----`);
1653
+
1654
+ for ( let warning of warnings ) {
1655
+ let warning_lines = warning.split(`\n`);
1656
+ for ( let warning_line of warning_lines ) {
1657
+ new_warnings_lines.push(`${ indent } ${ warning_line }`);
1658
+ }
1659
+ }
1660
+
1661
+ new_warnings_lines.push(`${ indent } -----`, `${ indent } """`);
1662
+
1663
+ let warnings_Step = new_warnings_lines.join(`\n`);
1664
+
1665
+ return { warnings_Step, errors };
1666
+ }
1667
+
1668
+
1669
+ let CAPTURE_QUESTION_IDS_REGEX = /("[^"]+")/g;
1670
+ function get_final_ids({ step_text }) {
1671
+ /** Return a list of docassemble question block ids (in double quotes) that
1672
+ * are in an ALKiln constraints Step. Every generated Story Table Step must
1673
+ * reach one of these ids.
1674
+ *
1675
+ * @param {object} obj - Named arguments
1676
+ * @param {string} obj.step_text - The text of a constraints Step
1677
+ *
1678
+ * Example:
1679
+ * get_final_ids({ step_text: `And I make 1 constrained random test that gets to "end1" or "end 2" with:` });
1680
+ * // [`"end1"`, `"end2"`]
1681
+ *
1682
+ * @returns {object} rtn - Named return values
1683
+ * @returns {["str"]} rtn.ids - 0 or more double-quoted docassemble question
1684
+ * block ids. Each generated Story Table Step must end at one of these
1685
+ * ids. this Scenario.
1686
+ * @returns {[str]} rtn.warnings - 0 or more warning messages.
1687
+ * @returns {[Error]} rtn.errors - 0 or more Errors.
1688
+ * */
1689
+ let errors = [];
1690
+ let warnings = [];
1691
+
1692
+ let ids = step_text.match( CAPTURE_QUESTION_IDS_REGEX );
1693
+
1694
+ if ( ids === null ) {
1695
+ 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 }`;
1696
+ let error = log.warn({ code: `ALK0245`, context: `generating files`, },
1697
+ missing_ids_msg
1698
+ );
1699
+ errors.push( new Error( error ));
1700
+ }
1701
+
1702
+ return { ids, warnings, errors };
1703
+ }
1704
+
1705
+
1706
+ function pick_random_answer({ answers_str=`` }) {
1707
+ /** Return one of the answers from the given string of possible answers.
1708
+ *
1709
+ * @param {object} obj - Named arguments
1710
+ * @param {str} obj.answers_str - A string representing one or more possible
1711
+ * answers. The possible answers are separated by this character
1712
+ * combination: `;;`
1713
+ *
1714
+ * Example:
1715
+ * pick_random_answer({ answers_str: `yes;; no;; maybe` });
1716
+ * // `no`
1717
+ *
1718
+ * TODO: Add a warning in the docs that Gherkin ignores whitespace at the
1719
+ * start and end of columns in a table. Discuss letting authors wrap
1720
+ * content in backticks or some other delimiter.
1721
+ *
1722
+ * @returns {string} - One of the possible answers
1723
+ * */
1724
+ let choices = answers_str.replace(`;; `, `;;`).split(`;;`);
1725
+ if ( choices.includes(``) ) {
1726
+ log.debug({ code: `ALK0244`, context: `generating files`, level: `warn` },
1727
+ `**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 }"`
1728
+ );
1729
+ }
1730
+ let choice_index = Math.floor( Math.random() * choices.length );
1731
+ return choices[ choice_index ];
1732
+ }
1733
+
1734
+
1735
+ function get_row_as_string({ row }) {
1736
+ /** Return the given Gherkin row as a single markdown table row */
1737
+ let cells = row.cells;
1738
+ let values = cells.map(( cell ) => { return cell.value; });
1739
+ let row_str = `| ${ values.join( ` | ` ) } |`;
1740
+ return row_str;
1741
+ }
1742
+
1743
+
1744
+ function get_Scenario_start_line_num({ scenario }) {
1745
+ /** Returns the Gherkin line number where the Scenario really starts. That
1746
+ * could be where the tags are or where the Scenario keyword is.
1747
+ *
1748
+ * @param {object} obj - Named arguments
1749
+ * @param {object} obj.scenario - The Gherkin AST for a Scenario
1750
+ *
1751
+ * @returns {int} - Gherkin line number where the Scenario starts
1752
+ * */
1753
+ if ( scenario.tags.length > 0 ) {
1754
+ return scenario.tags[0].location.line;
1755
+ } else {
1756
+ return scenario.location.line;
1757
+ }
1758
+ }
1759
+
1760
+
1761
+ function as_index( line_num ) {
1762
+ /** Given a Gherkin line number, return the file-lines-list index for that
1763
+ * line number. This is here mostly as a name that helps clarify what the
1764
+ * code is doing.
1765
+ *
1766
+ * Each Gherkin line number matches a line in the file and therefore an item
1767
+ * in the `generator_lines` list. The Gherkin line numbers are 1-indexed. That
1768
+ * means the location of `{line: 1}` in the AST refers to `generator_lines[0]`.
1769
+ *
1770
+ * @param {int} line_num - A 1-indexed Gherkin line number
1771
+ *
1772
+ * @returns {int} - The corresponding index in the list of lines in the
1773
+ * generator file
1774
+ * */
1775
+ return line_num - 1;
1776
+ }