cover_rage 0.0.1

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.
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: []