butternut 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +22 -0
- data/LICENSE +20 -0
- data/README.rdoc +18 -0
- data/Rakefile +53 -0
- data/VERSION +1 -0
- data/butternut.gemspec +79 -0
- data/lib/butternut/cucumber.css +112 -0
- data/lib/butternut/cucumber.sass +152 -0
- data/lib/butternut/formatter.rb +465 -0
- data/lib/butternut/helpers.rb +51 -0
- data/lib/butternut/scenario_extensions.rb +7 -0
- data/lib/butternut.rb +32 -0
- data/spec/butternut/formatter_spec.rb +288 -0
- data/spec/butternut/helpers_spec.rb +128 -0
- data/spec/butternut_spec.rb +53 -0
- data/spec/fixtures/foo.css +3 -0
- data/spec/fixtures/foo.html +20 -0
- data/spec/fixtures/foo.js +3 -0
- data/spec/fixtures/picard.jpg +0 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +78 -0
- data/tmp/features/.gitignore +2 -0
- data/tmp/main/.gitignore +2 -0
- metadata +121 -0
@@ -0,0 +1,465 @@
|
|
1
|
+
require 'cucumber/formatter/ordered_xml_markup'
|
2
|
+
require 'cucumber/formatter/duration'
|
3
|
+
require 'tmpdir'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'nokogiri'
|
6
|
+
require 'uri'
|
7
|
+
require 'open-uri'
|
8
|
+
|
9
|
+
module Butternut
|
10
|
+
class Formatter
|
11
|
+
include ERB::Util # for the #h method
|
12
|
+
include Cucumber::Formatter::Duration
|
13
|
+
|
14
|
+
# FIXME: this is obviously not nominal, but I have no way of
|
15
|
+
# accepting additional options from Cucumber at present
|
16
|
+
FEATURES_DIR_PARTS = %w{.. features}
|
17
|
+
FEATURES_HTML_PREFIX = "/features"
|
18
|
+
|
19
|
+
def initialize(step_mother, io, options)
|
20
|
+
@io = io
|
21
|
+
@options = options
|
22
|
+
@buffer = {}
|
23
|
+
@current_builder = create_builder(@io)
|
24
|
+
|
25
|
+
if @options && @options[:formats]
|
26
|
+
format = @options[:formats].detect { |(name, _)| underscore(name) == "butternut/formatter" }
|
27
|
+
if format && format[1].is_a?(String)
|
28
|
+
base_dir = File.dirname(File.expand_path(format[1]))
|
29
|
+
features_dir = File.expand_path(File.join(base_dir, *FEATURES_DIR_PARTS))
|
30
|
+
|
31
|
+
if File.exist?(features_dir)
|
32
|
+
today = Date.today.to_s
|
33
|
+
|
34
|
+
@source_output_dir = File.join(features_dir, today)
|
35
|
+
@source_html_path = FEATURES_HTML_PREFIX + "/" + today
|
36
|
+
if !File.exist?(@source_output_dir)
|
37
|
+
FileUtils.mkdir(@source_output_dir)
|
38
|
+
end
|
39
|
+
else
|
40
|
+
$stderr.puts "Directory not found: #{features_dir}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
@source_output_dir ||= Dir.tmpdir
|
45
|
+
@source_html_path ||= "file://" + Dir.tmpdir
|
46
|
+
end
|
47
|
+
|
48
|
+
def before_features(features)
|
49
|
+
start_buffering :features
|
50
|
+
end
|
51
|
+
|
52
|
+
def after_features(features)
|
53
|
+
stop_buffering :features
|
54
|
+
# <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
55
|
+
builder.declare!(
|
56
|
+
:DOCTYPE,
|
57
|
+
:html,
|
58
|
+
:PUBLIC,
|
59
|
+
'-//W3C//DTD XHTML 1.0 Strict//EN',
|
60
|
+
'http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd'
|
61
|
+
)
|
62
|
+
builder.html(:xmlns => 'http://www.w3.org/1999/xhtml') do
|
63
|
+
builder.head do
|
64
|
+
builder.meta(:content => 'text/html;charset=utf-8')
|
65
|
+
builder.title 'Cucumber'
|
66
|
+
inline_css
|
67
|
+
end
|
68
|
+
builder.body do
|
69
|
+
builder.div(:class => 'cucumber') do
|
70
|
+
builder << buffer(:features)
|
71
|
+
builder.div(format_duration(features.duration), :class => 'duration')
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def before_feature(feature)
|
78
|
+
start_buffering :feature
|
79
|
+
@exceptions = []
|
80
|
+
end
|
81
|
+
|
82
|
+
def after_feature(feature)
|
83
|
+
stop_buffering :feature
|
84
|
+
builder.div(:class => 'feature') do
|
85
|
+
builder << buffer(:feature)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def before_comment(comment)
|
90
|
+
start_buffering :comment
|
91
|
+
end
|
92
|
+
|
93
|
+
def after_comment(comment)
|
94
|
+
stop_buffering :comment
|
95
|
+
builder.pre(:class => 'comment') do
|
96
|
+
builder << buffer(:comment)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def comment_line(comment_line)
|
101
|
+
builder.text!(comment_line)
|
102
|
+
builder.br
|
103
|
+
end
|
104
|
+
|
105
|
+
def after_tags(tags)
|
106
|
+
@tag_spacer = nil
|
107
|
+
end
|
108
|
+
|
109
|
+
def tag_name(tag_name)
|
110
|
+
builder.text!(@tag_spacer) if @tag_spacer
|
111
|
+
@tag_spacer = ' '
|
112
|
+
builder.span(tag_name, :class => 'tag')
|
113
|
+
end
|
114
|
+
|
115
|
+
def feature_name(name)
|
116
|
+
lines = name.split(/\r?\n/)
|
117
|
+
return if lines.empty?
|
118
|
+
builder.h2 do |h2|
|
119
|
+
builder.span(lines[0], :class => 'val')
|
120
|
+
end
|
121
|
+
builder.p(:class => 'narrative') do
|
122
|
+
lines[1..-1].each do |line|
|
123
|
+
builder.text!(line.strip)
|
124
|
+
builder.br
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def before_background(background)
|
130
|
+
@in_background = true
|
131
|
+
start_buffering :background
|
132
|
+
end
|
133
|
+
|
134
|
+
def after_background(background)
|
135
|
+
stop_buffering :background
|
136
|
+
@in_background = nil
|
137
|
+
builder.div(:class => 'background') do
|
138
|
+
builder << buffer(:background)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def background_name(keyword, name, file_colon_line, source_indent)
|
143
|
+
@listing_background = true
|
144
|
+
builder.h3 do |h3|
|
145
|
+
builder.span(keyword, :class => 'keyword')
|
146
|
+
builder.text!(' ')
|
147
|
+
builder.span(name, :class => 'val')
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def before_feature_element(feature_element)
|
152
|
+
start_buffering :feature_element
|
153
|
+
@feature_element = feature_element
|
154
|
+
end
|
155
|
+
|
156
|
+
def after_feature_element(feature_element)
|
157
|
+
stop_buffering :feature_element
|
158
|
+
css_class = {
|
159
|
+
Cucumber::Ast::Scenario => 'scenario',
|
160
|
+
Cucumber::Ast::ScenarioOutline => 'scenario outline'
|
161
|
+
}[feature_element.class]
|
162
|
+
|
163
|
+
builder.div(:class => css_class) do
|
164
|
+
builder << buffer(:feature_element)
|
165
|
+
end
|
166
|
+
@open_step_list = true
|
167
|
+
@feature_element = nil
|
168
|
+
end
|
169
|
+
|
170
|
+
def scenario_name(keyword, name, file_colon_line, source_indent)
|
171
|
+
@listing_background = false
|
172
|
+
builder.h3 do
|
173
|
+
builder.span(keyword, :class => 'keyword')
|
174
|
+
builder.text!(' ')
|
175
|
+
builder.span(name, :class => 'val')
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
def before_outline_table(outline_table)
|
180
|
+
@outline_row = 0
|
181
|
+
start_buffering :outline_table
|
182
|
+
end
|
183
|
+
|
184
|
+
def after_outline_table(outline_table)
|
185
|
+
stop_buffering :outline_table
|
186
|
+
builder.table do
|
187
|
+
builder << buffer(:outline_table)
|
188
|
+
end
|
189
|
+
@outline_row = nil
|
190
|
+
end
|
191
|
+
|
192
|
+
def before_examples(examples)
|
193
|
+
start_buffering :examples
|
194
|
+
end
|
195
|
+
|
196
|
+
def after_examples(examples)
|
197
|
+
stop_buffering :examples
|
198
|
+
builder.div(:class => 'examples') do
|
199
|
+
builder << buffer(:examples)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def examples_name(keyword, name)
|
204
|
+
builder.h4 do
|
205
|
+
builder.span(keyword, :class => 'keyword')
|
206
|
+
builder.text!(' ')
|
207
|
+
builder.span(name, :class => 'val')
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def before_steps(steps)
|
212
|
+
start_buffering :steps
|
213
|
+
end
|
214
|
+
|
215
|
+
def after_steps(steps)
|
216
|
+
stop_buffering :steps
|
217
|
+
builder.ol do
|
218
|
+
builder << buffer(:steps)
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
def before_step(step)
|
223
|
+
@step_id = step.dom_id
|
224
|
+
end
|
225
|
+
|
226
|
+
def before_step_result(keyword, step_match, multiline_arg, status, exception, source_indent, background)
|
227
|
+
start_buffering :step_result
|
228
|
+
@hide_this_step = false
|
229
|
+
if exception
|
230
|
+
if @exceptions.include?(exception)
|
231
|
+
@hide_this_step = true
|
232
|
+
return
|
233
|
+
end
|
234
|
+
@exceptions << exception
|
235
|
+
end
|
236
|
+
if status != :failed && @in_background ^ background
|
237
|
+
@hide_this_step = true
|
238
|
+
return
|
239
|
+
end
|
240
|
+
@status = status
|
241
|
+
end
|
242
|
+
|
243
|
+
def after_step_result(keyword, step_match, multiline_arg, status, exception, source_indent, background)
|
244
|
+
stop_buffering :step_result
|
245
|
+
return if @hide_this_step
|
246
|
+
builder.li(:id => @step_id, :class => "step #{status}") do
|
247
|
+
builder.div do
|
248
|
+
builder << buffer(:step_result)
|
249
|
+
end
|
250
|
+
builder.div(:class => "page") do
|
251
|
+
if @feature_element.respond_to?(:last_page_source)
|
252
|
+
page_source = @feature_element.last_page_source
|
253
|
+
if page_source
|
254
|
+
page_url = @feature_element.last_page_url
|
255
|
+
page_source = transform_page_source(page_source, page_url)
|
256
|
+
path = source_file_name
|
257
|
+
File.open(path, "w") { |f| f.print(page_source) }
|
258
|
+
|
259
|
+
builder.a({:target => "_blank", :href => "#{@source_html_path}/#{File.basename(path)}"}) do
|
260
|
+
builder.span(:class => "icon-show") { builder << "" }
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
def step_name(keyword, step_match, status, source_indent, background)
|
269
|
+
@step_matches ||= []
|
270
|
+
background_in_scenario = background && !@listing_background
|
271
|
+
@skip_step = @step_matches.index(step_match) || background_in_scenario
|
272
|
+
@step_matches << step_match
|
273
|
+
|
274
|
+
unless @skip_step
|
275
|
+
build_step(keyword, step_match, status)
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
def exception(exception, status)
|
280
|
+
return if @hide_this_step
|
281
|
+
builder.pre(format_exception(exception), :class => status)
|
282
|
+
end
|
283
|
+
|
284
|
+
def before_multiline_arg(multiline_arg)
|
285
|
+
start_buffering :multiline_arg
|
286
|
+
end
|
287
|
+
|
288
|
+
def after_multiline_arg(multiline_arg)
|
289
|
+
stop_buffering :multiline_arg
|
290
|
+
return if @hide_this_step || @skip_step
|
291
|
+
if Cucumber::Ast::Table === multiline_arg
|
292
|
+
builder.table do
|
293
|
+
builder << buffer(:multiline_arg)
|
294
|
+
end
|
295
|
+
else
|
296
|
+
builder << buffer(:multiline_arg)
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
def py_string(string)
|
301
|
+
return if @hide_this_step
|
302
|
+
builder.pre(:class => 'val') do |pre|
|
303
|
+
builder << string.gsub("\n", '
')
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
def before_table_row(table_row)
|
308
|
+
@row_id = table_row.dom_id
|
309
|
+
@col_index = 0
|
310
|
+
start_buffering :table_row
|
311
|
+
end
|
312
|
+
|
313
|
+
def after_table_row(table_row)
|
314
|
+
stop_buffering :table_row
|
315
|
+
return if @hide_this_step
|
316
|
+
builder.table(:id => @row_id) do
|
317
|
+
builder << buffer(:table_row)
|
318
|
+
end
|
319
|
+
if table_row.exception
|
320
|
+
builder.tr do
|
321
|
+
builder.td(:colspan => @col_index.to_s, :class => 'failed') do
|
322
|
+
builder.pre do |pre|
|
323
|
+
pre << format_exception(table_row.exception)
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
328
|
+
@outline_row += 1 if @outline_row
|
329
|
+
end
|
330
|
+
|
331
|
+
def table_cell_value(value, status)
|
332
|
+
return if @hide_this_step
|
333
|
+
|
334
|
+
cell_type = @outline_row == 0 ? :th : :td
|
335
|
+
attributes = {:id => "#{@row_id}_#{@col_index}", :class => 'val'}
|
336
|
+
attributes[:class] += " #{status}" if status
|
337
|
+
build_cell(cell_type, value, attributes)
|
338
|
+
@col_index += 1
|
339
|
+
end
|
340
|
+
|
341
|
+
def announce(announcement)
|
342
|
+
builder.pre(announcement, :class => 'announcement')
|
343
|
+
end
|
344
|
+
|
345
|
+
private
|
346
|
+
|
347
|
+
def build_step(keyword, step_match, status)
|
348
|
+
step_name = step_match.format_args(lambda{|param| %{<span class="param">#{param}</span>}})
|
349
|
+
builder.div do |div|
|
350
|
+
builder.span(keyword, :class => 'keyword')
|
351
|
+
builder.text!(' ')
|
352
|
+
builder.span(:class => 'step val') do |name|
|
353
|
+
name << h(step_name).gsub(/<span class="(.*?)">/, '<span class="\1">').gsub(/<\/span>/, '</span>')
|
354
|
+
end
|
355
|
+
end
|
356
|
+
end
|
357
|
+
|
358
|
+
def build_cell(cell_type, value, attributes)
|
359
|
+
builder.__send__(cell_type, value, attributes)
|
360
|
+
end
|
361
|
+
|
362
|
+
def inline_css
|
363
|
+
builder.style(:type => 'text/css') do
|
364
|
+
builder.text!(File.read(File.dirname(__FILE__) + '/cucumber.css'))
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
def format_exception(exception)
|
369
|
+
(["#{exception.message} (#{exception.class})"] + exception.backtrace).join("\n")
|
370
|
+
end
|
371
|
+
|
372
|
+
def builder
|
373
|
+
@current_builder
|
374
|
+
end
|
375
|
+
|
376
|
+
def buffer(label)
|
377
|
+
result = @buffer[label]
|
378
|
+
@buffer[label] = ''
|
379
|
+
result
|
380
|
+
end
|
381
|
+
|
382
|
+
def start_buffering(label)
|
383
|
+
@buffer[label] ||= ''
|
384
|
+
@parent_builder ||= {}
|
385
|
+
@parent_builder[label] = @current_builder
|
386
|
+
@current_builder = create_builder(@buffer[label])
|
387
|
+
end
|
388
|
+
|
389
|
+
def stop_buffering(label)
|
390
|
+
@current_builder = @parent_builder[label]
|
391
|
+
end
|
392
|
+
|
393
|
+
def create_builder(io)
|
394
|
+
Cucumber::Formatter::OrderedXmlMarkup.new(:target => io, :indent => 0)
|
395
|
+
end
|
396
|
+
|
397
|
+
def source_file_name
|
398
|
+
t = Time.now.strftime("%Y%m%d")
|
399
|
+
path = nil
|
400
|
+
while path.nil?
|
401
|
+
path = File.join(@source_output_dir, "butternut#{t}-#{$$}-#{rand(0x100000000).to_s(36)}.html")
|
402
|
+
path = nil if File.exist?(path)
|
403
|
+
end
|
404
|
+
path
|
405
|
+
end
|
406
|
+
|
407
|
+
# Snagged from active_support
|
408
|
+
def underscore(camel_cased_word)
|
409
|
+
camel_cased_word.to_s.gsub(/::/, '/').
|
410
|
+
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
|
411
|
+
gsub(/([a-z\d])([A-Z])/,'\1_\2').
|
412
|
+
tr("-", "_").
|
413
|
+
downcase
|
414
|
+
end
|
415
|
+
|
416
|
+
def transform_page_source(page_source, page_url)
|
417
|
+
page_uri = URI.parse(page_url)
|
418
|
+
page_uri.query = nil
|
419
|
+
page_uri.path = File.dirname(page_uri.path)
|
420
|
+
page_url = page_uri.to_s
|
421
|
+
|
422
|
+
collected_files = []
|
423
|
+
|
424
|
+
doc = Nokogiri.HTML(page_source)
|
425
|
+
{ 'img' => 'src',
|
426
|
+
'link[rel=stylesheet]' => 'href'
|
427
|
+
}.each_pair do |selector, attr|
|
428
|
+
doc.css(selector).each do |elt|
|
429
|
+
elt_url = elt[attr]
|
430
|
+
next if elt_url.nil?
|
431
|
+
|
432
|
+
elt_url.gsub!('\\"', "")
|
433
|
+
next if elt_url.empty?
|
434
|
+
next if collected_files.index(elt_url)
|
435
|
+
|
436
|
+
basename = File.basename(elt_url)
|
437
|
+
local_file = File.join(@source_output_dir, basename)
|
438
|
+
remote_file = case elt_url
|
439
|
+
when %r{^\w+://} then elt_url
|
440
|
+
else
|
441
|
+
elt_url.sub!(/^\//, "")
|
442
|
+
page_url + "/" + elt_url
|
443
|
+
end
|
444
|
+
File.open(local_file, "w") { |f| f.write open(remote_file).read }
|
445
|
+
collected_files << elt_url
|
446
|
+
|
447
|
+
elt[attr] = basename
|
448
|
+
end
|
449
|
+
end
|
450
|
+
|
451
|
+
# disable links
|
452
|
+
doc.css('a').each do |link|
|
453
|
+
link['href'] = "#"
|
454
|
+
end
|
455
|
+
|
456
|
+
# turn off scripts
|
457
|
+
doc.css('script').each { |s| s.unlink }
|
458
|
+
|
459
|
+
# disable form elements
|
460
|
+
doc.css('input, select, textarea').each { |x| x['disabled'] = 'disabled' }
|
461
|
+
|
462
|
+
doc.to_s
|
463
|
+
end
|
464
|
+
end
|
465
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Butternut
|
2
|
+
module Helpers
|
3
|
+
def browser
|
4
|
+
@browser ||= Celerity::Browser.new
|
5
|
+
end
|
6
|
+
|
7
|
+
def visit(url)
|
8
|
+
browser.goto(url)
|
9
|
+
@page_changed = true
|
10
|
+
end
|
11
|
+
|
12
|
+
def current_url
|
13
|
+
browser.page.getWebResponse.getRequestUrl.toString
|
14
|
+
end
|
15
|
+
|
16
|
+
def current_page_source
|
17
|
+
browser.page ? browser.page.as_xml : nil
|
18
|
+
end
|
19
|
+
|
20
|
+
# Fill in a text field with a value
|
21
|
+
def fill_in(label_or_name, options = {})
|
22
|
+
elt = find_element_by_label_or_name(:text_field, label_or_name)
|
23
|
+
if elt.exist?
|
24
|
+
elt.value = options[:with]
|
25
|
+
@page_changed = true
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def select(option_text, options = {})
|
30
|
+
elt = find_element_by_label_or_name(:select_list, options[:from])
|
31
|
+
if elt.exist?
|
32
|
+
elt.select(option_text)
|
33
|
+
@page_changed = true
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def click_button(button_value)
|
38
|
+
browser.button(button_value).click
|
39
|
+
@page_changed = true
|
40
|
+
end
|
41
|
+
|
42
|
+
def page_changed?
|
43
|
+
@page_changed
|
44
|
+
end
|
45
|
+
|
46
|
+
def find_element_by_label_or_name(type, label_or_name)
|
47
|
+
elt = browser.send(type, :label, label_or_name)
|
48
|
+
elt.exist? ? elt : browser.send(type, :name, label_or_name)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/butternut.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'cucumber'
|
3
|
+
require 'celerity'
|
4
|
+
|
5
|
+
module Butternut
|
6
|
+
def self.setup_hooks(obj)
|
7
|
+
obj.instance_exec do
|
8
|
+
AfterStep do |object|
|
9
|
+
begin
|
10
|
+
if object.is_a?(Cucumber::Ast::Scenario)
|
11
|
+
if page_changed?
|
12
|
+
object.last_page_source = current_page_source
|
13
|
+
object.last_page_url = current_url
|
14
|
+
else
|
15
|
+
object.last_page_source = nil
|
16
|
+
object.last_page_url = nil
|
17
|
+
end
|
18
|
+
@page_changed = false
|
19
|
+
end
|
20
|
+
rescue Exception => e
|
21
|
+
p e
|
22
|
+
pp caller
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
require File.dirname(__FILE__) + "/butternut/scenario_extensions"
|
30
|
+
require File.dirname(__FILE__) + "/butternut/helpers"
|
31
|
+
require File.dirname(__FILE__) + "/butternut/formatter"
|
32
|
+
|