cukable 0.1.1

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,305 @@
1
+ # cuke.rb
2
+
3
+ # FIXME: This is a hack to support running cucumber features.
4
+ # May have unwanted side-effects.
5
+ $:.unshift File.join(File.dirname(__FILE__), '..')
6
+
7
+ require 'json'
8
+ require 'fileutils'
9
+ require 'diff/lcs/array'
10
+
11
+ require 'cukable/helper'
12
+ require 'cukable/conversion'
13
+
14
+ module Cukable
15
+
16
+ # Exception raised when a table is not in the expected format
17
+ class FormatError < Exception
18
+ end
19
+
20
+ # This fixture allows running Cucumber from FitNesse via rubyslim.
21
+ class Cuke
22
+
23
+ include Cukable::Helper
24
+ include Cukable::Conversion
25
+
26
+ # Hash mapping the MD5 digest of a feature to the .json output for that
27
+ # feature. This tells `do_table` whether a feature has already been
28
+ # run by the accelerator, and prevents it from being run again.
29
+ @@output_files = Hash.new
30
+
31
+ # Last-run FitNesse suite. Indicates whether a higher-level accelerator
32
+ # has already run. For example, if `HelloWorld.AaaAccelerator` has already
33
+ # run, then there's no need to run `HelloWorld.SuiteOne.AaaAccelerator` or
34
+ # `HelloWorld.SuiteTwo.AaaAccelerator`.
35
+ @@last_suite_name = nil
36
+
37
+
38
+ # Create the fixture, with optional Cucumber command-line arguments.
39
+ #
40
+ # @param [String] cucumber_args
41
+ # If this constructor is called from a `Cuke` table, then
42
+ # these arguments will be passed to Cucumber for that single
43
+ # test. Otherwise, the `cucumber_args` passed to `accelerate`
44
+ # take precedence.
45
+ #
46
+ def initialize(cucumber_args='')
47
+ # Directory where temporary .feature files will be written
48
+ @features_dir = File.join('features', 'fitnesse')
49
+ # Directory where JSON output files will be written by Cucumber
50
+ @output_dir = 'slim_results'
51
+ # Cucumber command-line arguments
52
+ @cucumber_args = cucumber_args
53
+ end
54
+
55
+
56
+ # Perform a batch-run of all features found in siblings of the given test
57
+ # in the FitNesse wiki tree. This only takes effect if `test_name` ends
58
+ # with `AaaAccelerator`; otherwise, nothing happens. The test results for
59
+ # each feature are stored in `@@output_files`, indexed by a hash of the
60
+ # feature contents; the individual tests in the suite will then invoke
61
+ # `do_table`, which looks up the test results in `@@output_files`.
62
+ #
63
+ # @param [String] test_name
64
+ # Dotted name of the test page in FitNesse. For example,
65
+ # `'HelloWorld.AaaAccelerator'`
66
+ # @param [String] cucumber_args
67
+ # Command-line arguments to pass to Cucumber for this run.
68
+ # Affects all tests in the suite.
69
+ #
70
+ def accelerate(test_name, cucumber_args='')
71
+ # Remove wiki cruft from the test_path
72
+ test_name = remove_cruft(test_name)
73
+ @cucumber_args = cucumber_args
74
+
75
+ # Don't run the accelerator unless we're on a page called AaaAccelerator
76
+ if !(test_name =~ /^.*AaaAccelerator$/)
77
+ return true
78
+ else
79
+ # Get the suite path (everything up to the last '.')
80
+ parts = test_name.split('.')
81
+ suite_path = parts[0..-2].join('/')
82
+ end
83
+
84
+ # If a higher-level accelerator has already run, skip this run
85
+ if @@last_suite_name != nil && suite_path =~ /^#{@@last_suite_name}/
86
+ return true
87
+ # Otherwise, this is the top-level accelerator in the suite
88
+ else
89
+ @@last_suite_name = suite_path
90
+ end
91
+
92
+ # Find the suite in the FitNesseRoot
93
+ suite = "FitNesseRoot/" + suite_path
94
+
95
+ # Delete and recreate @features_dir and @output_dir
96
+ # FIXME: May need to be smarter about this--what happens if
97
+ # two people are running different suites at the same time?
98
+ # The same suite at the same time?
99
+ [@features_dir, @output_dir].each do |dir|
100
+ FileUtils.rm_rf(dir)
101
+ FileUtils.mkdir(dir)
102
+ end
103
+
104
+ # Reset the digest-to-json map, then fill it in with the
105
+ # digest and .json output file of each feature that ran
106
+ @@output_files = Hash.new
107
+
108
+ # Write all .feature files and run Cucumber on them
109
+ feature_filenames = write_suite_features(suite)
110
+ run_cucumber(feature_filenames, @output_dir)
111
+
112
+ # Parse the results out over their sources.
113
+ return true # Wait for someone to test one of the same tables.
114
+ end
115
+
116
+
117
+ # Write `.feature` files for all scenarios found in `suite`,
118
+ # and return an array of all `.feature` filenames.
119
+ def write_suite_features(suite)
120
+ # For all FitNesse content files in the suite
121
+ feature_filenames = []
122
+
123
+ Dir.glob(File.join(suite, '**', 'content.txt')).each do |fitnesse_filename|
124
+ feature = clean_filename(fitnesse_filename, suite, 'content.txt')
125
+ number = 0
126
+ # For all feature tables in the content file
127
+ fitnesse_to_features(File.open(fitnesse_filename)).each do |table|
128
+ # Write the table to a .feature file with a unique name
129
+ feature_filename = File.join(
130
+ @features_dir, "#{feature}_#{number}.feature")
131
+ feature_filenames << feature_filename
132
+ begin
133
+ write_feature(table, feature_filename)
134
+ rescue FormatError => err
135
+ puts "!!!! Error writing #{feature_filename}:"
136
+ puts err.message
137
+ puts err.backtrace[0..5].join("\n")
138
+ puts ".... Continuing anyway."
139
+ end
140
+
141
+ # Store the JSON filename in the digest hash
142
+ digest = table_digest(table)
143
+ json_filename = File.join(@output_dir, "#{feature_filename}.json")
144
+ @@output_files[digest] = json_filename
145
+ end
146
+ end
147
+ return feature_filenames
148
+ end
149
+
150
+
151
+ # Execute a single test in a FitNesse TableTable.
152
+ #
153
+ # @param [Array] table
154
+ # Rows in the TableTable, where each row is an Array of strings
155
+ # containing cell contents.
156
+ #
157
+ # @return [Array]
158
+ # Same structure and number of rows as the input table, but with standard
159
+ # SliM status indicators and additional HTML formatting for displaying
160
+ # the test results in place of the original table.
161
+ #
162
+ def do_table(table)
163
+ # If the digest of this table already exists in @output files,
164
+ # simply return the results that were already generated.
165
+ existing = @@output_files[table_digest(table)]
166
+ if existing
167
+ results = existing
168
+ # Otherwise, run Cucumber from scratch on this table,
169
+ # and return the results
170
+ else
171
+ # FIXME: Move this to a separate method?
172
+ # Create @features_dir if it doesn't exist
173
+ FileUtils.mkdir(@features_dir) unless File.directory?(@features_dir)
174
+ feature_filename = File.join(@features_dir, 'fitnesse_test.feature')
175
+ # Create the feature file, run cucumber, return results
176
+ write_feature(table, feature_filename)
177
+ run_cucumber([feature_filename], @output_dir)
178
+ results = File.join(@output_dir, "#{feature_filename}.json")
179
+ end
180
+
181
+ # If the results file exists, parse it, merge with the original table,
182
+ # and return the results
183
+ if File.exist?(results)
184
+ json = JSON.load(File.open(results))
185
+ merged = merge_table_with_results(table, json)
186
+ return merged
187
+ # Otherwise, return an 'ignore' for all rows/cells in the table
188
+ else
189
+ return table.collect { |row| row.collect { |cell| 'ignore' } }
190
+ end
191
+ end
192
+
193
+
194
+ # Merge the original input table with the actual results, and
195
+ # return a new table that puts the results section in the correct place,
196
+ # with all other original rows marked as ignored.
197
+ #
198
+ # @param [Array] input_table
199
+ # The original TableTable content as it came from FitNesse
200
+ # @param [Array] json_results
201
+ # JSON results returned from Cucumber, via the SlimJSON formatter
202
+ #
203
+ # @return [Array]
204
+ # Original table with JSON results merged into the corresponding
205
+ # rows, and all other rows (if any) marked as ignored.
206
+ #
207
+ def merge_table_with_results(input_table, json_results)
208
+ final_results = []
209
+ # Strip extra stuff from the results to get the original line
210
+ clean_results = json_results.collect do |row|
211
+ row.collect do |cell|
212
+ clean_cell(cell)
213
+ end
214
+ end
215
+ # Perform a context-diff
216
+ input_table.sdiff(clean_results).each do |diff|
217
+ # If this row was in the input table, but not in the results,
218
+ # output it as an ignored row
219
+ if diff.action == '-'
220
+ # Ignore all cells in the row
221
+ final_results << input_table[diff.old_position].collect do |cell|
222
+ "ignore:#{cell}"
223
+ end
224
+ # In all other cases, output the row from json_results
225
+ else # '=', '+', '!'
226
+ final_results << json_results[diff.new_position]
227
+ end
228
+ end
229
+ return final_results
230
+ end
231
+
232
+
233
+ # Write a Cucumber `.feature` file containing the lines of Gherkin text
234
+ # found in `table`.
235
+ #
236
+ # @param [Array] table
237
+ # TableTable content as it came from FitNesse
238
+ # @param [String] feature_filename
239
+ # Name of `.feature` file to write the converted Cucumber feature to
240
+ #
241
+ def write_feature(table, feature_filename)
242
+ # Have 'Feature:' or 'Scenario:' been found in the input?
243
+ got_feature = false
244
+ got_scenario = false
245
+
246
+ FileUtils.mkdir(@features_dir) unless File.directory?(@features_dir)
247
+ file = File.open(feature_filename, 'w')
248
+
249
+ # Error if there is not exactly one "Feature" row
250
+ features = table.select { |row| row.first =~ /^\s*Feature:/ }
251
+ if features.count != 1
252
+ raise FormatError, "Table needs exactly one 'Feature:' row."
253
+ end
254
+
255
+ # Error if there are no "Scenario" or "Scenario Outline" rows
256
+ scenarios = table.select { |row| row.first =~ /^\s*Scenario( Outline)?:/ }
257
+ if scenarios.count < 1:
258
+ raise FormatError, "Table needs at least one 'Scenario:' or 'Scenario Outline:' row."
259
+ end
260
+
261
+ # Write all other lines from the table
262
+ table.each do |row|
263
+ # If this row starts with an empty cell, output remaining cells
264
+ # as a |-delimited table
265
+ if row.first.strip == ""
266
+ file.puts " | " + unescape(row[1..-1].join(" | ")) + " |"
267
+ # For all other rows, output all cells joined by spaces
268
+ else
269
+ # Replace &lt; and &gt; so scenario outlines will work
270
+ line = unescape(row.join(" "))
271
+ file.puts line
272
+ end
273
+ end
274
+
275
+ file.close
276
+ end
277
+
278
+
279
+ # Run cucumber on `feature_filenames`, and output
280
+ # results in FitNesse table format to `output_dir`.
281
+ #
282
+ # @param [Array] feature_filenames
283
+ # All `.feature` files to execute
284
+ # @param [String] output_dir
285
+ # Where to save the SlimJSON-formatted results
286
+ #
287
+ def run_cucumber(feature_filenames, output_dir)
288
+ req = "--require /home/eric/git/cukable/lib/"
289
+ format = "--format Cucumber::Formatter::SlimJSON"
290
+ output = "--out #{output_dir}"
291
+ args = @cucumber_args
292
+ features = feature_filenames.join(" ")
293
+
294
+ #puts "cucumber #{req} #{format} #{output} #{args} #{features}"
295
+ system "cucumber #{req} #{format} #{output} #{args} #{features}"
296
+
297
+ # TODO: Ensure that the correct number of output files were written
298
+ #if !File.exist?(@results_filename)
299
+ #raise "Cucumber failed to write '#{@results_filename}'"
300
+ #end
301
+ end
302
+
303
+ end
304
+ end
305
+
@@ -0,0 +1,221 @@
1
+ # Helper functions for Cukable
2
+
3
+ require 'cgi'
4
+ require 'digest/md5'
5
+
6
+ module Cukable
7
+
8
+ # Common/shared methods supporting the Cukable library
9
+ module Helper
10
+
11
+ # Return `filename` with `prefix` and `suffix` removed, and any
12
+ # path-separators converted to underscores.
13
+ #
14
+ # @example
15
+ # clean_filename('abc/some/path/xyz', 'abc', 'xyz')
16
+ # #=> 'some_path'
17
+ #
18
+ # @param [String] filename
19
+ # Filename to clean
20
+ # @param [String] prefix
21
+ # Leading text to remove
22
+ # @param [String] suffix
23
+ # Trailing text to remove
24
+ #
25
+ # @return [String]
26
+ # Cleaned filename with prefix and suffix removed
27
+ #
28
+ def clean_filename(filename, prefix, suffix)
29
+ middle = filename.gsub(/^#{prefix}\/(.+)\/#{suffix}$/, '\1')
30
+ return middle.gsub('/', '_')
31
+ end
32
+
33
+
34
+ # Remove FitNesse-generated link cruft from a string. Strips `<a ...></a>`
35
+ # tags, keeping the inner content unless that content is '[?]'.
36
+ #
37
+ # @example
38
+ # remove_cruft('Go to <a href="SomePage">this page</a>')
39
+ # #=> 'Go to this page'
40
+ # remove_cruft('See SomePage<a href="SomePage">[?]</a>')
41
+ # #=> 'See SomePage'
42
+ #
43
+ # @param [String] string
44
+ # The string to remove link-cruft from
45
+ #
46
+ # @return [String]
47
+ # Same string with `<a ...></a>` and `[?]` removed.
48
+ #
49
+ def remove_cruft(string)
50
+ string.gsub(/<a [^>]*>([^<]*)<\/a>/, '\1').gsub('[?]', '')
51
+ end
52
+
53
+
54
+ # Wikify (CamelCase) the given string, removing spaces, underscores, dashes
55
+ # and periods, and CamelCasing the remaining words. If this does not result
56
+ # in a CamelCase word with at least two words in it (that is, if the input was
57
+ # only a single word), then the last letter in the word is capitalized so
58
+ # as to make FitNesse happy.
59
+ #
60
+ # @example
61
+ # wikify('file.extension') #=> 'FileExtension'
62
+ # wikify('with_underscore') #=> 'WithUnderscore'
63
+ # wikify('having spaces') #=> 'HavingSpaces'
64
+ # wikify('foo') #=> 'FoO'
65
+ #
66
+ # @param [String] string
67
+ # String to wikify
68
+ #
69
+ # @return [String]
70
+ # Wikified string
71
+ #
72
+ # FIXME: This will not generate valid FitNesse wiki page names for
73
+ # pathological cases, such as any input that would result in consecutive
74
+ # capital letters, including words having only two letters in them.
75
+ #
76
+ def wikify(string)
77
+ string.gsub!(/^[a-z]|[_.\s\-]+[a-z]/) { |a| a.upcase }
78
+ string.gsub!(/[_.\s\-]/, '')
79
+ if string =~ /(([A-Z][a-z]*){2})/
80
+ return string
81
+ else
82
+ return string.gsub(/.\b/) { |c| c.upcase }
83
+ end
84
+ end
85
+
86
+
87
+ # Return the given string with any CamelCase words, email addresses, and
88
+ # URLs escaped with FitNesse's `!-...-!` string-literal markup.
89
+ #
90
+ # @example
91
+ # literalize('With a CamelCase word')
92
+ # #=> 'With a !-CamelCase-! word'
93
+ #
94
+ # @param [String] string
95
+ # String to escape CamelCase words in
96
+ #
97
+ # @return [String]
98
+ # Same string with CamelCase words escaped
99
+ #
100
+ # FIXME: Literals inside other literals will cause too much escaping!
101
+ def literalize(string)
102
+ result = string.strip
103
+
104
+ # Literalize email addresses
105
+ # FitNesse pattern for email addresses, per TextMaker.java:
106
+ # [\w\-_.]+@[\w\-_.]+\.[\w\-_.]+
107
+ result.gsub!(/([\w\-_.]+@[\w\-_.]+\.[\w\-_.]+)/, '!-\1-!')
108
+
109
+
110
+ # Literalize CamelCase words
111
+ # Regex for matching wiki words, according to FitNesse.UserGuide.WikiWord
112
+ # \b[A-Z](?:[a-z0-9]+[A-Z][a-z0-9]*)+
113
+ result.gsub!(/(\b[A-Z](?:[a-z0-9]+[A-Z][a-z0-9]*)+)/, '!-\1-!')
114
+
115
+ # Literalize URLs
116
+ # Brain-dead URL matcher, should do the trick in most cases though
117
+ # (Better to literalize too much than not enough)
118
+ result.gsub!(/(http[^ ]+)/, '!-\1-!')
119
+
120
+ return result
121
+ end
122
+
123
+
124
+ # Wikify the given path name, and return a path that's suitable
125
+ # for use as a FitNesse wiki page path. Any path component having only
126
+ # a single word in it will have the last letter in that word capitalized.
127
+ #
128
+ # @example
129
+ # wikify_path('features/account/create.feature')
130
+ # #=> 'FeatureS/AccounT/CreateFeature'
131
+ #
132
+ # @param [String] path
133
+ # Arbitrary path name to convert
134
+ #
135
+ # @return [String]
136
+ # New path with each component being a WikiWord
137
+ #
138
+ def wikify_path(path)
139
+ wiki_parts = path.split(File::SEPARATOR).collect do |part|
140
+ wikify(part)
141
+ end
142
+ return File.join(wiki_parts)
143
+ end
144
+
145
+
146
+ # Return an MD5 digest string for `table`. Any HTML entities and FitNesse
147
+ # markup in the table is unescaped before the digest is calculated.
148
+ #
149
+ # @example
150
+ # table_digest(['foo'], ['bar'])
151
+ # #=> '3858f62230ac3c915f300c664312c63f'
152
+ # table_digest(['foo', 'baz'])
153
+ # #=> '80338e79d2ca9b9c090ebaaa2ef293c7'
154
+ #
155
+ # @param [Array] table
156
+ # Array of strings, or nested Array of strings
157
+ #
158
+ # @return [String]
159
+ # Accumulated MD5 digest of all strings in `table`
160
+ #
161
+ def table_digest(table)
162
+ digest = Digest::MD5.new
163
+ table.flatten.each do |cell|
164
+ digest.update(unescape(cell))
165
+ end
166
+ return digest.to_s
167
+ end
168
+
169
+
170
+ # Unescape any HTML entities and FitNesse markup in the given string.
171
+ #
172
+ # @example
173
+ # unescape('Some &lt;stuff&gt; to !-unescape-!')
174
+ # #=> 'Some <stuff> to unescape'
175
+ #
176
+ # @param [String] string
177
+ # The string to unescape HTML entities in
178
+ #
179
+ # @return [String]
180
+ # Original string with HTML entities unescaped, and FitNesse `!-...-!`
181
+ # markup removed.
182
+ #
183
+ def unescape(string)
184
+ result = CGI.unescapeHTML(string)
185
+ result.gsub!(/!-(.*?)-!/, '\1')
186
+ return result
187
+ end
188
+
189
+
190
+ # Return the given string, cleaned of any HTML tags and status indicators
191
+ # added by the JSON output formatter. The intent here is to take a table
192
+ # cell from JSON output, and make it match the original FitNesse table
193
+ # cell.
194
+ #
195
+ # @example
196
+ # clean_cell('pass:Given some <b>bold text</b>')
197
+ # #=> 'Given some bold text'
198
+ # clean_cell('pass:Given a step <br>with line break')
199
+ # #=> 'Given a step'
200
+ #
201
+ # @param [String] string
202
+ # String to clean
203
+ #
204
+ # @return [String]
205
+ # String with any SlimJSON-added stuff removed.
206
+ #
207
+ def clean_cell(string)
208
+ # FIXME: This may not be terribly efficient...
209
+ # strip first to get a copy of the string
210
+ result = string.strip
211
+ # Remove extra stuff added by JSON formatter
212
+ result.gsub!(/^[^:]*:(.*)$/, '\1') # status indicator
213
+ result.gsub!(/<b>|<\/b>/, '') # all bold tags
214
+ result.gsub!(/<br\/?>.*/, '') # <br> and anything that follows
215
+ result.gsub!(/<span[^>]*>.*<\/span>/, '') # spans and their content
216
+ result.gsub!(/\(Undefined Step\)/, '') # (Undefined Step)
217
+ return result.strip
218
+ end
219
+ end
220
+ end
221
+