lucid_report 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,654 @@
1
+ require "base64"
2
+ require "json"
3
+ require "builder"
4
+
5
+ require "lucid_report/version"
6
+
7
+ class Hash
8
+ def except(*keys)
9
+ dup.except!(*keys)
10
+ end
11
+
12
+ def except!(*keys)
13
+ keys.each { |key| delete(key) }
14
+ self
15
+ end
16
+ end
17
+
18
+ class LucidReport
19
+ COLOR = {
20
+ passed: '#90ed7d',
21
+ working: '#90ed7d',
22
+ failed: '#f45b5b',
23
+ broken: '#f45b5b',
24
+ undefined: '#e4d354',
25
+ incomplete: '#e7a35c',
26
+ pending: '#f7a35c',
27
+ skipped: '#7cb5ec',
28
+ output: '#007fff'
29
+ }
30
+
31
+ def self.configure
32
+ default_options = OpenStruct.new(
33
+ json_path: nil,
34
+ report_path: 'lucid-report',
35
+ report_types: [:html],
36
+ report_tabs: [:overview, :features],
37
+ report_title: 'Lucid Results',
38
+ compress_images: false,
39
+ additional_info: {}
40
+ )
41
+ yield default_options if block_given?
42
+ @options = default_options.marshal_dump
43
+ end
44
+
45
+ def self.build_report(options = nil)
46
+ configure unless @options
47
+ @options.merge!(options) if options.is_a?(Hash)
48
+
49
+ raise 'Error: Invalid report_types. Use: [:json, :html]' unless @options[:report_types].is_a?(Array)
50
+ raise 'Error: Invalid report_tabs. Use: [:overview, :features, :scenarios, :errors]' unless @options[:report_tabs].is_a?(Array)
51
+
52
+ @options[:report_types].map!(&:to_s).map!(&:upcase)
53
+ @options[:report_tabs].map!(&:to_s).map!(&:downcase)
54
+
55
+ input = files(@options[:json_path])
56
+ all_features = features input rescue (raise 'LucidReportParsingError')
57
+
58
+ File.open(@options[:report_path] + '.json', 'w') do |file|
59
+ file.write JSON.pretty_generate all_features
60
+ puts "JSON test report generated: '#{@options[:report_path]}.json'"
61
+ end if @options[:report_types].include? 'JSON'
62
+
63
+ all_scenarios = scenarios(all_features)
64
+ all_steps = steps(all_scenarios)
65
+ all_tags = tags(all_scenarios)
66
+ total_time = total_time(all_features)
67
+ feature_data = data(all_features)
68
+ scenario_data = data(all_scenarios)
69
+ step_data = data(all_steps)
70
+
71
+ File.open(@options[:report_path] + '.html', 'w:UTF-8') do |file|
72
+ @builder = Builder::XmlMarkup.new(target: file, indent: 0)
73
+ @builder.declare!(:DOCTYPE, :html)
74
+ @builder << '<html>'
75
+
76
+ @builder.head do
77
+ @builder.meta(charset: 'UTF-8')
78
+ @builder.title @options[:report_title]
79
+
80
+ @builder.style(type: 'text/css') do
81
+ @builder << File.read(File.dirname(__FILE__) + '/../vendor/assets/stylesheets/jquery-ui.min.css')
82
+ COLOR.each do |color|
83
+ @builder << ".#{color[0].to_s}{background:#{color[1]};color:#434348;padding:2px}"
84
+ end
85
+ @builder << '.summary{margin-bottom:4px;border: 1px solid #c5c5c5;border-radius:4px;background:#f1f1f1;color:#434348;padding:4px;overflow:hidden;vertical-align:bottom;}'
86
+ @builder << '.summary .results{text-align:right;float:right;}'
87
+ @builder << '.summary .info{text-align:left;float:left;}'
88
+ @builder << '.data_table{border-collapse: collapse;} .data_table td{padding: 5px; border: 1px solid #ddd;}'
89
+ @builder << '.ui-tooltip{background: black; color: white; font-size: 12px; padding: 2px 4px; border-radius: 20px; box-shadow: 0 0 7px black;}'
90
+ end
91
+
92
+ @builder.script(type: 'text/javascript') do
93
+ %w(jquery-min jquery-ui.min highcharts highcharts-3d).each do |js|
94
+ @builder << File.read(File.dirname(__FILE__) + '/../vendor/assets/javascripts/' + js + '.js')
95
+ end
96
+ @builder << '$(function(){$("#results").tabs();});'
97
+ @builder << "$(function(){$('#features').accordion({collapsible: true, heightStyle: 'content', active: false, icons: false});});"
98
+ (0..all_features.size).each do |n|
99
+ @builder << "$(function(){$('#feature#{n}').accordion({collapsible: true, heightStyle: 'content', active: false, icons: false});});"
100
+ end
101
+ @builder << "$(function(){$('#status').accordion({collapsible: true, heightStyle: 'content', active: false, icons: false});});"
102
+ scenario_data.each do |data|
103
+ @builder << "$(function(){$('##{data[:name]}').accordion({collapsible: true, heightStyle: 'content', active: false, icons: false});});"
104
+ end
105
+ @builder << '$(function() {$(document).tooltip({track: true});});'
106
+ end
107
+ end
108
+
109
+ @builder << '<body>'
110
+
111
+ @builder.div(class: 'summary') do
112
+ @builder.span(class: 'info') do
113
+ info = @options[:additional_info].empty?
114
+ @builder << '<br/>&nbsp;&nbsp;&nbsp;' if info
115
+ @builder.span(style: "font-size:#{info ? 36 : 18 }px;font-weight: bold;") do
116
+ @builder << @options[:report_title]
117
+ end
118
+ @options[:additional_info].each do |l|
119
+ @builder << '<br/>' + l[0].to_s.capitalize + ' : ' + l[1].to_s
120
+ end
121
+ end if @options[:additional_info].is_a?(Hash)
122
+
123
+ @builder.span(class: 'results') do
124
+ s = all_features.size
125
+ @builder << s.to_s + " feature#{'s' if s > 1} ("
126
+ feature_data.each do |data|
127
+ @builder << ' ' + data[:count].to_s + ' ' + data[:name]
128
+ end
129
+ s = all_scenarios.size
130
+ @builder << ')<br/>' + s.to_s + " scenario#{'s' if s > 1} ("
131
+ scenario_data.each do |data|
132
+ @builder << ' ' + data[:count].to_s + ' ' + data[:name]
133
+ end
134
+ s = all_steps.size
135
+ @builder << ')<br/>' + s.to_s + " step#{'s' if s > 1} ("
136
+ step_data.each do |data|
137
+ @builder << ' ' + data[:count].to_s + ' ' + data[:name]
138
+ end
139
+ @builder << ')<br/>&#128336; ' + duration(total_time).to_s
140
+ end
141
+ end
142
+
143
+ @builder.div(id: 'results') do
144
+ build_menu(@options[:report_tabs])
145
+
146
+ @builder.div(id: 'overviewTab') do
147
+ @builder << "<div id='featurePieChart' style=\"float:left;width:33%\"></div>"
148
+ @builder << "<div id='scenarioPieChart' style=\"display:inline-block;width:33%\"></div>"
149
+ @builder << "<div id='stepPieChart' style=\"float:right;width:33%\"></div>"
150
+ end if @options[:report_tabs].include? 'overview'
151
+
152
+ @builder.div(id: 'featuresTab') do
153
+ build_tags_drop_down(all_tags)
154
+ @builder.div(id: 'features') do
155
+ all_features.each_with_index do |feature, n|
156
+ @builder.h3(style: "background:#{COLOR[feature['status'].to_sym]}") do
157
+ @builder.span(class: feature['status']) do
158
+ @builder << "<strong>#{feature['keyword']}</strong> #{feature['name']} (#{duration(feature['duration'])})"
159
+ end
160
+ end
161
+ @builder.div do
162
+ @builder.div(id: "feature#{n}") do
163
+ feature['elements'].each { |scenario| build_scenario(scenario) }
164
+ end
165
+ end
166
+ end
167
+ end
168
+ @builder << "<div id='featureTabPieChart'></div>"
169
+ end if @options[:report_tabs].include? 'features'
170
+
171
+ @builder.div(id: 'scenariosTab') do
172
+ build_tags_drop_down(all_tags)
173
+ @builder.div(id: 'status') do
174
+ all_scenarios.group_by{|scenario| scenario['status']}.each do |data|
175
+ @builder.h3(style: "background:#{COLOR[data[0].to_sym]}") do
176
+ @builder.span(class: data[0]) do
177
+ @builder << "<strong>#{data[0].capitalize} scenarios (Count: <span id='count'>#{data[1].size}</span>)</strong>"
178
+ end
179
+ end
180
+ @builder.div do
181
+ @builder.div(id: data[0]) do
182
+ data[1].sort_by { |scenario| scenario['name'] }.each { |scenario| build_scenario scenario }
183
+ end
184
+ end
185
+ end
186
+ end
187
+ @builder << "<div id='scenarioTabPieChart'></div>"
188
+ end if @options[:report_tabs].include? 'scenarios'
189
+
190
+ @builder.div(id: 'errorsTab') do
191
+ @builder.ol do
192
+ all_scenarios.each { |scenario| build_error_list scenario }
193
+ end
194
+ end if @options[:report_tabs].include? 'errors'
195
+ end
196
+
197
+ @builder.script(type: 'text/javascript') do
198
+ @builder << pie_chart_js('featurePieChart', 'Features', feature_data) if @options[:report_tabs].include? 'overview'
199
+ @builder << donut_js('featureTabPieChart', 'Features', feature_data) if @options[:report_tabs].include? 'features'
200
+ @builder << pie_chart_js('scenarioPieChart', 'Scenarios', scenario_data) if @options[:report_tabs].include? 'overview'
201
+ @builder << donut_js('scenarioTabPieChart', 'Scenarios', scenario_data) if @options[:report_tabs].include? 'scenarios'
202
+ @builder << pie_chart_js('stepPieChart', 'Steps', step_data) if @options[:report_tabs].include? 'overview'
203
+
204
+ unless all_tags.empty?
205
+ @builder << '$("#featuresTab .select-tags").change(function(){
206
+ $("#featuresTab .scenario-all").hide().next().hide().parent().hide().parent().hide().prev().hide();
207
+ $("#featuresTab ." + $(this).val()).show().parent().show().parent().prev().show();});' if @options[:report_tabs].include? 'features'
208
+
209
+ @builder << '$("#scenariosTab .select-tags").change(function(){var val = $(this).val();$("#scenariosTab .scenario-all").hide().next().hide();
210
+ $("#scenariosTab ." + val).show();$("#scenariosTab #count").each(function(){status = $(this).parent().parent().prop("className");
211
+ count = $("#scenariosTab #" + status + " ." + val).length;countElement = $("#scenariosTab ." + status + " #count");
212
+ countElement.parent().parent().parent().show();if(count==0){countElement.parent().parent().parent().hide().next().hide();}
213
+ countElement.html(count);});});' if @options[:report_tabs].include? 'scenarios'
214
+ end
215
+ end
216
+
217
+ @builder << '</body>'
218
+ @builder << '</html>'
219
+
220
+ puts "HTML test report generated: '#{@options[:report_path]}.html'"
221
+ end if @options[:report_types].include? 'HTML'
222
+
223
+ [total_time, feature_data, scenario_data, step_data]
224
+ end
225
+
226
+ def self.build_scenario(scenario)
227
+ tags = (scenario['tags'] ? scenario['tags'].map{|tag| tag['name']}.join(' ') : '')
228
+ @builder.h3(style: "background:#{COLOR[scenario['status'].to_sym]}", title: tags, class: 'scenario-all ' + tags.gsub('@','tag-')) do
229
+ @builder.span(class: scenario['status']) do
230
+ @builder << "<strong>#{scenario['keyword']}</strong> #{scenario['name']} (#{duration(scenario['duration'])})"
231
+ end
232
+ end
233
+ @builder.div do
234
+ scenario['before'].each do |before|
235
+ build_hook_error(before)
236
+ end
237
+ scenario['steps'].each do |step|
238
+ build_step step, scenario['keyword']
239
+ end
240
+ scenario['after'].each do |after|
241
+ build_output after['output']
242
+ build_hook_error after
243
+ build_embedding after['embeddings']
244
+ end
245
+ end
246
+ end
247
+
248
+ def self.build_step(step, scenario_keyword)
249
+ @builder.div(class: step['status']) do
250
+ @builder << "<strong>#{step['keyword']}</strong> #{step['name']} (#{duration(step['duration'])})"
251
+ end
252
+ build_data_table step['rows']
253
+ build_output step['output']
254
+ build_step_error step
255
+ build_embedding step['embeddings']
256
+ step['after'].each do |after|
257
+ build_output after['output']
258
+ build_step_hook_error after, scenario_keyword
259
+ build_embedding after['embeddings']
260
+ end if step['after']
261
+ end
262
+
263
+ def self.build_data_table(rows)
264
+ @builder.table(class: 'data_table', style: 'margin: 10px') do
265
+ rows.each do |row|
266
+ @builder.tr do
267
+ row['cells'].each do |cell|
268
+ @builder << "<td> #{cell} </td>"
269
+ end
270
+ end
271
+ end
272
+ end if rows.is_a? Array
273
+ end
274
+
275
+ def self.build_output(outputs)
276
+ outputs.each do |output|
277
+ @builder << "<span style='color:#{COLOR[:output]}'>#{output.gsub("\n",'</br>').gsub("\t",'&nbsp;&nbsp;').gsub(' ','&nbsp;')}</span><br/>"
278
+ end if outputs.is_a?(Array)
279
+ end
280
+
281
+ def self.build_step_error(step)
282
+ if step['status'] == 'failed' && step['result']['error_message']
283
+ @builder << "<strong style=color:#{COLOR[:failed]}>Error: </strong>"
284
+ error = step['result']['error_message'].split("\n")
285
+ @builder.span(style: "color:#{COLOR[:failed]}") do
286
+ error[0..-3].each do |line|
287
+ @builder << line + '<br/>'
288
+ end
289
+ end
290
+ @builder << "<strong>SD: </strong>#{error[-2]} <br/>"
291
+ @builder << "<strong>FF: </strong>#{error[-1]}<br/>"
292
+ end
293
+ end
294
+
295
+ def self.build_hook_error(hook)
296
+ if hook['status'] == 'failed'
297
+ @builder << "<strong style=color:#{COLOR[:failed]}>Error: </strong>"
298
+ error = hook['result']['error_message'].split("\n")
299
+ @builder.span(style: "color:#{COLOR[:failed]}") do
300
+ error[0..-2].each do |line|
301
+ @builder << line + '<br/>'
302
+ end
303
+ end
304
+ @builder << "<strong>Hook: </strong>#{error[-1]}<br/>"
305
+ end
306
+ end
307
+
308
+ def self.build_step_hook_error(hook, scenario_keyword)
309
+ if hook['result']['error_message']
310
+ @builder << "<strong style=color:#{COLOR[:failed]}>Error: </strong>"
311
+ error = hook['result']['error_message'].split("\n")
312
+ @builder.span(style: "color:#{COLOR[:failed]}") do
313
+ (scenario_keyword == 'Scenario Outline' ? error[0..-8] : error[0..-5]).each do |line|
314
+ @builder << line + '<br/>'
315
+ end
316
+ end
317
+ @builder << "<strong>Hook: </strong>#{scenario_keyword == 'Scenario Outline' ? error[-7] : error[-4]} <br/>"
318
+ @builder << "<strong>FF: </strong>#{error[-2]}<br/>"
319
+ end
320
+ end
321
+
322
+ def self.build_embedding(embeddings)
323
+ @embedding_count ||= 0
324
+ embeddings.each do |embedding|
325
+ src = Base64.decode64(embedding['data'])
326
+ id = "embedding_#{@embedding_count}"
327
+ if embedding['mime_type'] =~ /^image\/(png|gif|jpg|jpeg)/
328
+ begin
329
+ @builder.span(class: 'image') do
330
+ @builder.a(href: '', style: 'text-decoration: none;', onclick: "img=document.getElementById('#{id}');img.style.display = (img.style.display == 'none' ? 'block' : 'none');return false") do
331
+ @builder.span(style: "color: #{COLOR[:output]}; font-weight: bold; border-bottom: 1px solid #{COLOR[:output]};") do
332
+ @builder << "Screenshot ##{@embedding_count}"
333
+ end
334
+ end
335
+ @builder << '<br/>'
336
+ @options[:compress_images] ? build_unique_image(embedding, id) : build_image(embedding,id)
337
+ end
338
+ rescue => e
339
+ puts 'Image embedding failed!'
340
+ puts [e.class, e.message, e.backtrace[0..10].join("\n")].join("\n")
341
+ end
342
+ elsif embedding['mime_type'] =~ /^text\/plain/
343
+ begin
344
+ if src.include?('|||')
345
+ title, link = src.split('|||')
346
+ @builder.span(class: 'link') do
347
+ @builder.a(id: id, style: 'text-decoration: none;', href: link, title: title) do
348
+ @builder.span(style: "color: #{COLOR[:output]}; font-weight: bold; border-bottom: 1px solid #{COLOR[:output]};") do
349
+ @builder << title
350
+ end
351
+ end
352
+ @builder << '<br/>'
353
+ end
354
+ else
355
+ @builder.span(class: 'info') do
356
+ @builder << src
357
+ @builder << '<br/>'
358
+ end
359
+ end
360
+ rescue => e
361
+ puts('Link embedding skipped!')
362
+ puts [e.class, e.message, e.backtrace[0..10].join("\n")].join("\n")
363
+ end
364
+ end
365
+ @embedding_count += 1
366
+ end if embeddings.is_a?(Array)
367
+ end
368
+
369
+ def self.build_unique_image(image, id)
370
+ @images ||= []
371
+ index = @images.find_index image
372
+ if index
373
+ klass = "image_#{index}"
374
+ else
375
+ @images << image
376
+ klass = "image_#{@images.size - 1}"
377
+ @builder.style(type: 'text/css') do
378
+ begin
379
+ src = Base64.decode64(image['data'])
380
+ src = 'data:' + image['mime_type'] + ';base64,' + src unless src =~ /^data:image\/(png|gif|jpg|jpeg);base64,/
381
+ @builder << "img.#{klass} {content: url(#{src});}"
382
+ rescue
383
+ src = image['data']
384
+ src = 'data:' + image['mime_type'] + ';base64,' + src unless src =~ /^data:image\/(png|gif|jpg|jpeg);base64,/
385
+ @builder << "img.#{klass} {content: url(#{src});}"
386
+ end
387
+ end
388
+ end
389
+ @builder << %{<img id='#{id}' class='#{klass}' style='display: none; border: 1px solid #{COLOR[:output]};' />}
390
+ end
391
+
392
+ def self.build_image(image, id)
393
+ begin
394
+ src = Base64.decode64(image['data'])
395
+ src = 'data:' + image['mime_type'] + ';base64,' + src unless src =~ /^data:image\/(png|gif|jpg|jpeg);base64,/
396
+ @builder << %{<img id='#{id}' style='display: none; border: 1px solid #{COLOR[:output]};' src='#{src}'/>}
397
+ rescue
398
+ src = image['data']
399
+ src = 'data:' + image['mime_type'] + ';base64,' + src unless src =~ /^data:image\/(png|gif|jpg|jpeg);base64,/
400
+ @builder << %{<img id='#{id}' style='display: none; border: 1px solid #{COLOR[:output]};' src='#{src}'/>}
401
+ end
402
+ end
403
+
404
+ def self.build_error_list(scenario)
405
+ scenario['before'].each do |before|
406
+ next unless before['status'] == 'failed'
407
+ @builder.li do
408
+ error = before['result']['error_message'].split("\n")
409
+ @builder.span(style: "color:#{COLOR[:failed]}") do
410
+ error[0..-2].each do |line|
411
+ @builder << line + '<br/>'
412
+ end
413
+ end
414
+ @builder << "<strong>Hook: </strong>#{error[-1]} <br/>"
415
+ @builder << "<strong>Scenario: </strong>#{scenario['name']} <br/><hr/>"
416
+ end
417
+ end
418
+ scenario['steps'].each do |step|
419
+ step['after'].each do |after|
420
+ next unless after['status'] == 'failed'
421
+ @builder.li do
422
+ error = after['result']['error_message'].split("\n")
423
+ @builder.span(style: "color:#{COLOR[:failed]}") do
424
+ (scenario['keyword'] == 'Scenario Outline' ? error[0..-8] : error[0..-5]).each do |line|
425
+ @builder << line + '<br/>'
426
+ end
427
+ end
428
+ @builder << "<strong>Hook: </strong>#{scenario['keyword'] == 'Scenario Outline' ? error[-7] : error[-4]} <br/>"
429
+ @builder << "<strong>FF: </strong>#{error[-2]} <br/><hr/>"
430
+ end
431
+ end if step['after']
432
+ next unless step['status'] == 'failed' && step['result']['error_message']
433
+ @builder.li do
434
+ error = step['result']['error_message'].split("\n")
435
+ @builder.span(style: "color:#{COLOR[:failed]}") do
436
+ error[0..-3].each do |line|
437
+ @builder << line + '<br/>'
438
+ end
439
+ end
440
+ @builder << "<strong>SD: </strong>#{error[-2]} <br/>"
441
+ @builder << "<strong>FF: </strong>#{error[-1]} <br/><hr/>"
442
+ end
443
+ end
444
+ scenario['after'].each do |after|
445
+ next unless after['status'] == 'failed'
446
+ @builder.li do
447
+ error = after['result']['error_message'].split("\n")
448
+ @builder.span(style: "color:#{COLOR[:failed]}") do
449
+ error[0..-2].each do |line|
450
+ @builder << line + '<br/>'
451
+ end
452
+ end
453
+ @builder << "<strong>Hook: </strong>#{error[-1]} <br/>"
454
+ @builder << "<strong>Scenario: </strong>#{scenario['name']} <br/><hr/>"
455
+ end
456
+ end
457
+ end
458
+
459
+ def self.pie_chart_js(id, title, data)
460
+ data = data.each_with_object('') do |h, s|
461
+ s << "{name: '#{h[:name].capitalize}'"
462
+ s << ",y: #{h[:count]}"
463
+ s << ',sliced: true' if h[:sliced]
464
+ s << ',selected: true' if h[:selected]
465
+ s << ",color: '#{h[:color]}'" if h[:color]
466
+ s << '},'
467
+ end.chop
468
+ "$(function (){$('##{id}').highcharts({credits: {enabled: false}, chart: {type: 'pie',
469
+ options3d: {enabled: true, alpha: 45, beta: 0}}, title: {text: '#{title}'},
470
+ tooltip: {pointFormat: 'Count: <b>{point.y}</b><br/>Percentage: <b>{point.percentage:.1f}%</b>'},
471
+ plotOptions: {pie: {allowPointSelect: true, cursor: 'pointer', depth: 35, dataLabels: {enabled: true,
472
+ format: '{point.name}'}}}, series: [{type: 'pie', name: 'Results', data: [#{data}]}]});});"
473
+ end
474
+
475
+ def self.donut_js(id, title, data)
476
+ data = data.each_with_object('') do |h, s|
477
+ s << "{name: '#{h[:name].capitalize}'"
478
+ s << ",y: #{h[:count]}"
479
+ s << ',sliced: true' if h[:sliced]
480
+ s << ',selected: true' if h[:selected]
481
+ s << ",color: '#{h[:color]}'" if h[:color]
482
+ s << '},'
483
+ end.chop
484
+ "$(function (){$('##{id}').highcharts({credits: {enabled: false},
485
+ chart: {plotBackgroundColor: null, plotBorderWidth: 0, plotShadow: false, width: $(document).width()-80},
486
+ title: {text: '#{title}', align: 'center', verticalAlign: 'middle', y: 40},
487
+ tooltip: {pointFormat: 'Count: <b>{point.y}</b><br/>Percentage: <b>{point.percentage:.1f}%</b>'},
488
+ plotOptions: {pie: {dataLabels: {enabled: true, distance: -50,
489
+ style: {fontWeight: 'bold', color: 'white', textShadow: '0px 1px 2px black'}},
490
+ startAngle: -90, endAngle: 90, center: ['50%', '75%']}},
491
+ series: [{type: 'pie', innerSize: '50%', name: 'Results', data: [#{data}]}]});});"
492
+ end
493
+
494
+ def self.files(path)
495
+ files = if path.is_a?(String)
496
+ (path =~ /\.json$/) ? [path] : Dir.glob("#{path}/*.json")
497
+ elsif path.nil?
498
+ Dir.glob('*.json')
499
+ elsif path.is_a?(Array)
500
+ path.map do |file|
501
+ (file =~ /\.json$/) ? file : Dir.glob("#{file}/*.json")
502
+ end.flatten
503
+ else
504
+ raise 'InvalidInput'
505
+ end
506
+ raise 'InvalidOrNoInputFile' if files.empty?
507
+ files.uniq
508
+ end
509
+
510
+ def self.features(files)
511
+ files.each_with_object([]) { |file, features|
512
+ data = File.read(file)
513
+ next if data.empty?
514
+ features << JSON.parse(data)
515
+ }.flatten.group_by { |feature|
516
+ feature['uri'] + feature['id'] + feature['line'].to_s
517
+ }.values.each_with_object([]) { |group, features|
518
+ features << group.first.except('elements').merge('elements' => group.map { |feature| feature['elements'] }.flatten)
519
+ }.sort_by!{|feature| feature['name']}.each { |feature|
520
+ if feature['elements'][0]['type'] == 'background'
521
+ (0..feature['elements'].size - 1).step(2) do |i|
522
+ feature['elements'][i]['steps'] ||= []
523
+ feature['elements'][i]['steps'].each{|step| step['name']+=(' ('+feature['elements'][i]['keyword']+')')}
524
+ feature['elements'][i+1]['steps'] = feature['elements'][i]['steps'] + feature['elements'][i+1]['steps']
525
+ feature['elements'][i+1]['before'] = feature['elements'][i]['before'] if feature['elements'][i]['before']
526
+ end
527
+ feature['elements'].reject!{|element| element['type'] == 'background'}
528
+ end
529
+ feature['elements'].each { |scenario|
530
+ scenario['before'] ||= []
531
+ scenario['before'].each { |before|
532
+ before['result']['duration'] ||= 0
533
+ before.merge! 'status' => before['result']['status'], 'duration' => before['result']['duration']
534
+ }
535
+ scenario['steps'] ||= []
536
+ scenario['steps'].each { |step|
537
+ step['result']['duration'] ||= 0
538
+ duration = step['result']['duration']
539
+ status = step['result']['status']
540
+ step['after'].each { |after|
541
+ after['result']['duration'] ||= 0
542
+ duration += after['result']['duration']
543
+ status = 'failed' if after['result']['status'] == 'failed'
544
+ after.merge! 'status' => after['result']['status'], 'duration' => after['result']['duration']
545
+ } if step['after']
546
+ step.merge! 'status' => status, 'duration' => duration
547
+ }
548
+ scenario['after'] ||= []
549
+ scenario['after'].each { |after|
550
+ after['result']['duration'] ||= 0
551
+ after.merge! 'status' => after['result']['status'], 'duration' => after['result']['duration']
552
+ }
553
+ scenario.merge! 'status' => scenario_status(scenario), 'duration' => total_time(scenario['before']) + total_time(scenario['steps']) + total_time(scenario['after'])
554
+ }
555
+ feature.merge! 'status' => feature_status(feature), 'duration' => total_time(feature['elements'])
556
+ }
557
+ end
558
+
559
+ def self.scenarios(features)
560
+ features.map do |feature|
561
+ feature['elements']
562
+ end.flatten
563
+ end
564
+
565
+ def self.steps(scenarios)
566
+ scenarios.map do |scenario|
567
+ scenario['steps']
568
+ end.flatten
569
+ end
570
+
571
+ def self.tags(scenarios)
572
+ scenarios.map do |scenario|
573
+ scenario['tags'] ? scenario['tags'].map { |t| t['name'] } : []
574
+ end.flatten.uniq
575
+ end
576
+
577
+ def self.feature_status(feature)
578
+ feature_status = 'working'
579
+ feature['elements'].each do |scenario|
580
+ status = scenario['status']
581
+ return 'broken' if status == 'failed'
582
+ feature_status = 'incomplete' if %w(undefined pending).include?(status)
583
+ end
584
+ feature_status
585
+ end
586
+
587
+ def self.scenario_status(scenario)
588
+ (scenario['before'] + scenario['steps'] + scenario['after']).each do |step|
589
+ status = step['status']
590
+ return status unless status == 'passed'
591
+ end
592
+ 'passed'
593
+ end
594
+
595
+ def self.total_time(data)
596
+ total_time = 0
597
+ data.each{|item| total_time += item['duration']}
598
+ total_time
599
+ end
600
+
601
+ def self.duration(seconds)
602
+ seconds = seconds.to_f / 1000000000
603
+ m, s = seconds.divmod(60)
604
+ "#{m}m #{'%.3f' % s}s"
605
+ end
606
+
607
+ def self.data(all_data)
608
+ all_data.group_by { |db| db['status'] }.map do |data|
609
+ { name: data[0],
610
+ count: data[1].size,
611
+ color: COLOR[data[0].to_sym]
612
+ }
613
+ end
614
+ end
615
+
616
+ def self.build_menu(tabs)
617
+ @builder.ul do
618
+ tabs.each do |tab|
619
+ @builder.li do
620
+ @builder.a(href: "##{tab}Tab") do
621
+ @builder << tab.capitalize
622
+ end
623
+ end
624
+ end
625
+ end
626
+ end
627
+
628
+ def self.build_tags_drop_down(tags)
629
+ @builder.div(style: 'text-align:center;padding:5px;') do
630
+ @builder << '<strong>Tag: </strong>'
631
+ @builder.select(class: 'select-tags') do
632
+ @builder.option(value: 'scenario-all') do
633
+ @builder << 'All'
634
+ end
635
+ tags.sort.each do |tag|
636
+ @builder.option(value: tag.gsub('@','tag-')) do
637
+ @builder << tag
638
+ end
639
+ end
640
+ end
641
+ end if tags.is_a?(Array)
642
+ end
643
+
644
+ private_class_method :donut_js, :pie_chart_js, :files,
645
+ :features, :feature_status,
646
+ :scenarios, :scenario_status, :steps,
647
+ :data, :duration, :total_time,
648
+ :build_scenario, :build_step,
649
+ :build_menu, :build_output, :build_embedding,
650
+ :build_error_list, :build_step_error,
651
+ :build_hook_error, :build_step_hook_error,
652
+ :build_unique_image, :build_image,
653
+ :build_data_table, :tags, :build_tags_drop_down
654
+ end