mighty_test 0.1.0

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 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: []