lucid_report 0.1.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,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