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 +7 -0
- data/bin/config.ru +5 -0
- data/bin/cover_rage +5 -0
- data/lib/cover_rage/config.rb +33 -0
- data/lib/cover_rage/fork_hook.rb +11 -0
- data/lib/cover_rage/launcher.rb +25 -0
- data/lib/cover_rage/record.rb +27 -0
- data/lib/cover_rage/recorder.rb +65 -0
- data/lib/cover_rage/stores/redis.rb +48 -0
- data/lib/cover_rage/stores/sqlite.rb +77 -0
- data/lib/cover_rage/viewer/file.erb +51 -0
- data/lib/cover_rage/viewer/index.erb +28 -0
- data/lib/cover_rage/viewer/layout.erb +9 -0
- data/lib/cover_rage/viewer.rb +80 -0
- data/lib/cover_rage.rb +4 -0
- metadata +71 -0
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
data/bin/cover_rage
ADDED
@@ -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,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,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
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: []
|