loupe 0.1.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -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