cover_rage 0.0.3 → 0.0.4
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 +4 -4
- data/lib/cover_rage/config.rb +37 -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/reporters/html_reporter/index.html.erb +215 -0
- data/lib/cover_rage/reporters/html_reporter.rb +15 -0
- data/lib/cover_rage/reporters/json_reporter.rb +13 -0
- data/lib/cover_rage/stores/redis.rb +49 -0
- data/lib/cover_rage/stores/sqlite.rb +77 -0
- metadata +11 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 56c953122e72d8aaa3a4a50129595cfc36fb20f1c2feea245b255295a5ba8a90
|
4
|
+
data.tar.gz: 7b5b4972c31670c4c9ab6b5298682ce7b8d258f4a0ff31faf8790856f1691428
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 26cd15a4e159851270b8c9b351e1047049eb509e1ebc322634d94ab8c53f2e8e42d22ca4521c07af7d784e3d8fa5993fc59e57bbeba0d2c49fa83b86ba5b5a88
|
7
|
+
data.tar.gz: 38e670bed372e6117c64967fc32a124e2dcac34734e14f05e954b282de01cd557af0847a69c16474e3e13c4352ec57a878d96b95925fc08c88612a5774b6c89c
|
@@ -0,0 +1,37 @@
|
|
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
|
+
|
33
|
+
def self.disable?
|
34
|
+
@disable ||= ENV.key?('COVER_RAGE_DISABLE')
|
35
|
+
end
|
36
|
+
end
|
37
|
+
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,215 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="UTF-8" />
|
5
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7
|
+
<title>CoverRage</title>
|
8
|
+
<link
|
9
|
+
rel="stylesheet"
|
10
|
+
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css"
|
11
|
+
/>
|
12
|
+
<style>
|
13
|
+
.line {
|
14
|
+
width: 100%;
|
15
|
+
display: inline-block;
|
16
|
+
counter-increment: line_number;
|
17
|
+
}
|
18
|
+
|
19
|
+
.green {
|
20
|
+
background-color: rgb(221, 255, 221);
|
21
|
+
}
|
22
|
+
|
23
|
+
.red {
|
24
|
+
background-color: rgb(255, 212, 212);
|
25
|
+
}
|
26
|
+
|
27
|
+
.number {
|
28
|
+
display: inline-block;
|
29
|
+
text-align: right;
|
30
|
+
margin-right: 0.5em;
|
31
|
+
background-color: lightgray;
|
32
|
+
padding: 0 0.5em 0 1.5em;
|
33
|
+
}
|
34
|
+
|
35
|
+
.nav {
|
36
|
+
display: flex;
|
37
|
+
list-style: none;
|
38
|
+
padding-left: 0;
|
39
|
+
}
|
40
|
+
.nav > * {
|
41
|
+
margin-left: 8px;
|
42
|
+
}
|
43
|
+
</style>
|
44
|
+
</head>
|
45
|
+
<body>
|
46
|
+
<main id="main"></main>
|
47
|
+
|
48
|
+
<div style="display: none">
|
49
|
+
<div id="page-index">
|
50
|
+
<nav>
|
51
|
+
<ul class="nav">
|
52
|
+
<li><a href="#/">Index</a></li>
|
53
|
+
</ul>
|
54
|
+
</nav>
|
55
|
+
<table>
|
56
|
+
<thead>
|
57
|
+
<tr>
|
58
|
+
<th>path</th>
|
59
|
+
<th>lines</th>
|
60
|
+
<th>relevancy</th>
|
61
|
+
<th>hit</th>
|
62
|
+
<th>miss</th>
|
63
|
+
<th>coverage (%)</th>
|
64
|
+
</tr>
|
65
|
+
</thead>
|
66
|
+
<tbody></tbody>
|
67
|
+
</table>
|
68
|
+
</div>
|
69
|
+
<div id="page-file">
|
70
|
+
<nav>
|
71
|
+
<ul class="nav">
|
72
|
+
<li><a href="#/">Index</a></li>
|
73
|
+
<li>></li>
|
74
|
+
<li id="title"></li>
|
75
|
+
</ul>
|
76
|
+
</nav>
|
77
|
+
<pre><code id="code"></code></pre>
|
78
|
+
</div>
|
79
|
+
</div>
|
80
|
+
|
81
|
+
<script id="records" type="application/json"><%= records %></script>
|
82
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
|
83
|
+
<script>
|
84
|
+
const main = document.getElementById("main");
|
85
|
+
const page_index = document.getElementById("page-index");
|
86
|
+
const title = document.createTextNode("");
|
87
|
+
document.getElementById("title").appendChild(title);
|
88
|
+
const code = document.getElementById("code");
|
89
|
+
const page_file = document.getElementById("page-file");
|
90
|
+
const records = JSON.parse(
|
91
|
+
document.getElementById("records").childNodes[0].nodeValue
|
92
|
+
);
|
93
|
+
|
94
|
+
const route = (path) => {
|
95
|
+
if (path.startsWith("/file/")) {
|
96
|
+
const filepath = path.slice(6);
|
97
|
+
if (records.find((record) => record.path === filepath))
|
98
|
+
return { page: "file", path: filepath };
|
99
|
+
}
|
100
|
+
|
101
|
+
return { page: "index" };
|
102
|
+
};
|
103
|
+
|
104
|
+
const render = (routing) => {
|
105
|
+
let page;
|
106
|
+
if (routing.page === "file") {
|
107
|
+
const record = records.find(({ path }) => path === routing.path);
|
108
|
+
page = page_file;
|
109
|
+
title.nodeValue = routing.path;
|
110
|
+
const digit_width =
|
111
|
+
Math.floor(Math.log10(Math.max(...record.execution_count))) + 1;
|
112
|
+
code.innerHTML = hljs
|
113
|
+
.highlight(
|
114
|
+
records.find(({ path }) => path === routing.path).source,
|
115
|
+
{
|
116
|
+
language: "ruby",
|
117
|
+
}
|
118
|
+
)
|
119
|
+
.value.split("\n")
|
120
|
+
.map((line, index) => {
|
121
|
+
const value = record.execution_count[index];
|
122
|
+
let color = "";
|
123
|
+
if (typeof value === "number")
|
124
|
+
color = value > 0 ? "green" : "red";
|
125
|
+
const number = typeof value === "number" ? value : "-";
|
126
|
+
return `<span class="line ${color}"><span class="number">${number
|
127
|
+
.toString()
|
128
|
+
.padStart(digit_width, " ")}</span>${line}</span>`;
|
129
|
+
})
|
130
|
+
.join("\n");
|
131
|
+
}
|
132
|
+
if (routing.page === "index") {
|
133
|
+
page = page_index;
|
134
|
+
}
|
135
|
+
if (main.childNodes.length === 0) main.appendChild(page);
|
136
|
+
else main.replaceChild(page, main.childNodes[0]);
|
137
|
+
};
|
138
|
+
const createTdTextNode = (text) => {
|
139
|
+
const td = document.createElement("td");
|
140
|
+
td.appendChild(document.createTextNode(text));
|
141
|
+
return td;
|
142
|
+
};
|
143
|
+
|
144
|
+
page_index.querySelector("tbody").appendChild(
|
145
|
+
records
|
146
|
+
.map(({ path, revision, source, execution_count }) => {
|
147
|
+
const hit = execution_count.reduce(
|
148
|
+
(accumulator, current) =>
|
149
|
+
current > 0 ? accumulator + 1 : accumulator,
|
150
|
+
0
|
151
|
+
);
|
152
|
+
const miss = execution_count.reduce(
|
153
|
+
(accumulator, current) =>
|
154
|
+
current === 0 ? accumulator + 1 : accumulator,
|
155
|
+
0
|
156
|
+
);
|
157
|
+
const relevancy = execution_count.reduce(
|
158
|
+
(accumulator, current) =>
|
159
|
+
current !== null ? accumulator + 1 : accumulator,
|
160
|
+
0
|
161
|
+
);
|
162
|
+
const coverage = hit / relevancy;
|
163
|
+
return {
|
164
|
+
path,
|
165
|
+
lines: execution_count.length,
|
166
|
+
hit,
|
167
|
+
miss,
|
168
|
+
relevancy,
|
169
|
+
coverage,
|
170
|
+
};
|
171
|
+
})
|
172
|
+
.sort(({ coverage: a }, { coverage: b }) => {
|
173
|
+
if (isNaN(b)) return -1;
|
174
|
+
if (isNaN(a)) return 1;
|
175
|
+
return a - b;
|
176
|
+
})
|
177
|
+
.reduce(
|
178
|
+
(fragment, { path, lines, hit, miss, relevancy, coverage }) => {
|
179
|
+
const tr = document.createElement("tr");
|
180
|
+
tr.appendChild(
|
181
|
+
(() => {
|
182
|
+
const anchor = document.createElement("a");
|
183
|
+
anchor.href = `#/file/${path}`;
|
184
|
+
anchor.appendChild(document.createTextNode(path));
|
185
|
+
const td = document.createElement("td");
|
186
|
+
td.appendChild(anchor);
|
187
|
+
return td;
|
188
|
+
})()
|
189
|
+
);
|
190
|
+
tr.appendChild(createTdTextNode(lines));
|
191
|
+
tr.appendChild(createTdTextNode(relevancy));
|
192
|
+
tr.appendChild(createTdTextNode(hit));
|
193
|
+
tr.appendChild(createTdTextNode(miss));
|
194
|
+
tr.appendChild(
|
195
|
+
createTdTextNode(Math.round(coverage * 10000) / 100)
|
196
|
+
);
|
197
|
+
fragment.appendChild(tr);
|
198
|
+
return fragment;
|
199
|
+
},
|
200
|
+
document.createDocumentFragment()
|
201
|
+
)
|
202
|
+
);
|
203
|
+
|
204
|
+
document.addEventListener("DOMContentLoaded", () => {
|
205
|
+
const routing = route(window.location.hash.slice(1));
|
206
|
+
render(routing);
|
207
|
+
});
|
208
|
+
|
209
|
+
window.addEventListener("popstate", (event) => {
|
210
|
+
const routing = route(window.location.hash.slice(1));
|
211
|
+
render(routing);
|
212
|
+
});
|
213
|
+
</script>
|
214
|
+
</body>
|
215
|
+
</html>
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'erb'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module CoverRage
|
7
|
+
module Reporters
|
8
|
+
class HtmlReporter
|
9
|
+
def report(records)
|
10
|
+
records = JSON.dump(records.map(&:to_h))
|
11
|
+
ERB.new(File.read("#{__dir__}/html_reporter/index.html.erb")).result(binding)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'cover_rage/record'
|
4
|
+
require 'redis'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module CoverRage
|
8
|
+
module Stores
|
9
|
+
class Redis
|
10
|
+
KEY = 'cover_rage_records'
|
11
|
+
def initialize(url)
|
12
|
+
@redis = ::Redis.new(url: url)
|
13
|
+
end
|
14
|
+
|
15
|
+
def import(records)
|
16
|
+
loop do
|
17
|
+
break if @redis.watch(KEY) do
|
18
|
+
records_to_save = Record.merge(list, records)
|
19
|
+
if records_to_save.any?
|
20
|
+
arguments = []
|
21
|
+
records_to_save.each do |record|
|
22
|
+
arguments.push(record.path, JSON.dump(record.to_h))
|
23
|
+
end
|
24
|
+
@redis.multi { _1.hset(KEY, *arguments) }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def find(path)
|
31
|
+
result = @redis.hget(KEY, path)
|
32
|
+
return nil if result.nil?
|
33
|
+
|
34
|
+
Record.new(**JSON.parse(result))
|
35
|
+
end
|
36
|
+
|
37
|
+
def list
|
38
|
+
result = @redis.hgetall(KEY)
|
39
|
+
return [] if result.empty?
|
40
|
+
|
41
|
+
result.map { |_, value| Record.new(**JSON.parse(value)) }
|
42
|
+
end
|
43
|
+
|
44
|
+
def clear
|
45
|
+
@redis.del(KEY)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
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
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: cover_rage
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Weihang Jian
|
@@ -52,6 +52,16 @@ extra_rdoc_files: []
|
|
52
52
|
files:
|
53
53
|
- bin/cover_rage
|
54
54
|
- lib/cover_rage.rb
|
55
|
+
- lib/cover_rage/config.rb
|
56
|
+
- lib/cover_rage/fork_hook.rb
|
57
|
+
- lib/cover_rage/launcher.rb
|
58
|
+
- lib/cover_rage/record.rb
|
59
|
+
- lib/cover_rage/recorder.rb
|
60
|
+
- lib/cover_rage/reporters/html_reporter.rb
|
61
|
+
- lib/cover_rage/reporters/html_reporter/index.html.erb
|
62
|
+
- lib/cover_rage/reporters/json_reporter.rb
|
63
|
+
- lib/cover_rage/stores/redis.rb
|
64
|
+
- lib/cover_rage/stores/sqlite.rb
|
55
65
|
homepage: https://github.com/tonytonyjan/cover_rage
|
56
66
|
licenses:
|
57
67
|
- MIT
|