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 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