blinka-reporter 0.5.2 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,172 @@
1
+ require 'httparty'
2
+ require 'blinka_reporter/error'
3
+
4
+ module BlinkaReporter
5
+ class Blinka
6
+ SUPPORTED_MIME_TYPES = {
7
+ jpg: 'image/jpeg',
8
+ jpeg: 'image/jpeg',
9
+ png: 'image/png'
10
+ }
11
+
12
+ include HTTParty
13
+
14
+ def self.report(data:, config:)
15
+ Blinka.new(data: data, config: config).report
16
+ end
17
+
18
+ def initialize(data:, config:)
19
+ @data = data
20
+ @config = config
21
+ self.class.base_uri("#{@config.host}/api/v1")
22
+ end
23
+
24
+ def report
25
+ if ENV.fetch('BLINKA_ALLOW_WEBMOCK_DISABLE', 'true') == 'true' &&
26
+ defined?(WebMock) && WebMock.respond_to?(:disable!)
27
+ WebMock.disable!
28
+ end
29
+
30
+ @config.validate_blinka
31
+ self.authenticate
32
+
33
+ results =
34
+ @data
35
+ .fetch(:results, [])
36
+ .map do |result|
37
+ if !result[:image].nil?
38
+ result[:image] = Blinka.upload_image(filepath: result[:image])
39
+ result
40
+ else
41
+ result
42
+ end
43
+ end
44
+
45
+ body = {
46
+ report: {
47
+ repository: @config.repository,
48
+ tag: @config.tag,
49
+ commit: @config.commit,
50
+ metadata: {
51
+ total_time: @data[:total_time],
52
+ nbr_tests: @data[:nbr_tests],
53
+ nbr_assertions: @data[:nbr_assertions],
54
+ seed: @data[:seed]
55
+ }.compact,
56
+ results: results
57
+ }
58
+ }
59
+
60
+ response =
61
+ self.class.post(
62
+ '/report',
63
+ body: body.to_json,
64
+ headers: {
65
+ 'Content-Type' => 'application/json',
66
+ 'Authorization' => "Bearer #{@jwt_token}"
67
+ }
68
+ )
69
+ case response.code
70
+ when 200
71
+ puts "Reported #{@data[:nbr_tests]} tests of commit #{@config.commit}!"
72
+ else
73
+ raise(
74
+ BlinkaReporter::Error,
75
+ "Could not report, got response code #{response.code}"
76
+ )
77
+ end
78
+ rescue => error
79
+ raise(BlinkaReporter::Error, <<-EOS)
80
+ BLINKA:
81
+ Failed to create report because of #{error.class} with message:
82
+ #{error.message}
83
+ EOS
84
+ ensure
85
+ WebMock.enable! if defined?(WebMock) && WebMock.respond_to?(:enable!)
86
+ end
87
+
88
+ def authenticate
89
+ response =
90
+ self.class.post(
91
+ '/authentication',
92
+ body: {
93
+ token_id: @config.team_id,
94
+ token_secret: @config.team_secret
95
+ }.to_json,
96
+ headers: { 'Content-Type' => 'application/json' }
97
+ )
98
+ case response.code
99
+ when 200
100
+ @jwt_token = JSON.parse(response.body).dig('auth_token')
101
+ else
102
+ raise(
103
+ BlinkaReporter::Error,
104
+ "Could not authenticate to API #{response.code}"
105
+ )
106
+ end
107
+ end
108
+
109
+ def self.upload_image(filepath:)
110
+ return unless File.exist?(filepath)
111
+
112
+ file = File.open(filepath)
113
+ filename = File.basename(filepath)
114
+ extension = File.extname(filepath).delete('.').to_sym
115
+ content_type = SUPPORTED_MIME_TYPES[extension]
116
+ return if content_type.nil?
117
+
118
+ presigned_post =
119
+ Blinka.presign_image(filename: filename, content_type: content_type)
120
+ Blinka.upload_to_storage(presigned_post: presigned_post, file: file)
121
+
122
+ puts "Uploaded: #{filename}"
123
+ Blinka.to_shrine_object(
124
+ presigned_post: presigned_post,
125
+ file: file,
126
+ filename: filename
127
+ )
128
+ end
129
+
130
+ def self.presign_image(filename:, content_type:)
131
+ response =
132
+ self.get(
133
+ '/presign',
134
+ body: { filename: filename, content_type: content_type }
135
+ )
136
+
137
+ case response.code
138
+ when 200
139
+ JSON.parse(response.body)
140
+ else
141
+ raise(BlinkaReporter::Error, 'Could not presign file')
142
+ end
143
+ end
144
+
145
+ def self.upload_to_storage(presigned_post:, file:)
146
+ url = URI.parse(presigned_post.fetch('url'))
147
+
148
+ body = presigned_post['fields'].merge({ 'file' => file.read })
149
+ response = HTTParty.post(url, multipart: true, body: body)
150
+
151
+ case response.code
152
+ when 204
153
+ true
154
+ else
155
+ raise(BlinkaReporter::Error, 'Could not upload file to storage')
156
+ end
157
+ end
158
+
159
+ def self.to_shrine_object(presigned_post:, file:, filename:)
160
+ storage, idx = presigned_post.dig('fields', 'key').split('/')
161
+ {
162
+ "id": idx,
163
+ "storage": storage,
164
+ "metadata": {
165
+ "size": file.size,
166
+ "filename": filename,
167
+ "mime_type": presigned_post.dig('fields', 'Content-Type')
168
+ }
169
+ }
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,66 @@
1
+ require 'blinka_reporter/client'
2
+ require 'blinka_reporter/version'
3
+
4
+ module BlinkaReporter
5
+ class Cli
6
+ def self.run(argv)
7
+ if (argv.index('--help') || -1) >= 0
8
+ puts(<<~EOS)
9
+ blinka_reporter version #{BlinkaReporter::VERSION}
10
+
11
+ Options:
12
+ --path <path>: Path to test results file, can be supplied multiple times to combine results
13
+ - ./blinka_results.json blinka json format
14
+ - ./rspec.xml from https://github.com/sj26/rspec_junit_formatter
15
+
16
+ --tap: Flag for outputting test results in TAP-protocol, helpful on Heroku CI
17
+ --blinka: Flag for reporting test results to blinka.app, requires also supplying:
18
+ - --team-id
19
+ - --team-secret
20
+ - --repository
21
+ - --commit
22
+ --team-id <team-id>: Blinka team id, only used with --blinka
23
+ --team-secret <team-secret>: Blinka team secret, only used with --blinka
24
+ --commit <commit>: The commit hash to report
25
+ --tag <tag>: The tag for the run, for example to separate a test matrix
26
+ --repository <repository>: The Github repository
27
+ --host <host>: Override Blink host to send report
28
+
29
+ EOS
30
+ return 0
31
+ end
32
+
33
+ tap = (argv.index('--tap') || -1) >= 0
34
+
35
+ paths = argv_value_for(argv, '--path')
36
+
37
+ blinka = (argv.index('--blinka') || -1) >= 0
38
+ commit = argv_value_for(argv, '--commit')&.first
39
+ repository = argv_value_for(argv, '--repository')&.first
40
+ tag = argv_value_for(argv, '--tag')&.first
41
+ team_id = argv_value_for(argv, '--team-id')&.first
42
+ team_secret = argv_value_for(argv, '--team-secret')&.first
43
+ host = argv_value_for(argv, '--host')&.first
44
+
45
+ client = BlinkaReporter::Client.new
46
+ data = client.parse(paths: paths)
47
+ config =
48
+ BlinkaReporter::Config.new(
49
+ tag: tag,
50
+ commit: commit,
51
+ team_id: team_id,
52
+ team_secret: team_secret,
53
+ repository: repository,
54
+ host: host
55
+ )
56
+ client.report(data: data, config: config, tap: tap, blinka: blinka)
57
+ end
58
+
59
+ def self.argv_value_for(argv, option_name)
60
+ argv
61
+ .each_index
62
+ .select { |index| argv[index] == option_name }
63
+ .map { |index| argv[index + 1] }
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,128 @@
1
+ require 'httparty'
2
+ require 'ox'
3
+
4
+ require 'blinka_reporter/blinka'
5
+ require 'blinka_reporter/config'
6
+ require 'blinka_reporter/error'
7
+ require 'blinka_reporter/tap'
8
+
9
+ module BlinkaReporter
10
+ class Client
11
+ def parse(paths: nil)
12
+ paths ||= ['./blinka_results.json']
13
+ paths = Array(paths)
14
+ paths.each do |path|
15
+ unless File.exist?(path)
16
+ raise(
17
+ BlinkaReporter::Error,
18
+ "Could not find #{path}, make sure the path is correct."
19
+ )
20
+ end
21
+ end
22
+
23
+ merge_results(
24
+ paths.map do |path|
25
+ if path.end_with?('.xml')
26
+ parse_xml(path: path)
27
+ elsif path.end_with?('.json')
28
+ parse_json(path: path)
29
+ else
30
+ raise(
31
+ BlinkaReporter::Error,
32
+ "Unknown format of #{path}, needs to be .json or .xml"
33
+ )
34
+ end
35
+ end
36
+ )
37
+ end
38
+
39
+ def report(data:, blinka: false, tap: false, config: nil)
40
+ BlinkaReporter::Tap.report(data) if tap
41
+ BlinkaReporter::Blinka.report(config: config, data: data) if blinka
42
+ 0
43
+ end
44
+
45
+ private
46
+
47
+ def merge_results(data_array)
48
+ data = { total_time: 0, nbr_tests: 0, nbr_assertions: 0, results: [] }
49
+ data_array.each do |result|
50
+ data[:total_time] += result[:total_time] || 0
51
+ data[:nbr_tests] += result[:nbr_tests] || 0
52
+ data[:nbr_assertions] += result[:nbr_assertions] || 0
53
+ data[:results] += result[:results] || []
54
+ end
55
+ data
56
+ end
57
+
58
+ def parse_json(path:)
59
+ JSON.parse(File.open(path).read, symbolize_names: true)
60
+ end
61
+
62
+ def parse_xml(path:)
63
+ data = Ox.load_file(path, { symbolize_keys: true, skip: :skip_none })
64
+ test_suite = data.root
65
+ unless test_suite.name == 'testsuite'
66
+ raise("Root element is not <testsuite>, instead #{test_suite.name}")
67
+ end
68
+
69
+ properties = test_suite.nodes.select { |node| node.name == 'properties' }
70
+ test_cases = test_suite.nodes.select { |node| node.name == 'testcase' }
71
+ {
72
+ nbr_tests: Integer(test_suite.tests || 0),
73
+ total_time: Float(test_suite.time),
74
+ seed: xml_seed(properties),
75
+ results: xml_test_cases(test_cases)
76
+ }
77
+ end
78
+
79
+ def xml_seed(ox_properties)
80
+ ox_properties.each do |property|
81
+ property.nodes.each do |node|
82
+ return node.attributes[:value] if node.attributes[:name] == 'seed'
83
+ end
84
+ end
85
+ nil
86
+ end
87
+
88
+ # Kind is extracted from the second part of spec.models.customer_spec
89
+ def xml_test_cases(test_cases)
90
+ test_cases.map do |test_case|
91
+ result = {
92
+ kind: Array(test_case.attributes[:classname]&.split('.'))[1],
93
+ name: test_case.attributes[:name],
94
+ path: test_case.attributes[:file]&.delete_prefix('./'),
95
+ time: Float(test_case.attributes[:time] || 0)
96
+ }
97
+ if test_case.nodes.any?
98
+ skipped = test_case.nodes.any? { |node| node.name == 'skipped' }
99
+ result[:result] = 'skip' if skipped
100
+ failure =
101
+ test_case.nodes.select { |node| node.name == 'failure' }.first
102
+ if failure
103
+ result[:result] = 'fail'
104
+
105
+ # Needs to be double quotation marks to work properly
106
+ result[:backtrace] = failure.text.split("\n")
107
+ result[:image] = get_image_path(result[:backtrace])
108
+ result[:message] = failure.attributes[:message]
109
+ end
110
+ else
111
+ result[:result] = 'pass'
112
+ end
113
+ result
114
+ end
115
+ end
116
+
117
+ def get_image_path(backtrace)
118
+ backtrace.each do |text|
119
+ path = /^(\[Screenshot\]|\[Screenshot Image\]):\s([\S]*)$/.match(text)
120
+ next if path.nil?
121
+ path = path[-1]
122
+ next unless File.exist?(path)
123
+ return path
124
+ end
125
+ nil
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,37 @@
1
+ require 'blinka_reporter/error'
2
+
3
+ module BlinkaReporter
4
+ class Config
5
+ attr_reader(:commit, :host, :repository, :tag, :team_id, :team_secret)
6
+ DEFAULT_HOST = 'https://www.blinka.app'
7
+
8
+ def initialize(
9
+ tag:,
10
+ commit:,
11
+ repository:,
12
+ host: nil,
13
+ team_id:,
14
+ team_secret:
15
+ )
16
+ @commit = commit || find_commit
17
+ @host = host || DEFAULT_HOST
18
+ @repository = repository
19
+ @tag = tag
20
+ @team_id = team_id
21
+ @team_secret = team_secret
22
+ end
23
+
24
+ def validate_blinka
25
+ required = [@team_id, @team_secret, @repository]
26
+ if required.include?(nil) || required.include?('')
27
+ raise(BlinkaReporter::Error, <<~EOS)
28
+ Missing configuration, make sure to set --team-id, --team-secret, --repository
29
+ EOS
30
+ end
31
+ end
32
+
33
+ def find_commit
34
+ ENV.fetch('HEROKU_TEST_RUN_COMMIT_VERSION', `git rev-parse HEAD`.chomp)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,3 @@
1
+ module BlinkaReporter
2
+ class Error < StandardError; end
3
+ end
@@ -0,0 +1,92 @@
1
+ module BlinkaReporter
2
+ class MinitestAdapter
3
+ def initialize(test_result)
4
+ @test_result = test_result
5
+ end
6
+
7
+ def path
8
+ @path ||= source_location.first.gsub(Dir.getwd, '').delete_prefix('/')
9
+ end
10
+
11
+ def line
12
+ @line ||= source_location.last
13
+ end
14
+
15
+ # Handle broken API in Minitest between 5.10 and 5.11
16
+ # https://github.com/minitest-reporters/minitest-reporters/blob/e9092460b5a5cf5ca9eb375428217cbb2a7f6dbb/lib/minitest/reporters/default_reporter.rb#L159
17
+ def source_location
18
+ @source_location ||=
19
+ if @test_result.respond_to?(:klass)
20
+ @test_result.source_location
21
+ else
22
+ @test_result.method(@test_result.name).source_location
23
+ end
24
+ end
25
+
26
+ def kind
27
+ parts = self.path.gsub('test/', '').split('/')
28
+ parts.length > 1 ? parts.first : 'general'
29
+ end
30
+
31
+ def message
32
+ failure = @test_result.failure
33
+ return unless failure
34
+ "#{failure.error.class}: #{failure.error.message}"
35
+ end
36
+
37
+ def backtrace
38
+ return unless @test_result.failure
39
+ Minitest.filter_backtrace(@test_result.failure.backtrace)
40
+ end
41
+
42
+ def result
43
+ if @test_result.error?
44
+ :error
45
+ elsif @test_result.skipped?
46
+ :skip
47
+ elsif @test_result.failure
48
+ :fail
49
+ else
50
+ :pass
51
+ end
52
+ end
53
+
54
+ def time
55
+ @test_result.time
56
+ end
57
+
58
+ def name
59
+ @test_result.name
60
+ end
61
+
62
+ def image
63
+ return unless kind == 'system'
64
+
65
+ image_path =
66
+ if defined?(Capybara) && Capybara.respond_to?(:save_path) &&
67
+ Capybara.save_path.present?
68
+ "#{Capybara.save_path}/failures_#{name}.png"
69
+ else
70
+ "./tmp/screenshots/failures_#{name}.png"
71
+ end
72
+
73
+ return unless File.exist?(image_path)
74
+
75
+ image_path
76
+ end
77
+
78
+ def report
79
+ {
80
+ backtrace: backtrace,
81
+ message: message,
82
+ line: line,
83
+ image: image,
84
+ kind: kind,
85
+ name: name,
86
+ path: path,
87
+ result: result,
88
+ time: time
89
+ }.compact
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,51 @@
1
+ module BlinkaReporter
2
+ class Tap
3
+ # Based on https://github.com/kern/minitest-reporters/blob/master/lib/minitest/reporters/progress_reporter.rb
4
+ # Tries to adhere to https://testanything.org/tap-specification.html
5
+ TAP_COMMENT_PAD = 8
6
+ attr_reader(:data)
7
+
8
+ def self.report(data)
9
+ Tap.new(data).report
10
+ end
11
+
12
+ def initialize(data)
13
+ results = Array(data[:results])
14
+ return if results.size == 0
15
+
16
+ @data = <<~REPORT
17
+ TAP version 13
18
+ 1..#{results.size}
19
+ #{test_results(results)}
20
+ REPORT
21
+ end
22
+
23
+ def test_results(results)
24
+ report = []
25
+ results.each_with_index do |test, index|
26
+ test_str = "#{test[:path]} - #{test[:name].tr('#', '_')}"
27
+ result = test[:result]
28
+ if result == 'pass'
29
+ report << "ok #{index + 1} - #{test_str}"
30
+ elsif result == 'skip'
31
+ report << "ok #{index + 1} # skip: #{test_str}"
32
+ elsif result == 'fail'
33
+ report << "not ok #{index + 1} - failed: #{test_str}"
34
+ test[:message].split('\n') do |line|
35
+ report << "##{' ' * TAP_COMMENT_PAD + line}"
36
+ end
37
+ report << '#'
38
+ Array(test[:backtrace]).each do |line|
39
+ report << "##{' ' * TAP_COMMENT_PAD + line}"
40
+ end
41
+ report << ''
42
+ end
43
+ end
44
+ report.join("\n")
45
+ end
46
+
47
+ def report
48
+ puts(@data)
49
+ end
50
+ end
51
+ end
@@ -1,3 +1,3 @@
1
1
  module BlinkaReporter
2
- VERSION = '0.5.2'.freeze
2
+ VERSION = '0.7.0'.freeze
3
3
  end
@@ -0,0 +1,4 @@
1
+ require 'blinka_reporter/version'
2
+ require 'blinka_reporter/cli'
3
+
4
+ module BlinkaReporter; end
@@ -1,7 +1,7 @@
1
1
  require 'minitest'
2
2
  require 'json'
3
- require 'blinka_minitest'
4
- require 'blinka_client'
3
+ require 'blinka_reporter/minitest_adapter'
4
+ require 'blinka_reporter/client'
5
5
 
6
6
  module Minitest
7
7
  def self.plugin_blinka_init(options)
@@ -11,114 +11,46 @@ module Minitest
11
11
  def plugin_blinka_options(opts, options); end
12
12
 
13
13
  module BlinkaPlugin
14
- REPORT_PATH = 'blinka_results.json'.freeze
15
- TAP_COMMENT_PAD = 8
16
14
  class Reporter < Minitest::StatisticsReporter
17
15
  attr_accessor :tests
18
16
 
19
- def initialize(io = $stdout, options = {})
20
- super
21
- self.tests = []
22
- end
23
-
24
17
  def record(test)
25
18
  super
19
+ self.tests ||= []
26
20
  tests << test
27
21
  end
28
22
 
29
23
  def report
30
24
  super
31
25
 
32
- tap_report if ENV['BLINKA_TAP']
33
- if ENV['BLINKA_JSON'] || ENV['BLINKA_REPORT']
34
- json_report(append: !ENV['BLINKA_APPEND'].nil?)
35
- end
36
- BlinkaClient.new.report if ENV['BLINKA_REPORT']
37
- rescue BlinkaClient::BlinkaError => error
26
+ json_report
27
+ rescue BlinkaReporter::Error => error
38
28
  puts(error)
39
29
  end
40
30
 
41
31
  private
42
32
 
43
- def json_report(append:)
33
+ def json_report
34
+ report_path = ENV['BLINKA_PATH']
35
+ return if report_path.nil? || report_path.eql?('')
36
+
44
37
  result = {
45
38
  total_time: total_time,
46
39
  nbr_tests: count,
47
40
  nbr_assertions: assertions,
48
- commit: find_commit,
49
- tag: ENV.fetch('BLINKA_TAG', ''),
50
41
  seed: options[:seed],
51
42
  results:
52
- tests.map { |test_result| BlinkaMinitest.new(test_result).report }
43
+ tests.map do |test_result|
44
+ BlinkaReporter::MinitestAdapter.new(test_result).report
45
+ end
53
46
  }
54
- result = append_previous(result) if append
55
47
 
56
- File.open(REPORT_PATH, 'w+') do |file|
48
+ File.open(report_path, 'w+') do |file|
57
49
  file.write(JSON.pretty_generate(result))
58
50
  end
59
51
 
60
52
  puts
61
- puts("Test results written to `#{REPORT_PATH}`")
62
- end
63
-
64
- # Based on https://github.com/kern/minitest-reporters/blob/master/lib/minitest/reporters/progress_reporter.rb
65
- # Tries to adhere to https://testanything.org/tap-specification.html
66
- def tap_report
67
- puts
68
- puts('TAP version 13')
69
- puts("1..#{tests.length}")
70
- tests.each_with_index do |test, index|
71
- blinka = BlinkaMinitest.new(test)
72
- test_str = "#{blinka.path} - #{test.name.tr('#', '_')}"
73
- if test.passed?
74
- puts "ok #{index + 1} - #{test_str}"
75
- elsif test.skipped?
76
- puts "ok #{index + 1} # skip: #{test_str}"
77
- elsif test.failure
78
- puts "not ok #{index + 1} - failed: #{test_str}"
79
- blinka.message.each_line { |line| print_padded_comment(line) }
80
-
81
- # test.failure.message.each_line { |line| print_padded_comment(line) }
82
- unless test.failure.is_a?(MiniTest::UnexpectedError)
83
- blinka.backtrace.each { |line| print_padded_comment(line) }
84
- end
85
- puts
86
- end
87
- end
88
- end
89
-
90
- def print_padded_comment(line)
91
- puts "##{' ' * TAP_COMMENT_PAD + line}"
92
- end
93
-
94
- def find_commit
95
- ENV.fetch(
96
- 'BLINKA_COMMIT',
97
- ENV.fetch(
98
- 'HEROKU_TEST_RUN_COMMIT_VERSION',
99
- `git rev-parse HEAD`.chomp
100
- )
101
- )
102
- end
103
-
104
- private
105
-
106
- def parse_report
107
- return unless File.exist?(REPORT_PATH)
108
- JSON.parse(File.read(REPORT_PATH))
109
- end
110
-
111
- def append_previous(result)
112
- previous = parse_report
113
- return if previous.nil?
114
- return if result[:commit] != previous['commit']
115
- return if result[:tag] != previous['tag']
116
-
117
- result[:total_time] += previous['total_time'] || 0
118
- result[:nbr_tests] += previous['nbr_tests'] || 0
119
- result[:nbr_assertions] += previous['nbr_assertions'] || 0
120
- result[:results] += previous['results'] || []
121
- result
53
+ puts("Test results written to `#{report_path}`")
122
54
  end
123
55
  end
124
56
  end