cover_rage 0.0.3 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f3618b26d2fc9a7c3f32354d8e30bed52f529d9d6f4de7c648a66245300e1c4
4
- data.tar.gz: 4bfb79ee930c1886138dc25d90d804bed14e200e0576fc63c3aee9693ca348a9
3
+ metadata.gz: 56c953122e72d8aaa3a4a50129595cfc36fb20f1c2feea245b255295a5ba8a90
4
+ data.tar.gz: 7b5b4972c31670c4c9ab6b5298682ce7b8d258f4a0ff31faf8790856f1691428
5
5
  SHA512:
6
- metadata.gz: 6ea002a43c449f4c7b2f0c323e30c1f1354a8bbdcf10ca3659262acb62ace07138f64cb80a34e1d2a47d07055f5a6b28902d40d3e4234a36fe25322755c179c7
7
- data.tar.gz: 93a1ea6738c01fcf076fabc73f6ca0f7708e0db1897efec3944ad1cbdcd33419b42807988356441e564646db51fa72936d82ab0263b0b555a3658447d31e3ee9
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,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,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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module CoverRage
6
+ module Reporters
7
+ class JsonReporter
8
+ def report(records)
9
+ JSON.dump(records.map(&:to_h))
10
+ end
11
+ end
12
+ end
13
+ 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.3
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