cover_rage 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bb5a49ec54d2f39963fa0704561b1d49f27fd04b4070b760ad1a0dd361eaa2e4
4
+ data.tar.gz: 926ef7443cf1efecfe908d9d6e2a74799b1cf5ddf260d16f03ddc9cd6c821ada
5
+ SHA512:
6
+ metadata.gz: 9ada124a26bd41c144f54391c5d424f396a1f260e2c2210d4d971c0905ff04578d3bd346d33115b5de1168e8476a869fb8710ab3f3b0b1201c01fcfaf5629bb9
7
+ data.tar.gz: 0264676faf4d418cc51b27560fa3645b6a7d806aae8ac22aed6b835463d3318bc42083b6ca00766378bcbc8948276ac9d5a49c9412c1a855d1881aeb572a9bda
data/bin/config.ru ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cover_rage/viewer'
4
+ require 'cover_rage/config'
5
+ run CoverRage::Viewer.new(store: CoverRage::Config.store)
data/bin/cover_rage ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'rackup'
5
+ Dir.chdir(__dir__) { Rackup::Server.start }
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ module CoverRage
5
+ module Config
6
+ def self.root_path
7
+ @root_path ||= ENV.fetch('COVER_RAGE_ROOT_PATH', defined?(Rails) && Rails.root ? Rails.root.to_s : Dir.pwd)
8
+ end
9
+
10
+ def self.store
11
+ @store ||= begin
12
+ uri = URI.parse(ENV.fetch('COVER_RAGE_STORE_URL'))
13
+ case uri.scheme
14
+ when 'redis'
15
+ require 'cover_rage/stores/redis'
16
+ CoverRage::Stores::Redis.new(uri.to_s)
17
+ when 'sqlite'
18
+ require 'cover_rage/stores/sqlite'
19
+ CoverRage::Stores::Sqlite.new(uri.path)
20
+ end
21
+ end
22
+ end
23
+
24
+ def self.sleep_duration
25
+ @sleep_duration ||= begin
26
+ args =
27
+ ENV.fetch('COVER_RAGE_SLEEP_DURATION', '60:90').split(':').map!(&:to_i).first(2)
28
+ args.push(args.first.succ) if args.length < 2
29
+ Range.new(*args, true)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoverRage
4
+ module ForkHook
5
+ def _fork
6
+ pid = super
7
+ CoverRage::Launcher.start if pid.zero?
8
+ pid
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cover_rage/recorder'
4
+ require 'cover_rage/config'
5
+ require 'cover_rage/fork_hook'
6
+ require 'coverage'
7
+
8
+ module CoverRage
9
+ class Launcher
10
+ def self.start(**kwargs)
11
+ if @recorder.nil?
12
+ @recorder = CoverRage::Recorder.new(
13
+ store: CoverRage::Config.store,
14
+ root_path: CoverRage::Config.root_path,
15
+ **kwargs
16
+ )
17
+ end
18
+ @recorder.start
19
+ return unless Process.respond_to?(:_fork)
20
+ return if Process.singleton_class < CoverRage::ForkHook
21
+
22
+ Process.singleton_class.prepend(CoverRage::ForkHook)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoverRage
4
+ Record = Data.define(:path, :revision, :source, :execution_count) do
5
+ def self.merge(existing, current)
6
+ records_to_save = []
7
+ current.each do |record|
8
+ found = existing.find { _1.path == record.path }
9
+ records_to_save <<
10
+ if found.nil? || record.revision != found.revision
11
+ record
12
+ else
13
+ record + found
14
+ end
15
+ end
16
+ records_to_save
17
+ end
18
+
19
+ def +(other)
20
+ with(
21
+ execution_count: execution_count.map.with_index do |item, index|
22
+ item.nil? ? nil : item + other.execution_count[index]
23
+ end
24
+ )
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cover_rage/config'
4
+ require 'cover_rage/record'
5
+ require 'digest'
6
+
7
+ module CoverRage
8
+ class Recorder
9
+ SLEEP_DURATION = Config.sleep_duration
10
+ COVERAGE_STOP_STATES = %i[idle suspended].freeze
11
+ attr_reader :store
12
+
13
+ def initialize(root_path:, store:)
14
+ @store = store
15
+ @root_path = root_path.end_with?('/') ? root_path : "#{root_path}/"
16
+ @digest = Digest::MD5.new
17
+ @file_cache = {}
18
+ end
19
+
20
+ def start
21
+ return if @thread&.alive?
22
+
23
+ Coverage.start if COVERAGE_STOP_STATES.include?(Coverage.state)
24
+ @thread = Thread.new do
25
+ loop do
26
+ sleep(rand(SLEEP_DURATION))
27
+ save(Coverage.result(stop: false, clear: true))
28
+ end
29
+ ensure
30
+ save(Coverage.result)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def save(coverage_result)
37
+ records = []
38
+ coverage_result.map do |filepath, execution_count|
39
+ filepath = File.expand_path(filepath) unless filepath.start_with?('/')
40
+ next unless filepath.start_with?(@root_path)
41
+ next if execution_count.all? { _1.nil? || _1.zero? }
42
+
43
+ relative_path = filepath.delete_prefix(@root_path)
44
+ revision, source = read_file_with_revision(filepath)
45
+
46
+ records << Record.new(
47
+ path: relative_path,
48
+ revision: revision,
49
+ source: source,
50
+ execution_count: execution_count
51
+ )
52
+ end
53
+ @store.import(records) if records.any?
54
+ end
55
+
56
+ def read_file_with_revision(path)
57
+ return @file_cache[path] if @file_cache.key?(path)
58
+
59
+ @file_cache[path] = begin
60
+ content = File.read(path)
61
+ [@digest.hexdigest(content), content]
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis'
4
+ require 'json'
5
+
6
+ module CoverRage
7
+ module Stores
8
+ class Redis
9
+ KEY = 'cover_rage_records'
10
+ def initialize(url)
11
+ @redis = ::Redis.new(url: url)
12
+ end
13
+
14
+ def import(records)
15
+ loop do
16
+ break if @redis.watch(KEY) do
17
+ records_to_save = Record.merge(list, records)
18
+ if records_to_save.any?
19
+ arguments = []
20
+ records_to_save.each do |record|
21
+ arguments.push(record.path, JSON.dump(record.to_h))
22
+ end
23
+ @redis.multi { _1.hset(KEY, *arguments) }
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def find(path)
30
+ result = @redis.hget(KEY, path)
31
+ return nil if result.nil?
32
+
33
+ Record.new(**JSON.parse(result))
34
+ end
35
+
36
+ def list
37
+ result = @redis.hgetall(KEY)
38
+ return [] if result.empty?
39
+
40
+ result.map { |_, value| Record.new(**JSON.parse(value)) }
41
+ end
42
+
43
+ def clear
44
+ @redis.del(KEY)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cover_rage/record'
4
+ require 'sqlite3'
5
+ require 'json'
6
+
7
+ module CoverRage
8
+ module Stores
9
+ class Sqlite
10
+ def initialize(path)
11
+ @db = SQLite3::Database.new(path)
12
+ @db.execute <<-SQL
13
+ create table if not exists records (
14
+ path text primary key not null,
15
+ revision blob not null,
16
+ source text not null,
17
+ execution_count text not null
18
+ )
19
+ SQL
20
+ end
21
+
22
+ def import(records)
23
+ @db.transaction do
24
+ records_to_save = Record.merge(list, records)
25
+ if records_to_save.any?
26
+ @db.execute(
27
+ "insert or replace into records (path, revision, source, execution_count) values #{
28
+ (['(?,?,?,?)'] * records_to_save.length).join(',')
29
+ }",
30
+ records_to_save.each_with_object([]) do |record, memo|
31
+ memo.push(
32
+ record.path,
33
+ record.revision,
34
+ record.source,
35
+ JSON.dump(record.execution_count)
36
+ )
37
+ end
38
+ )
39
+ end
40
+ end
41
+ end
42
+
43
+ def find(path)
44
+ rows = @db.execute(
45
+ 'select revision, source, execution_count from records where path = ? limit 1',
46
+ [path]
47
+ )
48
+ return nil if rows.empty?
49
+
50
+ revision, source, execution_count = rows.first
51
+ Record.new(
52
+ path: path,
53
+ revision: revision,
54
+ source: source,
55
+ execution_count: JSON.parse(execution_count)
56
+ )
57
+ end
58
+
59
+ def list
60
+ @db
61
+ .execute('select path, revision, source, execution_count from records')
62
+ .map! do |(path, revision, source, execution_count)|
63
+ Record.new(
64
+ path: path,
65
+ revision: revision,
66
+ source: source,
67
+ execution_count: JSON.parse(execution_count)
68
+ )
69
+ end
70
+ end
71
+
72
+ def clear
73
+ @db.execute('delete from records')
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,51 @@
1
+ <link
2
+ rel="stylesheet"
3
+ href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css"
4
+ />
5
+ <style>
6
+ .line {
7
+ width: 100%;
8
+ display: inline-block;
9
+ counter-increment: line_number;
10
+ }
11
+
12
+ .green {
13
+ background-color: rgb(221, 255, 221);
14
+ }
15
+
16
+ .red {
17
+ background-color: rgb(255, 212, 212);
18
+ }
19
+
20
+ .number {
21
+ display: inline-block;
22
+ text-align: right;
23
+ margin-right: 0.5em;
24
+ background-color: lightgray;
25
+ padding: 0 0.5em 0 1.5em;
26
+ }
27
+ </style>
28
+ <h1><%= path %></h1>
29
+ <pre><code id="code"><%= source_code.encode(xml: :text) %></code></pre>
30
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
31
+ <script id="execution_count" type="application/json"><%= execution_count.encode(xml: :text) %></script>
32
+ <script>
33
+ const execution_count = JSON.parse(
34
+ document.querySelector("#execution_count").childNodes[0].nodeValue
35
+ );
36
+ const digit_width = Math.floor(Math.log10(Math.max(...execution_count))) + 1;
37
+ const $code = document.querySelector("#code");
38
+ $code.innerHTML = hljs
39
+ .highlight($code.textContent, {
40
+ language: "ruby",
41
+ })
42
+ .value.split("\n")
43
+ .map((line, index) => {
44
+ const value = execution_count[index];
45
+ let color = "";
46
+ if (typeof value === "number") color = value > 0 ? "green" : "red";
47
+ const number = typeof value === "number" ? value : "-";
48
+ return `<span class="line ${color}"><span class="number">${number.toString().padStart(digit_width, ' ')}</span>${line}</span>`;
49
+ })
50
+ .join("\n");
51
+ </script>
@@ -0,0 +1,28 @@
1
+ <table>
2
+ <thead>
3
+ <tr>
4
+ <th>path</th>
5
+ <th>lines</th>
6
+ <th>relevancy</th>
7
+ <th>hit</th>
8
+ <th>miss</th>
9
+ <th>coverage (%)</th>
10
+ </tr>
11
+ </thead>
12
+ <tbody>
13
+ <% items.each do |item| %>
14
+ <tr>
15
+ <td>
16
+ <a href="/file?path=<%= URI.encode_uri_component(item.path) %>"
17
+ ><%= item.path %></a
18
+ >
19
+ </td>
20
+ <td style="text-align: right"><%= item.number_of_lines %></td>
21
+ <td style="text-align: right"><%= item.number_of_relevancies %></td>
22
+ <td style="text-align: right"><%= item.number_of_hits %></td>
23
+ <td style="text-align: right"><%= item.number_of_misses %></td>
24
+ <td style="text-align: right"><%= '%.2f' % (item.coverage * 100) %></td>
25
+ </tr>
26
+ <% end %>
27
+ </tbody>
28
+ </table>
@@ -0,0 +1,9 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>CoverRage</title>
5
+ </head>
6
+ <body>
7
+ <%= yield %>
8
+ </body>
9
+ </html>
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cover_rage/record'
4
+ require 'erb'
5
+ require 'json'
6
+
7
+ module CoverRage
8
+ class Viewer
9
+ LAYOUT_PAGE = ERB.new(File.read("#{__dir__}/viewer/layout.erb"))
10
+ INDEX_PAGE = ERB.new(File.read("#{__dir__}/viewer/index.erb"))
11
+ FILE_PAGE = ERB.new(File.read("#{__dir__}/viewer/file.erb"))
12
+
13
+ class Item
14
+ attr_reader(
15
+ :path,
16
+ :execution_count,
17
+ :number_of_lines,
18
+ :number_of_hits,
19
+ :number_of_misses,
20
+ :number_of_relevancies,
21
+ :coverage
22
+ )
23
+
24
+ def initialize(record)
25
+ @path = record.path
26
+ @execution_count = record.execution_count
27
+ @number_of_lines = record.execution_count.length
28
+ @number_of_hits = record.execution_count.count { _1&.positive? }
29
+ @number_of_misses = record.execution_count.count { _1&.zero? }
30
+ @number_of_relevancies = record.execution_count.count { !_1.nil? }
31
+ @coverage = @number_of_hits.to_f / @number_of_relevancies
32
+ end
33
+ end
34
+
35
+ def initialize(store:)
36
+ @store = store
37
+ end
38
+
39
+ def call(env)
40
+ template =
41
+ case env['PATH_INFO']
42
+ when '/'
43
+ items =
44
+ @store
45
+ .list
46
+ .map! { Item.new(_1) }
47
+ .sort! do |a, b|
48
+ next -1 if b.coverage.nan?
49
+ next 1 if a.coverage.nan?
50
+
51
+ a.coverage <=> b.coverage
52
+ end
53
+ INDEX_PAGE
54
+ when '/file'
55
+ path = URI.decode_www_form(env['QUERY_STRING']).assoc('path').last
56
+
57
+ record = @store.find(path)
58
+ return [404, {}, []] if record.nil?
59
+
60
+ source_code = record.source
61
+ execution_count = JSON.dump(record.execution_count)
62
+ FILE_PAGE
63
+ when '/clear'
64
+ return [405, {}, []] unless env['REQUEST_METHOD'] == 'DELETE'
65
+
66
+ @store.clear
67
+ return [302, { 'location' => '/' }, []]
68
+ else return [404, {}, []]
69
+ end
70
+ body = layout { template.result(binding) }
71
+ [200, {}, [body]]
72
+ end
73
+
74
+ private
75
+
76
+ def layout
77
+ LAYOUT_PAGE.result(binding)
78
+ end
79
+ end
80
+ end
data/lib/cover_rage.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cover_rage/launcher'
4
+ CoverRage::Launcher.start
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cover_rage
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Weihang Jian
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-04-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '13.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '13.0'
27
+ description: coverage recorder
28
+ email: tonytonyjan@gmail.com
29
+ executables:
30
+ - cover_rage
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - bin/config.ru
35
+ - bin/cover_rage
36
+ - lib/cover_rage.rb
37
+ - lib/cover_rage/config.rb
38
+ - lib/cover_rage/fork_hook.rb
39
+ - lib/cover_rage/launcher.rb
40
+ - lib/cover_rage/record.rb
41
+ - lib/cover_rage/recorder.rb
42
+ - lib/cover_rage/stores/redis.rb
43
+ - lib/cover_rage/stores/sqlite.rb
44
+ - lib/cover_rage/viewer.rb
45
+ - lib/cover_rage/viewer/file.erb
46
+ - lib/cover_rage/viewer/index.erb
47
+ - lib/cover_rage/viewer/layout.erb
48
+ homepage: https://github.com/tonytonyjan/cover_rage
49
+ licenses:
50
+ - MIT
51
+ metadata: {}
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 2.3.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.4.10
68
+ signing_key:
69
+ specification_version: 4
70
+ summary: coverage recorder
71
+ test_files: []