@suffolklitlab/alkiln 5.13.2 → 5.13.3-feat-random

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