mighty_test 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 83c44a15f432d51e09d5c772fd11bb8e6054eaf98cbcc76e08e8f8050c1e03bd
4
+ data.tar.gz: 5908e6428920eb6aedcc993a24ad44587b7e6355f9002b07358c095717f865f6
5
+ SHA512:
6
+ metadata.gz: 912d2cc77b7b732439f129ce082d140d15216221aa8fd8c4e0a6a9082c3129c238bb29f8841303d12dfe6c43b96f4c37f76090bada21114843b5c4db933ea9b2
7
+ data.tar.gz: 4d1468bc39fa04b7445b97bb58e559d798e3978067a29ad841ba82d3fbe2b6c893592231116f5fa41df3d567cbd352aea49b57fd098b0cf04db3d11bfb2f58e5
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Matt Brictson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # mighty_test
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/mighty_test)](https://rubygems.org/gems/mighty_test)
4
+ [![Gem Downloads](https://img.shields.io/gem/dt/mighty_test)](https://www.ruby-toolbox.com/projects/mighty_test)
5
+ [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/mattbrictson/mighty_test/ci.yml)](https://github.com/mattbrictson/mighty_test/actions/workflows/ci.yml)
6
+ [![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/mattbrictson/mighty_test)](https://codeclimate.com/github/mattbrictson/mighty_test)
7
+
8
+ mighty_test (`mt`) is a TDD-friendly Minitest runner for Ruby projects. It includes a Jest-inspired interactive watch mode, focus mode, CI sharding, run by directory/file/line number, fail-fast, and color formatting.
9
+
10
+ ---
11
+
12
+ **Quick Start**
13
+
14
+ - [Install](#install)
15
+ - [Requirements](#requirements)
16
+ - [Usage](#usage)
17
+
18
+ **Features**
19
+
20
+ - ⚙️ [CI Mode](#%EF%B8%8F-ci-mode)
21
+ - 🧑‍🔬 [Watch Mode](#-watch-mode)
22
+ - 🔬 [Focus Mode](#-focus-mode)
23
+ - 🛑 [Fail Fast](#-fail-fast)
24
+ - 🚥 [Color Output](#-color-output)
25
+ - 💬 [More Options](#-more-options)
26
+
27
+ **Community**
28
+
29
+ - [Support](#support)
30
+ - [License](#license)
31
+ - [Code of conduct](#code-of-conduct)
32
+ - [Contribution guide](#contribution-guide)
33
+
34
+ ## Install
35
+
36
+ The mighty_test gem provides an `mt` binary. To install it into a Ruby project, first add the gem to your Gemfile and run `bundle install`.
37
+
38
+ ```ruby
39
+ gem "mighty_test"
40
+ ```
41
+
42
+ Then generate a binstub:
43
+
44
+ ```sh
45
+ bundle binstub mighty_test
46
+ ```
47
+
48
+ Now you can run mighty_test with `bin/mt`.
49
+
50
+ > [!TIP]
51
+ > **When installing mighty_test in a Rails project, make sure to put the gem in the `:test` Gemfile group.** Although Rails has a built-in test runner (`bin/rails test`) that already provides a lot of what mighty_test offers, you can still use `bin/mt` with Rails projects for its unique `--watch` mode and CI `--shard` feature.
52
+
53
+ ## Requirements
54
+
55
+ mighty_test requires modern versions of Minitest and Ruby.
56
+
57
+ - Minitest 5.15+
58
+ - Ruby 3.1+
59
+
60
+ Support for older Ruby versions will be dropped when they reach EOL. The EOL schedule can be found here: https://endoflife.date/ruby
61
+
62
+ > [!NOTE]
63
+ > mighty_test currently assumes that your tests are stored in `test/` and are named `*_test.rb`. Watch mode expects implementation files to be in `app/` and/or `lib/`.
64
+
65
+ ## Usage
66
+
67
+ `mt` defaults to running all tests, excluding slow tests (see the explanation of slow tests below). You can also run tests by directory, file, or line number.
68
+
69
+ ```sh
70
+ # Run all tests, excluding slow tests
71
+ bin/mt
72
+
73
+ # Run all tests, slow tests included
74
+ bin/mt --all
75
+
76
+ # Run a specific test file
77
+ bin/mt test/cli_test.rb
78
+
79
+ # Run a test by line number
80
+ bin/mt test/importer_test.rb:43
81
+
82
+ # Run a directory of tests
83
+ bin/mt test/commands
84
+ ```
85
+
86
+ > [!TIP]
87
+ > mighty_test is optimized for TDD, and excludes slow tests by default. **Slow tests** are defined as those found in `test/{e2e,feature,features,integration,system}` directories. You can run slow tests with `--all` or by specifying a slow test file or directory explicitly, like `bin/mt test/system`.
88
+
89
+ ## ⚙️ CI Mode
90
+
91
+ If the `CI` environment variable is set, mighty_test defaults to running _all_ tests, including slow tests. This is equivalent to passing `--all`.
92
+
93
+ mighty_test can also distribute test files evenly across parallel CI jobs, using the `--shard` option. The _shard_ nomenclature has been borrowed from similar features in [Jest](https://jestjs.io/docs/cli#--shard) and [Playwright](https://playwright.dev/docs/test-sharding).
94
+
95
+ ```sh
96
+ # Run the 1st group of tests out of 4 total groups
97
+ bin/mt --shard 1/4
98
+ ```
99
+
100
+ In GitHub Actions, for example, you can use `--shard` with a matrix strategy to easily divide tests across N jobs.
101
+
102
+ ```yaml
103
+ jobs:
104
+ test:
105
+ strategy:
106
+ matrix:
107
+ shard:
108
+ - "1/4"
109
+ - "2/4"
110
+ - "3/4"
111
+ - "4/4"
112
+ steps:
113
+ - uses: actions/checkout@v4
114
+ - uses: ruby/setup-ruby@v1
115
+ with:
116
+ bundler-cache: true
117
+ - run: bin/mt --shard ${{ matrix.shard }}
118
+ ```
119
+
120
+ In CircleCI, you can use the `parallelism` setting, which automatically injects `$CIRCLE_NODE_INDEX` and `$CIRCLE_NODE_TOTAL` environment variables. Note that `$CIRCLE_NODE_INDEX` is zero-indexed, so it needs to be incremented by 1.
121
+
122
+ ```yaml
123
+ jobs:
124
+ test:
125
+ parallelism: 4
126
+ steps:
127
+ - checkout
128
+ - ruby/install-deps
129
+ - run: SHARD="$((${CIRCLE_NODE_INDEX}+1))"; bin/mt --shard ${SHARD}/${CIRCLE_NODE_TOTAL}
130
+ ```
131
+
132
+ > [!TIP]
133
+ > `--shard` will shuffle tests and automatically distribute slow tests evenly across jobs.
134
+
135
+ ## 🧑‍🔬 Watch Mode
136
+
137
+ mighty_test includes a Jest-style watch mode, which can be started with `--watch`. This is ideal for TDD.
138
+
139
+ ```sh
140
+ # Start watch mode
141
+ bin/mt --watch
142
+ ```
143
+
144
+ In watch mode, mighty_test will listen for file system activity and run a test file whenever it is modified.
145
+
146
+ When you modify an implementation file, mighty_test will find the corresponding test file and run it automatically. This works as long as your implementation and test files follow a standard path naming convention: e.g. `lib/commands/init.rb` is expected to have a corresponding test file named `test/commands/init_test.rb`.
147
+
148
+ Watch mode also offers a menu of interactive commands:
149
+
150
+ ```
151
+ > Press Enter to run all tests.
152
+ > Press "a" to run all tests, including slow tests.
153
+ > Press "d" to run tests for files diffed or added since the last git commit.
154
+ > Press "h" to show this help menu.
155
+ > Press "q" to quit.
156
+ ```
157
+
158
+ ## 🔬 Focus Mode
159
+
160
+ You can focus a specific test by annotating the method definition with `focus`.
161
+
162
+ ```ruby
163
+ class MyTest < Minitest::Test
164
+ focus def test_something_important
165
+ assert # ...
166
+ end
167
+ ```
168
+
169
+ Now running `bin/mt` will execute only the focused test:
170
+
171
+ ```sh
172
+ # Only runs MyTest#test_something_important
173
+ bin/mt
174
+ ```
175
+
176
+ In Rails projects that use the `test` syntax, `focus` must be placed on the previous line.
177
+
178
+ ```ruby
179
+ class MyTest < ActiveSupport::TestCase
180
+ focus
181
+ test "something important" do
182
+ assert # ...
183
+ end
184
+ ```
185
+
186
+ This functionality is provided by the [minitest-focus](https://github.com/minitest/minitest-focus) plugin, which is included with mighty_test.
187
+
188
+ ## 🛑 Fail Fast
189
+
190
+ By default, mighty_test runs the entire test suite to completion. With the `--fail-fast` option, it will stop on the first failed test.
191
+
192
+ ```sh
193
+ # Stop immediately on first test failure
194
+ bin/mt --fail-fast
195
+
196
+ # Use with watch mode for even faster TDD
197
+ bin/mt --watch --fail-fast
198
+ ```
199
+
200
+ This functionality is provided by the [minitest-fail-fast](https://github.com/teoljungberg/minitest-fail-fast) plugin, which is included with mighty_test.
201
+
202
+ ## 🚥 Color Output
203
+
204
+ Successes, failures, errors, and skips are colored appropriately by default.
205
+
206
+ ```sh
207
+ # Run tests with color output (if terminal supports it)
208
+ bin/mt
209
+
210
+ # Disable color
211
+ bin/mt --no-rg
212
+ ```
213
+
214
+ (image goes here)
215
+
216
+ This functionality is provided by the [minitest-rg](https://github.com/minitest/minitest-rg) plugin, which is included with mighty_test.
217
+
218
+ ## 💬 More Options
219
+
220
+ Minitest options are passed through to Minitest.
221
+
222
+ ```sh
223
+ # Run tests with Minitest pride color output
224
+ bin/mt --pride
225
+
226
+ # Run tests with an explicit seed value for test ordering
227
+ bin/mt --seed 4519
228
+
229
+ # Run tests with detailed progress and explanation of skipped tests
230
+ bin/mt --verbose
231
+
232
+ # Show the full list of possible options
233
+ bin/mt --help
234
+ ```
235
+
236
+ If you have Minitest extensions installed, like [minitest-snapshots](https://github.com/mattbrictson/minitest-snapshots), the command line options of those extensions are supported as well.
237
+
238
+ ```sh
239
+ # Update snapshots
240
+ bin/mt -u
241
+ ```
242
+
243
+ ## Support
244
+
245
+ If you want to report a bug, or have ideas, feedback or questions about the gem, [let me know via GitHub issues](https://github.com/mattbrictson/mighty_test/issues/new) and I will do my best to provide a helpful answer. Happy hacking!
246
+
247
+ ## License
248
+
249
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
250
+
251
+ ## Code of conduct
252
+
253
+ Everyone interacting in this project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
254
+
255
+ ## Contribution guide
256
+
257
+ Pull requests are welcome!
data/exe/mt ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "mighty_test"
4
+ MightyTest::CLI.new.run
@@ -0,0 +1,99 @@
1
+ module MightyTest
2
+ class CLI
3
+ def initialize(file_system: FileSystem.new, env: ENV, option_parser: OptionParser.new, runner: MinitestRunner.new)
4
+ @file_system = file_system
5
+ @env = env.to_h
6
+ @option_parser = option_parser
7
+ @runner = runner
8
+ end
9
+
10
+ def run(argv: ARGV)
11
+ @path_args, @extra_args, @options = option_parser.parse(argv)
12
+
13
+ if options[:help]
14
+ print_help
15
+ elsif options[:version]
16
+ puts VERSION
17
+ elsif options[:watch]
18
+ watch
19
+ elsif path_args.grep(/.:\d+$/).any?
20
+ run_test_by_line_number
21
+ else
22
+ run_tests_by_path
23
+ end
24
+ rescue Exception => e # rubocop:disable Lint/RescueException
25
+ handle_exception(e)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :file_system, :env, :path_args, :extra_args, :options, :option_parser, :runner
31
+
32
+ def print_help
33
+ # Minitest already prints the `-h, --help` option, so omit mighty_test's
34
+ puts option_parser.to_s.sub(/^\s*-h.*?\n/, "")
35
+ puts
36
+ runner.print_help_and_exit!
37
+ end
38
+
39
+ def watch
40
+ Watcher.new(extra_args:).run
41
+ end
42
+
43
+ def run_test_by_line_number
44
+ path, line = path_args.first.match(/^(.+):(\d+)$/).captures
45
+ test_name = TestParser.new(path).test_name_at_line(line.to_i)
46
+
47
+ if test_name
48
+ run_tests_and_exit!(path, flags: ["-n", "/^#{Regexp.quote(test_name)}$/"])
49
+ else
50
+ run_tests_and_exit!
51
+ end
52
+ end
53
+
54
+ def run_tests_by_path
55
+ test_paths = find_test_paths
56
+ test_paths = excluding_slow_paths(test_paths) unless path_args.any? || ci? || options[:all]
57
+ test_paths = Sharder.from_argv(options[:shard], env:, file_system:).shard(test_paths) if options[:shard]
58
+
59
+ run_tests_and_exit!(*test_paths)
60
+ end
61
+
62
+ def excluding_slow_paths(test_paths)
63
+ test_paths.reject { |path| file_system.slow_test_path?(path) }
64
+ end
65
+
66
+ def ci?
67
+ !env["CI"].to_s.strip.empty?
68
+ end
69
+
70
+ def find_test_paths
71
+ return file_system.find_test_paths if path_args.empty?
72
+
73
+ path_args.flat_map do |path|
74
+ if Dir.exist?(path)
75
+ file_system.find_test_paths(path)
76
+ elsif File.exist?(path)
77
+ [path]
78
+ else
79
+ raise ArgumentError, "#{path} does not exist"
80
+ end
81
+ end
82
+ end
83
+
84
+ def run_tests_and_exit!(*test_paths, flags: [])
85
+ runner.run_inline_and_exit!(*test_paths, args: extra_args + flags)
86
+ end
87
+
88
+ def handle_exception(e) # rubocop:disable Naming/MethodParameterName
89
+ case e
90
+ when SignalException
91
+ exit(128 + e.signo)
92
+ when Errno::EPIPE
93
+ # pass
94
+ else
95
+ raise e
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,58 @@
1
+ require "io/console"
2
+
3
+ module MightyTest
4
+ class Console
5
+ def initialize(stdin: $stdin, sound_player: "/usr/bin/afplay", sound_paths: SOUNDS)
6
+ @stdin = stdin
7
+ @sound_player = sound_player
8
+ @sound_paths = sound_paths
9
+ end
10
+
11
+ def clear
12
+ return false unless tty?
13
+
14
+ $stdout.clear_screen
15
+ true
16
+ end
17
+
18
+ def wait_for_keypress
19
+ return stdin.getc unless stdin.respond_to?(:raw) && tty?
20
+
21
+ stdin.raw(intr: true) { stdin.getc }
22
+ end
23
+
24
+ def play_sound(name, wait: false)
25
+ return false unless tty?
26
+
27
+ paths = sound_paths.fetch(name) { raise ArgumentError, "Unknown sound name #{name}" }
28
+ path = paths.find { |p| File.exist?(p) }
29
+ return false unless path && File.executable?(sound_player)
30
+
31
+ thread = Thread.new { system(sound_player, path) }
32
+ thread.join if wait
33
+ true
34
+ end
35
+
36
+ private
37
+
38
+ # rubocop:disable Layout/LineLength
39
+ SOUNDS = {
40
+ pass: %w[
41
+ /System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/EncoreInfinitum/Milestone-EncoreInfinitum.caf
42
+ /System/Library/Sounds/Glass.aiff
43
+ ],
44
+ fail: %w[
45
+ /System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/EncoreInfinitum/Rebound-EncoreInfinitum.caf
46
+ /System/Library/Sounds/Bottle.aiff
47
+ ]
48
+ }.freeze
49
+ private_constant :SOUNDS
50
+ # rubocop:enable Layout/LineLength
51
+
52
+ attr_reader :sound_player, :sound_paths, :stdin
53
+
54
+ def tty?
55
+ $stdout.respond_to?(:tty?) && $stdout.tty?
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,41 @@
1
+ require "open3"
2
+
3
+ module MightyTest
4
+ class FileSystem
5
+ def listen(&)
6
+ require "listen"
7
+ Listen.to(*%w[app lib test].select { |p| Dir.exist?(p) }, relative: true, &).tap(&:start)
8
+ end
9
+
10
+ def find_matching_test_path(path) # rubocop:disable Metrics/CyclomaticComplexity
11
+ return nil unless path && File.exist?(path) && !Dir.exist?(path)
12
+ return path if path.match?(%r{^test/.*_test.rb$})
13
+
14
+ test_path = path[%r{^(?:app|lib)/(.+)\.[^\.]+$}, 1]&.then { "test/#{_1}_test.rb" }
15
+ test_path if test_path && File.exist?(test_path)
16
+ end
17
+
18
+ def find_test_paths(directory="test")
19
+ glob = File.join(directory, "**/*_test.rb")
20
+ Dir[glob]
21
+ end
22
+
23
+ def slow_test_path?(path)
24
+ return false if path.nil?
25
+
26
+ path.match?(%r{^test/(e2e|feature|features|integration|system)/})
27
+ end
28
+
29
+ def find_new_and_changed_paths
30
+ out, _err, status = Open3.capture3(*%w[git status --porcelain=1 -uall -z --no-renames -- test app lib])
31
+ return [] unless status.success?
32
+
33
+ out
34
+ .split("\x0")
35
+ .filter_map { |line| line[/^.. (.+)/, 1] }
36
+ .uniq
37
+ rescue SystemCallError
38
+ []
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,22 @@
1
+ module MightyTest
2
+ class MinitestRunner
3
+ def print_help_and_exit!
4
+ require "minitest"
5
+ Minitest.run(["--help"])
6
+ exit
7
+ end
8
+
9
+ def run_inline_and_exit!(*test_files, args: [])
10
+ $LOAD_PATH.unshift "test"
11
+ ARGV.replace(Array(args))
12
+
13
+ require "minitest/focus"
14
+ require "minitest/rg"
15
+
16
+ test_files.flatten.each { |file| require File.expand_path(file.to_s) }
17
+
18
+ require "minitest/autorun"
19
+ exit
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,77 @@
1
+ require "shellwords"
2
+
3
+ module MightyTest
4
+ class OptionParser
5
+ def parse(argv)
6
+ argv, literal_args = split(argv, "--")
7
+ options = parse_options!(argv)
8
+ minitest_flags = parse_minitest_flags!(argv) unless options[:help]
9
+
10
+ [argv + literal_args, minitest_flags || [], options]
11
+ end
12
+
13
+ def to_s
14
+ <<~USAGE
15
+ Usage: mt [--all]
16
+ mt [test file...] [test dir...]
17
+ mt --watch
18
+ USAGE
19
+ end
20
+
21
+ private
22
+
23
+ def parse_options!(argv)
24
+ options = {}
25
+ options[:all] = true if argv.delete("--all")
26
+ options[:watch] = true if argv.delete("--watch")
27
+ options[:version] = true if argv.delete("--version")
28
+ options[:help] = true if argv.delete("--help") || argv.delete("-h")
29
+ parse_shard(argv, options)
30
+ options
31
+ end
32
+
33
+ def parse_minitest_flags!(argv)
34
+ return [] if argv.grep(/\A-/).none?
35
+
36
+ require "minitest"
37
+ orig_argv = argv.dup
38
+
39
+ Minitest.load_plugins unless argv.delete("--no-plugins") || ENV["MT_NO_PLUGINS"]
40
+ minitest_options = Minitest.process_args(argv)
41
+
42
+ minitest_args = Shellwords.split(minitest_options[:args] || "")
43
+ remove_seed_flag(minitest_args) unless orig_argv.include?("--seed")
44
+
45
+ minitest_args
46
+ end
47
+
48
+ def split(array, delim)
49
+ delim_at = array.index(delim)
50
+ return [array.dup, []] if delim_at.nil?
51
+
52
+ [
53
+ array[0...delim_at],
54
+ array[(delim_at + 1)..]
55
+ ]
56
+ end
57
+
58
+ def parse_shard(argv, options)
59
+ argv.delete_if { |arg| options[:shard] = Regexp.last_match(1) if arg =~ /\A--shard=(.*)/ }
60
+
61
+ argv.each_with_index do |flag, i|
62
+ value = argv[i + 1]
63
+ next unless flag == "--shard"
64
+ raise "missing shard value" if value.nil? || value.start_with?("-")
65
+
66
+ options[:shard] = value
67
+ argv.slice!(i, 2)
68
+ break
69
+ end
70
+ end
71
+
72
+ def remove_seed_flag(parsed_argv)
73
+ index = parsed_argv.index("--seed")
74
+ parsed_argv.slice!(index, 2) if index
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,46 @@
1
+ module MightyTest
2
+ class Sharder
3
+ DEFAULT_SEED = 123_456_789
4
+
5
+ def self.from_argv(value, env: ENV, file_system: FileSystem.new)
6
+ index, total = value.to_s.match(%r{\A(\d+)/(\d+)\z})&.captures&.map(&:to_i)
7
+ raise ArgumentError, "shard: value must be in the form INDEX/TOTAL (e.g. 2/8)" if total.nil?
8
+
9
+ git_sha = env.values_at("GITHUB_SHA", "CIRCLE_SHA1").find { |sha| !sha.to_s.strip.empty? }
10
+ seed = git_sha&.unpack1("l_")
11
+
12
+ new(index:, total:, seed:, file_system:)
13
+ end
14
+
15
+ attr_reader :index, :total, :seed
16
+
17
+ def initialize(index:, total:, seed: nil, file_system: FileSystem.new)
18
+ raise ArgumentError, "shard: total shards must be a number greater than 0" unless total > 0
19
+
20
+ valid_group = index > 0 && index <= total
21
+ raise ArgumentError, "shard: shard index must be > 0 and <= #{total}" unless valid_group
22
+
23
+ @index = index
24
+ @total = total
25
+ @seed = seed || DEFAULT_SEED
26
+ @file_system = file_system
27
+ end
28
+
29
+ def shard(*test_paths)
30
+ random = Random.new(seed)
31
+
32
+ # Shuffle slow and normal paths separately so that slow ones get evenly distributed
33
+ shuffled_paths = test_paths
34
+ .flatten
35
+ .partition { |path| !file_system.slow_test_path?(path) }
36
+ .flat_map { |paths| paths.shuffle(random:) }
37
+
38
+ slices = shuffled_paths.each_slice(total)
39
+ slices.filter_map { |slice| slice[index - 1] }
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :file_system
45
+ end
46
+ end
@@ -0,0 +1,33 @@
1
+ module MightyTest
2
+ class TestParser
3
+ def initialize(test_path)
4
+ @path = test_path.to_s
5
+ end
6
+
7
+ def test_name_at_line(number)
8
+ method_name = nil
9
+ lines = File.read(path).lines
10
+ lines[2...number].reverse_each.find do |line|
11
+ method_name =
12
+ match_minitest_method_name(line) ||
13
+ match_active_support_test_string(line)&.then { "test_#{_1.gsub(/\s+/, '_')}" }
14
+ end
15
+ method_name
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :path
21
+
22
+ def match_minitest_method_name(line)
23
+ line[/^\s*(?:focus\s+)?def\s+(test_\w+)/, 1]
24
+ end
25
+
26
+ def match_active_support_test_string(line)
27
+ match = line.match(/^\s*test\s+(?:"(.+?)"|'(.+?)')\s*do\s*(?:#.*?)?$/)
28
+ return unless match
29
+
30
+ match.captures.compact.first
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ module MightyTest
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,140 @@
1
+ module MightyTest
2
+ class Watcher # rubocop:disable Metrics/ClassLength
3
+ WATCHING_FOR_CHANGES = 'Watching for changes to source and test files. Press "h" for help or "q" to quit.'.freeze
4
+
5
+ def initialize(console: Console.new, extra_args: [], file_system: FileSystem.new, system_proc: method(:system))
6
+ @queue = Thread::Queue.new
7
+ @console = console
8
+ @extra_args = extra_args
9
+ @file_system = file_system
10
+ @system_proc = system_proc
11
+ end
12
+
13
+ def run(iterations: :indefinitely) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
14
+ start_file_system_listener
15
+ start_keypress_listener
16
+ puts WATCHING_FOR_CHANGES
17
+
18
+ loop_for(iterations) do
19
+ case await_next_event
20
+ in [:file_system_changed, [_, *] => paths]
21
+ run_matching_test_files(paths)
22
+ in [:keypress, "\r" | "\n"]
23
+ run_all_tests
24
+ in [:keypress, "a"]
25
+ run_all_tests(flags: ["--all"])
26
+ in [:keypress, "d"]
27
+ run_matching_test_files_from_git_diff
28
+ in [:keypress, "h"]
29
+ show_help
30
+ in [:keypress, "q"]
31
+ break
32
+ else
33
+ nil
34
+ end
35
+ end
36
+ ensure
37
+ puts "\nExiting."
38
+ file_system_listener&.stop
39
+ keypress_listener&.kill
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :console, :extra_args, :file_system, :file_system_listener, :keypress_listener, :system_proc
45
+
46
+ def show_help
47
+ console.clear
48
+ puts <<~MENU
49
+ `mt --watch` is watching file system activity and will automatically run
50
+ test files when they are added or modified. If you modify a source file,
51
+ mt will find and run the corresponding tests.
52
+
53
+ You can also trigger test runs with the following interactive commands.
54
+
55
+ > Press Enter to run all tests.
56
+ > Press "a" to run all tests, including slow tests.
57
+ > Press "d" to run tests for files diffed or added since the last git commit.
58
+ > Press "h" to show this help menu.
59
+ > Press "q" to quit.
60
+
61
+ MENU
62
+ end
63
+
64
+ def run_all_tests(flags: [])
65
+ console.clear
66
+ puts flags.any? ? "Running tests with #{flags.join(' ')}..." : "Running tests..."
67
+ puts
68
+ mt(flags:)
69
+ end
70
+
71
+ def run_matching_test_files(paths)
72
+ test_paths = paths.flat_map { |path| file_system.find_matching_test_path(path) }.compact.uniq
73
+ return false if test_paths.empty?
74
+
75
+ console.clear
76
+ puts test_paths.join("\n")
77
+ puts
78
+ mt(*test_paths)
79
+ true
80
+ end
81
+
82
+ def run_matching_test_files_from_git_diff
83
+ return if run_matching_test_files(file_system.find_new_and_changed_paths)
84
+
85
+ console.clear
86
+ puts "No affected test files detected since the last git commit."
87
+ puts WATCHING_FOR_CHANGES
88
+ end
89
+
90
+ def mt(*test_paths, flags: [])
91
+ command = ["mt", *extra_args, *flags]
92
+ command.append("--", *test_paths.flatten) if test_paths.any?
93
+
94
+ success = system_proc.call(*command)
95
+
96
+ console.play_sound(success ? :pass : :fail)
97
+ puts "\n#{WATCHING_FOR_CHANGES}"
98
+ $stdout.flush
99
+ rescue Interrupt
100
+ # Pressing ctrl-c kills the fs_event background process, so we have to manually restart it.
101
+ restart_file_system_listener
102
+ end
103
+
104
+ def start_file_system_listener
105
+ file_system_listener.stop if file_system_listener && !file_system_listener.stopped?
106
+
107
+ @file_system_listener = file_system.listen do |modified, added, _removed|
108
+ # Pause listener so that subsequent changes are queued up while we are running the tests
109
+ file_system_listener.pause unless file_system_listener.stopped?
110
+ post_event(:file_system_changed, [*modified, *added].uniq)
111
+ end
112
+ end
113
+ alias restart_file_system_listener start_file_system_listener
114
+
115
+ def start_keypress_listener
116
+ @keypress_listener = Thread.new do
117
+ loop do
118
+ key = console.wait_for_keypress
119
+ break if key.nil?
120
+
121
+ post_event(:keypress, key)
122
+ end
123
+ end
124
+ @keypress_listener.abort_on_exception = true
125
+ end
126
+
127
+ def loop_for(iterations, &)
128
+ iterations == :indefinitely ? loop(&) : iterations.times(&)
129
+ end
130
+
131
+ def await_next_event
132
+ file_system_listener.start if file_system_listener.paused?
133
+ @queue.pop
134
+ end
135
+
136
+ def post_event(*event)
137
+ @queue << event
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,11 @@
1
+ module MightyTest
2
+ autoload :VERSION, "mighty_test/version"
3
+ autoload :CLI, "mighty_test/cli"
4
+ autoload :Console, "mighty_test/console"
5
+ autoload :FileSystem, "mighty_test/file_system"
6
+ autoload :MinitestRunner, "mighty_test/minitest_runner"
7
+ autoload :OptionParser, "mighty_test/option_parser"
8
+ autoload :Sharder, "mighty_test/sharder"
9
+ autoload :TestParser, "mighty_test/test_parser"
10
+ autoload :Watcher, "mighty_test/watcher"
11
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mighty_test
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matt Brictson
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-02-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: listen
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.5'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.15'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.15'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest-fail-fast
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.1.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.1.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest-focus
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.4'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.4'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest-rg
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '5.3'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '5.3'
83
+ description:
84
+ email:
85
+ - opensource@mattbrictson.com
86
+ executables:
87
+ - mt
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - LICENSE.txt
92
+ - README.md
93
+ - exe/mt
94
+ - lib/mighty_test.rb
95
+ - lib/mighty_test/cli.rb
96
+ - lib/mighty_test/console.rb
97
+ - lib/mighty_test/file_system.rb
98
+ - lib/mighty_test/minitest_runner.rb
99
+ - lib/mighty_test/option_parser.rb
100
+ - lib/mighty_test/sharder.rb
101
+ - lib/mighty_test/test_parser.rb
102
+ - lib/mighty_test/version.rb
103
+ - lib/mighty_test/watcher.rb
104
+ homepage: https://github.com/mattbrictson/mighty_test
105
+ licenses:
106
+ - MIT
107
+ metadata:
108
+ bug_tracker_uri: https://github.com/mattbrictson/mighty_test/issues
109
+ changelog_uri: https://github.com/mattbrictson/mighty_test/releases
110
+ source_code_uri: https://github.com/mattbrictson/mighty_test
111
+ homepage_uri: https://github.com/mattbrictson/mighty_test
112
+ rubygems_mfa_required: 'true'
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '3.1'
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 3.5.5
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: A modern Minitest runner for TDD, with watch mode and more
132
+ test_files: []