cukable 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+