loupe 0.1.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +71 -0
- data/README.md +101 -0
- data/Rakefile +16 -0
- data/exe/loupe +9 -0
- data/lib/loupe/cli.rb +83 -0
- data/lib/loupe/color.rb +31 -0
- data/lib/loupe/executor.rb +46 -0
- data/lib/loupe/expectation.rb +538 -0
- data/lib/loupe/failure.rb +49 -0
- data/lib/loupe/paged_reporter.rb +195 -0
- data/lib/loupe/plain_reporter.rb +27 -0
- data/lib/loupe/process_executor.rb +56 -0
- data/lib/loupe/queue_server.rb +49 -0
- data/lib/loupe/ractor_executor.rb +52 -0
- data/lib/loupe/rake_task.rb +47 -0
- data/lib/loupe/reporter.rb +106 -0
- data/lib/loupe/test.rb +270 -0
- data/lib/loupe/version.rb +6 -0
- data/lib/loupe.rb +18 -0
- metadata +95 -0
@@ -0,0 +1,195 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "io/console"
|
4
|
+
|
5
|
+
module Loupe
|
6
|
+
# Pager
|
7
|
+
#
|
8
|
+
# This class is responsible for paginating the test failures,
|
9
|
+
# and implementing an interface for interacting with them.
|
10
|
+
class PagedReporter < Reporter # rubocop:disable Metrics/ClassLength
|
11
|
+
# @return [void]
|
12
|
+
def print_summary
|
13
|
+
@current_page = 0
|
14
|
+
@console = IO.console
|
15
|
+
@runtime = Time.now - @start_time
|
16
|
+
@running = true
|
17
|
+
page
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
# Main loop of the pager
|
23
|
+
# Allow users to navigate through pages of test failures
|
24
|
+
# and interact with them.
|
25
|
+
# @return [void]
|
26
|
+
def page
|
27
|
+
while @running
|
28
|
+
@current_failure = @failures[@current_page]
|
29
|
+
@mid_width = @console.winsize[1] / 2
|
30
|
+
header
|
31
|
+
|
32
|
+
if @failures.empty?
|
33
|
+
puts "All tests fixed"
|
34
|
+
@running = false
|
35
|
+
else
|
36
|
+
file_preview
|
37
|
+
menu
|
38
|
+
handle_raw_command
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Read a raw command from the console and match it
|
44
|
+
#
|
45
|
+
# @return [void]
|
46
|
+
def handle_raw_command # rubocop:disable Metrics/CyclomaticComplexity
|
47
|
+
case @console.raw { |c| c.read(1) }
|
48
|
+
when "j"
|
49
|
+
@current_page += 1 unless @current_page == @failures.length - 1
|
50
|
+
when "k"
|
51
|
+
@current_page -= 1 unless @current_page.zero?
|
52
|
+
when "o"
|
53
|
+
open_editor
|
54
|
+
when "f"
|
55
|
+
@failures.delete_at(@current_page)
|
56
|
+
@failure_count -= 1
|
57
|
+
@success_count += 1
|
58
|
+
@current_page -= 1 unless @current_page.zero?
|
59
|
+
when "r"
|
60
|
+
rerun_failure
|
61
|
+
when "q"
|
62
|
+
@running = false
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Print the summary at the top of the screen.
|
67
|
+
# This string has to be updated every time, since the statistics
|
68
|
+
# might change if the user has marked tests as fixed
|
69
|
+
# return [String]
|
70
|
+
def summary
|
71
|
+
<<~SUMMARY
|
72
|
+
Tests: #{@test_count} Expectations: #{@expectation_count}
|
73
|
+
Passed: #{@success_count} Failures: #{@failure_count}
|
74
|
+
|
75
|
+
Finished in #{@runtime} seconds
|
76
|
+
SUMMARY
|
77
|
+
end
|
78
|
+
|
79
|
+
# Prints a bar at the top with the summary of the test run
|
80
|
+
# including totals failures and expectations
|
81
|
+
# @return [void]
|
82
|
+
def header
|
83
|
+
@console.erase_screen(2)
|
84
|
+
@console.cursor = [0, 0]
|
85
|
+
bar = "=" * @console.winsize[1]
|
86
|
+
puts "#{bar}\n#{summary}\n#{bar}\n"
|
87
|
+
end
|
88
|
+
|
89
|
+
# Print the preview of the file where a failure occurred
|
90
|
+
# add a line indicating where exactly it broke
|
91
|
+
# @return [void]
|
92
|
+
def file_preview
|
93
|
+
lines = File.readlines(@current_failure.file_name)
|
94
|
+
|
95
|
+
lines.insert(
|
96
|
+
@current_failure.line_number + 1,
|
97
|
+
"#{indentation_on_failure_line(lines)}^^^ #{@current_failure.message.gsub(/(\[\d;\d{2}m|\[0m)/, '')}"
|
98
|
+
)
|
99
|
+
|
100
|
+
content = lines[@current_failure.line_number - 5..@current_failure.line_number + 5].join("\n")
|
101
|
+
puts content
|
102
|
+
end
|
103
|
+
|
104
|
+
# The indentation on the line where the failure happened
|
105
|
+
# so that the error message can be inserted at the right level
|
106
|
+
# @param lines [Array<String>]
|
107
|
+
# return [String]
|
108
|
+
def indentation_on_failure_line(lines)
|
109
|
+
" " * (lines[@current_failure.line_number].chars.index { |c| c != " " })
|
110
|
+
end
|
111
|
+
|
112
|
+
# @return [void]
|
113
|
+
def menu
|
114
|
+
location, message = @current_failure.location_and_message
|
115
|
+
|
116
|
+
print_on_right_side(7, @status)
|
117
|
+
print_on_right_side(9, location)
|
118
|
+
print_on_right_side(10, message)
|
119
|
+
|
120
|
+
print_on_right_side(12, "Commands")
|
121
|
+
print_on_right_side(14, "j (next)")
|
122
|
+
print_on_right_side(15, "k (previous)")
|
123
|
+
print_on_right_side(16, "o (open in editor)")
|
124
|
+
print_on_right_side(17, "f (mark as fixed)")
|
125
|
+
print_on_right_side(18, "r (rerun selected test)")
|
126
|
+
print_on_right_side(19, "q (quit)")
|
127
|
+
|
128
|
+
@status = nil
|
129
|
+
end
|
130
|
+
|
131
|
+
# The first half of the screen is the file preview.
|
132
|
+
# This helper method assists in printing things on the
|
133
|
+
# other side of the screen.
|
134
|
+
#
|
135
|
+
# Always clear coloring afterwards
|
136
|
+
#
|
137
|
+
# return [void]
|
138
|
+
def print_on_right_side(row, message)
|
139
|
+
@console.cursor = [row, @mid_width + 1]
|
140
|
+
available_length = @console.winsize[1] - @mid_width + 1
|
141
|
+
print message.to_s[0, available_length]
|
142
|
+
print "\033[0m"
|
143
|
+
end
|
144
|
+
|
145
|
+
# Open the editor selected by options (or defined by $EDITOR) with the current
|
146
|
+
# failure being viewed.
|
147
|
+
# @return [void]
|
148
|
+
def open_editor
|
149
|
+
editor = @options[:editor] || ENV["EDITOR"]
|
150
|
+
executable = editor_executable(editor)
|
151
|
+
|
152
|
+
case editor
|
153
|
+
when "vim", "nvim"
|
154
|
+
spawn "#{executable} +#{@current_failure.line_number} #{@current_failure.file_name}"
|
155
|
+
when "code"
|
156
|
+
spawn "#{executable} -g #{@current_failure.file_name}:#{@current_failure.line_number}"
|
157
|
+
else
|
158
|
+
spawn "#{executable} #{@current_failure.file_name}"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# Attempt to find the editor's executable within
|
163
|
+
# the given PATHs
|
164
|
+
# @param editor [String]
|
165
|
+
# @return [String]
|
166
|
+
def editor_executable(editor)
|
167
|
+
ENV["PATH"].split(":").each do |p|
|
168
|
+
path = File.join(p, editor)
|
169
|
+
return path if File.exist?(path)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
# Rerun the current failure
|
174
|
+
#
|
175
|
+
# Since the developer is changing the test file to fix it,
|
176
|
+
# we need to unload it from LOADED_FEATURES and require it again.
|
177
|
+
# Otherwise, we would just be re-running the same test loaded in memory
|
178
|
+
# and it would never pass.
|
179
|
+
#
|
180
|
+
# @return void
|
181
|
+
def rerun_failure
|
182
|
+
$LOADED_FEATURES.delete(@current_failure.file_name)
|
183
|
+
require @current_failure.file_name
|
184
|
+
|
185
|
+
reporter = @current_failure.klass.run(@current_failure.test_name, @options)
|
186
|
+
|
187
|
+
if reporter.failures.empty?
|
188
|
+
@status = "#{@color.p('Fixed', :green)}. Click f to remove from list"
|
189
|
+
else
|
190
|
+
@failures[@current_page] = reporter.failures.first
|
191
|
+
@status = @color.p("Still failing", :red)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Loupe
|
4
|
+
# PlainReporter
|
5
|
+
#
|
6
|
+
# A simple reporter that just prints dots and Fs to
|
7
|
+
# the terminal
|
8
|
+
class PlainReporter < Reporter
|
9
|
+
# @return [void]
|
10
|
+
def print_summary
|
11
|
+
if @failures.empty?
|
12
|
+
report = ""
|
13
|
+
else
|
14
|
+
report = +"\n\n"
|
15
|
+
report << @failures.map!(&:to_s).join("\n")
|
16
|
+
end
|
17
|
+
|
18
|
+
print "\n\n"
|
19
|
+
print <<~SUMMARY
|
20
|
+
Tests: #{@test_count} Expectations: #{@expectation_count}
|
21
|
+
Passed: #{@success_count} Failures: #{@failure_count}#{report}
|
22
|
+
|
23
|
+
Finished in #{Time.now - @start_time} seconds
|
24
|
+
SUMMARY
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "drb/drb"
|
4
|
+
|
5
|
+
module Loupe
|
6
|
+
# ProcessExecutor
|
7
|
+
#
|
8
|
+
# This class is responsible for executing tests in process mode.
|
9
|
+
class ProcessExecutor < Executor
|
10
|
+
# Create a new ProcessExecutor
|
11
|
+
#
|
12
|
+
# This will create a new server object that will be shared
|
13
|
+
# with child processes using DRb
|
14
|
+
# @param options [Hash<Symbol, BasicObject>]
|
15
|
+
# @return [Loupe::Executor]
|
16
|
+
def initialize(options)
|
17
|
+
super
|
18
|
+
|
19
|
+
@server = QueueServer.new(populate_queue, @reporter)
|
20
|
+
@url = DRb.start_service("drbunix:", @server).uri
|
21
|
+
end
|
22
|
+
|
23
|
+
# run
|
24
|
+
#
|
25
|
+
# Fork each one of the process workers and connect with the server
|
26
|
+
# object coming from DRb. Run until the queue is clear
|
27
|
+
# @return [Integer]
|
28
|
+
def run
|
29
|
+
@workers = (0...[Etc.nprocessors, @server.length].min).map do
|
30
|
+
fork do
|
31
|
+
DRb.start_service
|
32
|
+
server = DRbObject.new_with_uri(@url)
|
33
|
+
|
34
|
+
until server.empty?
|
35
|
+
klass, method_name = server.pop
|
36
|
+
server.add_reporter(klass.run(method_name, @options)) if klass && method_name
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
shutdown
|
42
|
+
@reporter.print_summary
|
43
|
+
@reporter.exit_status
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
# Wait until all child processes finish executing tests
|
49
|
+
# and then stop the DRb service
|
50
|
+
# return [void]
|
51
|
+
def shutdown
|
52
|
+
@workers.each { |pid| Process.waitpid(pid) }
|
53
|
+
DRb.stop_service
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Loupe
|
4
|
+
# Server
|
5
|
+
#
|
6
|
+
# This object is the one passed to DRb in order to
|
7
|
+
# communicate between worker and server processes and coordinate
|
8
|
+
# both the queue and the reporting results
|
9
|
+
class QueueServer
|
10
|
+
# The two operations we need to synchronize between the
|
11
|
+
# main process and its children is the queue and the reporter.
|
12
|
+
# We need to share the queue, so that workers can pop the tests from it
|
13
|
+
# and we need to share the reporter, so that workers can update the results
|
14
|
+
#
|
15
|
+
# @param queue [Array<Array<Class, Symbol>>]
|
16
|
+
# @param reporter [Loupe::Reporter]
|
17
|
+
# @return [Loupe::Server]
|
18
|
+
def initialize(queue, reporter)
|
19
|
+
@queue = queue
|
20
|
+
@reporter = reporter
|
21
|
+
end
|
22
|
+
|
23
|
+
# add_reporter
|
24
|
+
#
|
25
|
+
# Adds a temporary reporter from a child process into
|
26
|
+
# the main reporter to aggregate results
|
27
|
+
#
|
28
|
+
# @param other [Loupe::Reporter]
|
29
|
+
# @return [void]
|
30
|
+
def add_reporter(other)
|
31
|
+
@reporter << other
|
32
|
+
end
|
33
|
+
|
34
|
+
# @return [Array<Class, Symbol>]
|
35
|
+
def pop
|
36
|
+
@queue.pop
|
37
|
+
end
|
38
|
+
|
39
|
+
# @return [Integer]
|
40
|
+
def length
|
41
|
+
@queue.length
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return [Boolean]
|
45
|
+
def empty?
|
46
|
+
@queue.empty?
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Loupe
|
4
|
+
# RactorExecutor
|
5
|
+
#
|
6
|
+
# This class is responsible for the execution flow. It populates
|
7
|
+
# the queue of tests to be executed, instantiates the workers,
|
8
|
+
# creates an accumulator reporter and delegates tests to workers
|
9
|
+
# until the queue is empty.
|
10
|
+
class RactorExecutor < Executor
|
11
|
+
# @param options [Hash<Symbol, BasicObject>]
|
12
|
+
# @return [Loupe::Executor]
|
13
|
+
def initialize(options)
|
14
|
+
super
|
15
|
+
@workers = (0...[Etc.nprocessors, @queue.length].min).map do
|
16
|
+
Ractor.new(options) do |opts|
|
17
|
+
loop do
|
18
|
+
klass, method_name = Ractor.receive
|
19
|
+
Ractor.yield klass.run(method_name, opts)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Run the main process for executing tests
|
26
|
+
#
|
27
|
+
# Send the first tests to all workers from the queue and
|
28
|
+
# then keep selecting the idle Ractor until the queue is empty.
|
29
|
+
# Acumulate the reporters as tests are finalized.
|
30
|
+
# The last set of results are obtained outside the loop using `take`,
|
31
|
+
# since once the queue is empty `select` will no longer accumulate the result.
|
32
|
+
#
|
33
|
+
# @return [Integer]
|
34
|
+
def run
|
35
|
+
@workers.each do |r|
|
36
|
+
item = @queue.pop
|
37
|
+
r.send(item) unless item.nil?
|
38
|
+
end
|
39
|
+
|
40
|
+
until @queue.empty?
|
41
|
+
idle_worker, tmp_reporter = Ractor.select(*@workers)
|
42
|
+
@reporter << tmp_reporter
|
43
|
+
idle_worker.send(@queue.pop)
|
44
|
+
end
|
45
|
+
|
46
|
+
@workers.each { |w| @reporter << w.take }
|
47
|
+
|
48
|
+
@reporter.print_summary
|
49
|
+
@reporter.exit_status
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "loupe"
|
4
|
+
require "rake"
|
5
|
+
require "rake/tasklib"
|
6
|
+
|
7
|
+
module Loupe
|
8
|
+
# Loupe's test rake task
|
9
|
+
#
|
10
|
+
# Define a rake task so that we can hook into `rake test`
|
11
|
+
# an run the suite using Loupe. To hook it up, add this to the Rakefile
|
12
|
+
#
|
13
|
+
# require "loupe/rake_task"
|
14
|
+
#
|
15
|
+
# Loupe::RakeTask.new do |options|
|
16
|
+
# options << "--plain"
|
17
|
+
# options << "--ractor"
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# Then run with `bundle exec rake test`
|
21
|
+
#
|
22
|
+
class RakeTask < Rake::TaskLib
|
23
|
+
attr_accessor :name, :description, :libs
|
24
|
+
|
25
|
+
# @return [Loupe::RakeTask]
|
26
|
+
def initialize
|
27
|
+
super
|
28
|
+
|
29
|
+
@name = "test"
|
30
|
+
@description = "Run tests using Loupe"
|
31
|
+
@libs = %w[lib test]
|
32
|
+
@options = []
|
33
|
+
ARGV.shift if ARGV.first == "test"
|
34
|
+
yield(@options)
|
35
|
+
define
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
# @return [Loupe::RakeTask]
|
41
|
+
def define
|
42
|
+
desc @description
|
43
|
+
task(@name) { Loupe::Cli.new(@options) }
|
44
|
+
self
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Loupe's reporter structure is heavily inspired by or adapted from Minitest. The
|
4
|
+
# originals license can be found below.
|
5
|
+
#
|
6
|
+
# Minitest https://github.com/seattlerb/minitest
|
7
|
+
#
|
8
|
+
# (The MIT License)
|
9
|
+
#
|
10
|
+
# Copyright © Ryan Davis, seattle.rb
|
11
|
+
#
|
12
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
13
|
+
# documentation files (the 'Software'), to deal in the Software without restriction, including without limitation
|
14
|
+
# the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
|
15
|
+
# to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
16
|
+
#
|
17
|
+
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of
|
18
|
+
# the Software.
|
19
|
+
#
|
20
|
+
# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
21
|
+
# THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
22
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
23
|
+
# CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
24
|
+
# IN THE SOFTWARE.
|
25
|
+
|
26
|
+
module Loupe
|
27
|
+
# Reporter
|
28
|
+
#
|
29
|
+
# Class that handles reporting test results
|
30
|
+
# and progress.
|
31
|
+
class Reporter
|
32
|
+
# @return [Integer]
|
33
|
+
attr_reader :test_count
|
34
|
+
|
35
|
+
# @return [Integer]
|
36
|
+
attr_reader :expectation_count
|
37
|
+
|
38
|
+
# @return [Integer]
|
39
|
+
attr_reader :success_count
|
40
|
+
|
41
|
+
# @return [Integer]
|
42
|
+
attr_reader :failure_count
|
43
|
+
|
44
|
+
# @return [Array<Loupe::Failure>]
|
45
|
+
attr_reader :failures
|
46
|
+
|
47
|
+
# @param options [Hash<Symbol, BasicObject>]
|
48
|
+
# @return [Loupe::Reporter]
|
49
|
+
def initialize(options = {})
|
50
|
+
@options = options
|
51
|
+
@color = Color.new(options[:color])
|
52
|
+
@options = options
|
53
|
+
@test_count = 0
|
54
|
+
@expectation_count = 0
|
55
|
+
@success_count = 0
|
56
|
+
@failure_count = 0
|
57
|
+
@failures = []
|
58
|
+
@start_time = Time.now
|
59
|
+
end
|
60
|
+
|
61
|
+
# @return [void]
|
62
|
+
def increment_test_count
|
63
|
+
@test_count += 1
|
64
|
+
end
|
65
|
+
|
66
|
+
# @return [void]
|
67
|
+
def increment_expectation_count
|
68
|
+
@expectation_count += 1
|
69
|
+
end
|
70
|
+
|
71
|
+
# @return [void]
|
72
|
+
def increment_success_count
|
73
|
+
print(@color.p(".", :green))
|
74
|
+
@success_count += 1
|
75
|
+
end
|
76
|
+
|
77
|
+
# @param test [Loupe::Test]
|
78
|
+
# @return [void]
|
79
|
+
def increment_failure_count(test, message)
|
80
|
+
print(@color.p("F", :red))
|
81
|
+
@failures << Failure.new(test, message)
|
82
|
+
@failure_count += 1
|
83
|
+
end
|
84
|
+
|
85
|
+
# @param other [Loupe::Reporter]
|
86
|
+
# @return [Loupe::Reporter]
|
87
|
+
def <<(other)
|
88
|
+
@test_count += other.test_count
|
89
|
+
@expectation_count += other.expectation_count
|
90
|
+
@success_count += other.success_count
|
91
|
+
@failure_count += other.failure_count
|
92
|
+
@failures.concat(other.failures)
|
93
|
+
self
|
94
|
+
end
|
95
|
+
|
96
|
+
# @return [Integer]
|
97
|
+
def exit_status
|
98
|
+
@failure_count.zero? ? 0 : 1
|
99
|
+
end
|
100
|
+
|
101
|
+
# @return [void]
|
102
|
+
def print_summary
|
103
|
+
raise NotImplementedError, "Print must be implemented in the inheriting reporter class"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|