loupe 0.1.5

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