blinka-reporter 0.5.2 → 0.7.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,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