cuke_cataloger 1.0.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.
@@ -0,0 +1,555 @@
1
+ module CukeCataloger
2
+ class UniqueTestCaseTagger
3
+
4
+ SUB_ID_PATTERN = /^\d+\-\d+$/
5
+ SUB_ID_MATCH_PATTERN = /^\d+\-(\d+)$/
6
+
7
+
8
+ attr_accessor :tag_location
9
+
10
+
11
+ def initialize
12
+ @file_line_increases = Hash.new(0)
13
+ @tag_location = :adjacent
14
+ end
15
+
16
+ def tag_tests(feature_directory, tag_prefix, explicit_indexes = {})
17
+ warn("This script will potentially rewrite all of your feature files. Please be patient and remember to tip your source control system.")
18
+
19
+ @known_id_tags = {}
20
+
21
+ set_id_tag(tag_prefix)
22
+ set_test_suite_model(feature_directory)
23
+
24
+ @start_indexes = merge_indexes(default_start_indexes(determine_known_ids(feature_directory, tag_prefix)), explicit_indexes)
25
+ @next_index = @start_indexes[:primary]
26
+
27
+ # Analysis and output
28
+ @tests.each do |test|
29
+ case
30
+ when test.is_a?(CukeModeler::Scenario)
31
+ process_scenario(test)
32
+ when test.is_a?(CukeModeler::Outline)
33
+ process_outline(test)
34
+ else
35
+ raise("Unknown test type: #{test.class.to_s}")
36
+ end
37
+ end
38
+ end
39
+
40
+ def scan_for_tagged_tests(feature_directory, tag_prefix)
41
+ @results = []
42
+ @known_id_tags = {}
43
+
44
+ set_id_tag(tag_prefix)
45
+ set_test_suite_model(feature_directory)
46
+
47
+ @tests.each do |test|
48
+ add_to_results(test) if has_id_tag?(test)
49
+
50
+ if test.is_a?(CukeModeler::Outline)
51
+ test.examples.each do |example|
52
+ if has_id_parameter?(example)
53
+ example_rows_for(example).each do |row|
54
+ add_to_results(row) if has_row_id?(row)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ @results
62
+ end
63
+
64
+ def validate_test_ids(feature_directory, tag_prefix)
65
+ @results = []
66
+ @known_id_tags = {}
67
+
68
+ set_id_tag(tag_prefix)
69
+ set_test_suite_model(feature_directory)
70
+
71
+ @features.each { |feature| validate_feature(feature) }
72
+ @tests.each { |test| validate_test(test) }
73
+
74
+ @results
75
+ end
76
+
77
+ def determine_known_ids(feature_directory, tag_prefix)
78
+ known_ids = []
79
+
80
+ found_tagged_objects = scan_for_tagged_tests(feature_directory, tag_prefix).collect { |result| result[:object] }
81
+
82
+ found_tagged_objects.each do |element|
83
+ if element.is_a?(CukeModeler::Row)
84
+ row_id = row_id_for(element)
85
+ known_ids << row_id if well_formed_sub_id?(row_id)
86
+ else
87
+ known_ids << test_id_for(element)
88
+ end
89
+ end
90
+
91
+ known_ids
92
+ end
93
+
94
+
95
+ private
96
+
97
+
98
+ def set_id_tag(tag_prefix)
99
+ @tag_prefix = tag_prefix
100
+ #todo -should probably escape these characters
101
+ @tag_pattern = Regexp.new("#{@tag_prefix}\\d+")
102
+ end
103
+
104
+ def set_test_suite_model(feature_directory)
105
+ @directory = CukeModeler::Directory.new(feature_directory)
106
+ @model_repo = CQL::Repository.new(@directory)
107
+
108
+ @tests = @model_repo.query do
109
+ select :self
110
+ from scenarios, outlines
111
+ end.collect { |result| result[:self] }
112
+
113
+ @features = @model_repo.query do
114
+ select :self
115
+ from features
116
+ end.collect { |result| result[:self] }
117
+ end
118
+
119
+ def validate_feature(feature)
120
+ check_for_feature_level_test_tag(feature)
121
+ end
122
+
123
+ def validate_test(test)
124
+ check_for_missing_test_tag(test)
125
+ check_for_multiple_test_id_tags(test)
126
+ check_for_duplicated_test_id_tags(test)
127
+
128
+ if test.is_a?(CukeModeler::Outline)
129
+ check_for_missing_id_columns(test)
130
+ check_for_missing_row_tags(test)
131
+ check_for_duplicated_row_tags(test)
132
+ check_for_mismatched_row_tags(test)
133
+ check_for_malformed_row_tags(test)
134
+ end
135
+ end
136
+
137
+ def check_for_feature_level_test_tag(feature)
138
+ add_to_results(feature, :feature_test_tag) if has_id_tag?(feature)
139
+ end
140
+
141
+ def check_for_duplicated_test_id_tags(test)
142
+ @existing_tags ||= @model_repo.query do
143
+ select tags
144
+ from features, scenarios, outlines, examples
145
+ end.collect { |result| result['tags'] }.flatten
146
+
147
+ test_id_tag = static_id_tag_for(test)
148
+
149
+ matching_tags = @existing_tags.select { |tag| tag == test_id_tag }
150
+
151
+ add_to_results(test, :duplicate_id_tag) if matching_tags.count > 1
152
+ end
153
+
154
+ def check_for_multiple_test_id_tags(test)
155
+ id_tags_found = test.tags.select { |tag| tag =~ @tag_pattern }
156
+
157
+ add_to_results(test, :multiple_tags) if id_tags_found.count > 1
158
+ end
159
+
160
+ def check_for_missing_test_tag(test)
161
+ add_to_results(test, :missing_tag) unless has_id_tag?(test)
162
+ end
163
+
164
+ def check_for_missing_id_columns(test)
165
+ test.examples.each do |example|
166
+ add_to_results(example, :missing_id_column) unless has_id_column?(example)
167
+ end
168
+ end
169
+
170
+ def check_for_duplicated_row_tags(test)
171
+ validate_rows(test, :duplicate_row_id, false, :has_duplicate_row_id?)
172
+ end
173
+
174
+ def check_for_missing_row_tags(test)
175
+ validate_rows(test, :missing_row_id, true, :has_row_id?)
176
+ end
177
+
178
+ def check_for_mismatched_row_tags(test)
179
+ validate_rows(test, :mismatched_row_id, true, :has_matching_id?)
180
+ end
181
+
182
+ def check_for_malformed_row_tags(test)
183
+ test.examples.each do |example|
184
+ if has_id_column?(example)
185
+ example_rows_for(example).each do |row|
186
+ add_to_results(row, :malformed_sub_id) if (has_row_id?(row) && !well_formed_sub_id?(row_id_for(row)))
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ def validate_rows(test, rule, desired, row_check)
193
+ test.examples.each do |example|
194
+ if has_id_column?(example)
195
+ example_rows_for(example).each do |row|
196
+ if desired
197
+ add_to_results(row, rule) unless self.send(row_check, row)
198
+ else
199
+ add_to_results(row, rule) if self.send(row_check, row)
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ def process_scenario(test)
207
+ apply_tag_if_needed(test)
208
+ end
209
+
210
+ def process_outline(test)
211
+ apply_tag_if_needed(test)
212
+ update_parameters_if_needed(test)
213
+ update_rows_if_needed(test, determine_next_sub_id(test))
214
+ end
215
+
216
+ def apply_tag_if_needed(test)
217
+ unless has_id_tag?(test)
218
+ tag = "#{@tag_prefix}#{@next_index}"
219
+ @next_index += 1
220
+
221
+ tag_test(test, tag, (' ' * determine_test_indentation(test)))
222
+ end
223
+ end
224
+
225
+ def has_id_tag?(test)
226
+ !!fast_id_tag_for(test)
227
+ end
228
+
229
+ def has_id_column?(example)
230
+ example.parameters.any? { |param| param =~ /test_case_id/ }
231
+ end
232
+
233
+ def row_id_for(row)
234
+ id_index = determine_row_id_cell_index(row)
235
+
236
+ id_index && row.cells[id_index] != '' ? row.cells[id_index] : nil
237
+ end
238
+
239
+ def has_row_id?(row)
240
+ !!row_id_for(row)
241
+ end
242
+
243
+ def well_formed_sub_id?(id)
244
+ !!(id =~ SUB_ID_PATTERN)
245
+ end
246
+
247
+ def has_matching_id?(row)
248
+ row_id = row_id_for(row)
249
+
250
+ # A lack of id counts as 'matching'
251
+ return true if row_id.nil?
252
+
253
+ parent_tag = static_id_tag_for(row.get_ancestor(:test))
254
+
255
+ if parent_tag
256
+ parent_id = parent_tag.sub(@tag_prefix, '')
257
+
258
+ row_id =~ /#{parent_id}-/
259
+ else
260
+ row_id.nil?
261
+ end
262
+ end
263
+
264
+ def has_duplicate_row_id?(row)
265
+ row_id = row_id_for(row)
266
+
267
+ return false unless row_id && well_formed_sub_id?(row_id)
268
+
269
+ existing_ids = determine_used_sub_ids(row.get_ancestor(:test))
270
+ matching_ids = existing_ids.select { |id| id == row_id[/\d+$/] }
271
+
272
+ matching_ids.count > 1
273
+ end
274
+
275
+ def determine_next_sub_id(test)
276
+ parent = test_id_for(test)
277
+ explicit_index = @start_indexes[:sub][parent]
278
+
279
+ explicit_index ? explicit_index : 1
280
+ end
281
+
282
+ def determine_used_sub_ids(test)
283
+ ids = test.examples.collect do |example|
284
+ if has_id_parameter?(example)
285
+ example_rows_for(example).collect do |row|
286
+ row_id_for(row)
287
+ end
288
+ else
289
+ []
290
+ end
291
+ end
292
+
293
+ ids.flatten!
294
+ ids.delete_if { |id| !id.to_s.match(SUB_ID_PATTERN) }
295
+
296
+ ids.collect! { |id| id.match(SUB_ID_MATCH_PATTERN)[1] }
297
+
298
+ ids
299
+ end
300
+
301
+ def determine_row_id_cell_index(row)
302
+ row.get_ancestor(:example).parameters.index { |param| param =~ /test_case_id/ }
303
+ end
304
+
305
+ def tag_test(test, tag, padding_string = ' ')
306
+ feature_file = test.get_ancestor(:feature_file)
307
+ file_path = feature_file.path
308
+
309
+ index_adjustment = @file_line_increases[file_path]
310
+ tag_index = source_line_to_use(test) + index_adjustment
311
+
312
+ file_lines = File.readlines(file_path)
313
+ file_lines.insert(tag_index, "#{padding_string}#{tag}\n")
314
+
315
+ File.open(file_path, 'w') { |file| file.print file_lines.join }
316
+ @file_line_increases[file_path] += 1
317
+ test.tags << tag
318
+ end
319
+
320
+ def update_parameters_if_needed(test)
321
+ feature_file = test.get_ancestor(:feature_file)
322
+ file_path = feature_file.path
323
+ index_adjustment = @file_line_increases[file_path]
324
+
325
+ test.examples.each do |example|
326
+ unless has_id_parameter?(example)
327
+ parameter_line_index = (example.row_elements.first.source_line - 1) + index_adjustment
328
+
329
+ file_lines = File.readlines(file_path)
330
+
331
+ new_parameter = 'test_case_id'.ljust(parameter_spacing(example))
332
+ update_parameter_row(file_lines, parameter_line_index, new_parameter)
333
+ File.open(file_path, 'w') { |file| file.print file_lines.join }
334
+ end
335
+ end
336
+ end
337
+
338
+ def update_rows_if_needed(test, sub_id)
339
+ feature_file = test.get_ancestor(:feature_file)
340
+ file_path = feature_file.path
341
+ index_adjustment = @file_line_increases[file_path]
342
+
343
+ tag_index = fast_id_tag_for(test)[/\d+/]
344
+
345
+ file_lines = File.readlines(file_path)
346
+
347
+ test.examples.each do |example|
348
+ example.row_elements[1..(example.row_elements.count - 1)].each do |row|
349
+ unless has_row_id?(row)
350
+ row_id = "#{tag_index}-#{sub_id}".ljust(parameter_spacing(example))
351
+
352
+ row_line_index = (row.source_line - 1) + index_adjustment
353
+
354
+ update_value_row(file_lines, row_line_index, row, row_id)
355
+ sub_id += 1
356
+ end
357
+ end
358
+
359
+ File.open(file_path, 'w') { |file| file.print file_lines.join }
360
+ end
361
+ end
362
+
363
+
364
+ # Slowest way to get the id tag. Will check the object every time.
365
+ def current_id_tag_for(thing)
366
+ id_tag_for(thing)
367
+ end
368
+
369
+ # Faster way to get the id tag. Will skip checking the object if an id for it is already known.
370
+ def fast_id_tag_for(thing)
371
+ @known_id_tags ||= {}
372
+
373
+ id = @known_id_tags[thing.object_id]
374
+
375
+ unless id
376
+ id = current_id_tag_for(thing)
377
+ @known_id_tags[thing.object_id] = id
378
+ end
379
+
380
+ id
381
+ end
382
+
383
+ # Fastest way to get the id tag. Will skip checking the object if it has been checked before, even if no id was found.
384
+ def static_id_tag_for(thing)
385
+ @known_id_tags ||= {}
386
+ id_key = thing.object_id
387
+
388
+ return @known_id_tags[id_key] if @known_id_tags.has_key?(id_key)
389
+
390
+ id = current_id_tag_for(thing)
391
+ @known_id_tags[id_key] = id
392
+
393
+ id
394
+ end
395
+
396
+ def id_tag_for(thing)
397
+ thing.tags.select { |tag| tag =~ @tag_pattern }.first
398
+ end
399
+
400
+ def test_id_for(test)
401
+ #todo - should probably be escaping these in case regex characters used in prefix...
402
+ fast_id_tag_for(test).match(/#{@tag_prefix}(.*)/)[1]
403
+ end
404
+
405
+ def has_id_parameter?(example)
406
+ #todo - make the id column name configurable
407
+ example.parameters.any? { |parameter| parameter == 'test_case_id' }
408
+ end
409
+
410
+ def update_parameter_row(file_lines, line_index, parameter)
411
+ append_row!(file_lines, line_index, " #{parameter} |")
412
+ end
413
+
414
+ def update_value_row(file_lines, line_index, row, row_id)
415
+ case
416
+ when needs_adding?(row)
417
+ append_row!(file_lines, line_index, " #{row_id} |")
418
+ when needs_filled_in?(row)
419
+ fill_in_row(file_lines, line_index, row, row_id)
420
+ else
421
+ raise("Don't know how to update row")
422
+ end
423
+ end
424
+
425
+ def needs_adding?(row)
426
+ !has_id_parameter?(row.get_ancestor(:example))
427
+ end
428
+
429
+ def needs_filled_in?(row)
430
+ has_id_parameter?(row.get_ancestor(:example))
431
+ end
432
+
433
+ def replace_row!(file_lines, line_index, new_line)
434
+ file_lines[line_index] = new_line
435
+ end
436
+
437
+ def prepend_row!(file_lines, line_index, string)
438
+ old_row = file_lines[line_index]
439
+ new_row = string + old_row.lstrip
440
+ file_lines[line_index] = new_row
441
+ end
442
+
443
+ def append_row!(file_lines, line_index, string)
444
+ old_row = file_lines[line_index]
445
+ trailing_bits = old_row[/\s*$/]
446
+ new_row = old_row.rstrip + string + trailing_bits
447
+
448
+ file_lines[line_index] = new_row
449
+ end
450
+
451
+ def example_rows_for(example)
452
+ rows = example.row_elements.dup
453
+ rows.shift
454
+
455
+ rows
456
+ end
457
+
458
+ def add_to_results(item, issue = nil)
459
+ result = {:test => "#{item.get_ancestor(:feature_file).path}:#{item.source_line}", :object => item}
460
+ result.merge!({:problem => issue}) if issue
461
+
462
+ @results << result
463
+ end
464
+
465
+ def default_start_indexes(known_ids)
466
+ primary_ids = known_ids.select { |id| id =~ /^\d+$/ }
467
+ sub_ids = known_ids.select { |id| id =~ /^\d+-\d+$/ }
468
+
469
+ max_primary_id = primary_ids.collect { |id| id.to_i }.max || 0
470
+ default_indexes = {:primary => max_primary_id + 1,
471
+ :sub => {}}
472
+
473
+ sub_primaries = sub_ids.collect { |sub_id| sub_id[/^\d+/] }
474
+
475
+ sub_primaries.each do |primary|
476
+ default_indexes[:sub][primary] = sub_ids.select { |sub_id| sub_id[/^\d+/] == primary }.collect { |sub_id| sub_id[/\d+$/].to_i }.max + 1
477
+ end
478
+
479
+ default_indexes
480
+ end
481
+
482
+ def merge_indexes(set1, set2)
483
+ set1.merge(set2) { |key, set1_value, set2_value|
484
+ key == :sub ? set1_value.merge(set2_value) : set2_value
485
+ }
486
+ end
487
+
488
+ def parameter_spacing(example)
489
+ test = example.get_ancestor(:test)
490
+ test_id = fast_id_tag_for(test)[/\d+$/]
491
+ row_count = test.examples.reduce(0) { |sum, example| sum += example.rows.count }
492
+
493
+ max_id_length = test_id.length + 1 + row_count.to_s.length
494
+ param_length = 'test_case_id'.length
495
+
496
+ [param_length, max_id_length].max
497
+ end
498
+
499
+ def determine_test_indentation(test)
500
+ #todo - replace with 'get_most_recent_file_text'
501
+ feature_file = test.get_ancestor(:feature_file)
502
+ file_path = feature_file.path
503
+
504
+ index_adjustment = @file_line_increases[file_path]
505
+ test_index = (test.source_line - 1) + index_adjustment
506
+
507
+ file_lines = File.readlines(file_path)
508
+ indentation = file_lines[test_index][/^\s*/].length
509
+
510
+ indentation
511
+ end
512
+
513
+ def fill_in_row(file_lines, line_index, row, row_id)
514
+ old_row = file_lines[line_index]
515
+ sections = file_lines[line_index].split('|', -1)
516
+
517
+ replacement_index = determine_row_id_cell_index(row)
518
+ sections[replacement_index + 1] = " #{row_id} "
519
+
520
+ new_row = sections.join('|')
521
+
522
+ replace_row!(file_lines, line_index, new_row)
523
+ end
524
+
525
+ def source_line_to_use(test)
526
+ case @tag_location
527
+ when :above
528
+ determine_highest_tag_line(test)
529
+ when :below
530
+ determine_lowest_tag_line(test)
531
+ when :adjacent
532
+ adjacent_tag_line(test)
533
+ else
534
+ raise(ArgumentError, "Don't know where #{@tag_location} is.")
535
+ end
536
+ end
537
+
538
+ def determine_highest_tag_line(test)
539
+ return adjacent_tag_line(test) if test.tags.empty?
540
+
541
+ test.tag_elements.collect { |tag_element| tag_element.source_line }.min - 1
542
+ end
543
+
544
+ def determine_lowest_tag_line(test)
545
+ return adjacent_tag_line(test) if test.tags.empty?
546
+
547
+ test.tag_elements.collect { |tag_element| tag_element.source_line }.max
548
+ end
549
+
550
+ def adjacent_tag_line(test)
551
+ (test.source_line - 1)
552
+ end
553
+
554
+ end
555
+ end
@@ -0,0 +1,3 @@
1
+ module CukeCataloger
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,60 @@
1
+ require 'rake'
2
+ require 'cuke_modeler'
3
+ require 'cql'
4
+
5
+ require 'extensions/cucumber_analytics_extensions'
6
+ require 'cuke_cataloger/version'
7
+ require 'cuke_cataloger/unique_test_case_tagger'
8
+
9
+ module CukeCataloger
10
+
11
+ extend Rake::DSL
12
+
13
+
14
+ def self.create_tasks
15
+
16
+ desc 'Add unique id tags to tests in the given directory'
17
+ task 'tag_tests', [:directory, :prefix] do |t, args|
18
+ puts "Tagging tests in '#{args[:directory]}' with tag '#{args[:prefix]}'\n"
19
+
20
+ tagger = CukeCataloger::UniqueTestCaseTagger.new
21
+ tagger.tag_tests(args[:directory], args[:prefix])
22
+ end
23
+
24
+ desc 'Scan tests in the given directory for id problems'
25
+ task 'validate_tests', [:directory, :prefix, :out_file] do |t, args|
26
+ puts "Validating tests in '#{args[:directory]}' with tag '#{args[:prefix]}'\n"
27
+
28
+ results = CukeCataloger::UniqueTestCaseTagger.new.validate_test_ids(args[:directory], args[:prefix])
29
+ report_text = "Validation Results\nProblems found: #{results.count}\n\n"
30
+
31
+
32
+ results_by_category = Hash.new { |hash, key| hash[key] = [] }
33
+
34
+ results.each do |result|
35
+ results_by_category[result[:problem]] << result
36
+ end
37
+
38
+ results_by_category.keys.each do |problem_category|
39
+ report_text << "#{problem_category} problems: #{results_by_category[problem_category].count}\n"
40
+ end
41
+
42
+ results_by_category.keys.each do |problem_category|
43
+ report_text << "\n\n#{problem_category} problems (#{results_by_category[problem_category].count}):\n"
44
+
45
+ results_by_category[problem_category].each do |result|
46
+ report_text << "#{result[:test]}\n"
47
+ end
48
+ end
49
+
50
+ if args[:out_file]
51
+ puts "Problems found: #{results.count}"
52
+ File.write(args[:out_file], report_text)
53
+ else
54
+ puts report_text
55
+ end
56
+ end
57
+
58
+ end
59
+
60
+ end
@@ -0,0 +1,44 @@
1
+ module CukeModeler
2
+ module Parsing
3
+
4
+ class << self
5
+
6
+ def parse_text(source_text, file_name = nil)
7
+ raise(ArgumentError, "Cannot parse #{source_text.class} objects. Strings only.") unless source_text.is_a?(String)
8
+
9
+ file_name ||= 'fake_file.txt'
10
+
11
+ io = StringIO.new
12
+ formatter = Gherkin::Formatter::JSONFormatter.new(io)
13
+ parser = Gherkin::Parser::Parser.new(formatter)
14
+ begin
15
+ parser.parse(source_text, file_name, 0)
16
+
17
+ formatter.done
18
+ rescue => e
19
+ raise("Error encountered while parsing file #{file_name}: #{e.message}")
20
+ end
21
+ MultiJson.load(io.string)
22
+ end
23
+
24
+ end
25
+
26
+ end
27
+ end
28
+
29
+
30
+ module CukeModeler
31
+ class FeatureFile
32
+
33
+
34
+ private
35
+
36
+
37
+ def parse_file(file_to_parse)
38
+ source_text = IO.read(file_to_parse)
39
+
40
+ Parsing::parse_text(source_text, file_to_parse)
41
+ end
42
+
43
+ end
44
+ end
@@ -0,0 +1,24 @@
1
+ unless RUBY_VERSION.to_s < '1.9.0'
2
+ require 'simplecov'
3
+ SimpleCov.command_name('cuke_cataloger-cucumber')
4
+ end
5
+
6
+
7
+ require 'cuke_cataloger'
8
+
9
+
10
+ RSpec.configure do |config|
11
+ config.before(:all) do
12
+ here = File.dirname(__FILE__)
13
+ @default_file_directory = "#{here}/temp_files"
14
+ @default_test_file_directory = "#{here}/test_files"
15
+ end
16
+
17
+ config.before(:each) do
18
+ FileUtils.mkpath(@default_file_directory)
19
+ end
20
+
21
+ config.after(:each) do
22
+ FileUtils.remove_dir(@default_file_directory, true)
23
+ end
24
+ end