railstest 0.3.1

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,477 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Railstest
6
+ class CLI
7
+ def self.start(args = ARGV)
8
+ new(args).run
9
+ end
10
+
11
+ def initialize(args)
12
+ @args = args.dup
13
+ end
14
+
15
+ def run
16
+ options = {
17
+ ruby_version: nil,
18
+ rails_version: nil,
19
+ database: nil,
20
+ test_path: nil,
21
+ gem_path: nil,
22
+ workers: 1,
23
+ gemspec: false
24
+ }
25
+
26
+ parser = OptionParser.new do |opts|
27
+ opts.banner = "Railstest CLI\nUsage: railstest [options]"
28
+ opts.separator ''
29
+ opts.separator 'Options:'
30
+
31
+ opts.on('--ruby VERSION', 'Ruby version filter (tests all compatible Rails when --rails omitted)') do |v|
32
+ options[:ruby_version] = v
33
+ end
34
+
35
+ opts.on('--rails VERSION', 'Rails version filter (tests all compatible Ruby when --ruby omitted)') do |v|
36
+ options[:rails_version] = normalize_rails_version(v)
37
+ end
38
+
39
+ opts.on('--db DATABASE', 'Database: sqlite, mysql, postgres (default: sqlite)') do |db|
40
+ options[:database] = db
41
+ end
42
+
43
+ opts.on('--path PATH', 'Specific test file or directory') do |path|
44
+ options[:test_path] = path
45
+ end
46
+
47
+ opts.on('--gem-path PATH', 'Path to the gem to test') do |path|
48
+ options[:gem_path] = path
49
+ end
50
+
51
+ opts.on('--gemspec', 'Show which combinations the gemspec claims to support (no Docker required)') do
52
+ options[:gemspec] = true
53
+ end
54
+
55
+ opts.on('-j N', '--workers N', Integer, 'Parallel workers (default: 1, sequential)') do |n|
56
+ options[:workers] = n
57
+ end
58
+
59
+ opts.separator ''
60
+
61
+ opts.on('-v', '--version', 'Show version') do
62
+ puts "railstest #{Railstest::VERSION}"
63
+ exit
64
+ end
65
+
66
+ opts.on('-h', '--help', 'Show this help') do
67
+ puts opts
68
+ exit
69
+ end
70
+ end
71
+
72
+ parser.parse!(@args)
73
+
74
+ base_path = options[:gem_path] || Dir.pwd
75
+ gem_name = detect_gem_name(base_path)
76
+
77
+ if options[:gemspec]
78
+ show_gemspec_matrix(options, gem_name)
79
+ return
80
+ end
81
+
82
+ # Single combination: both versions explicitly given
83
+ if options[:ruby_version] && options[:rails_version]
84
+ run_single(options, gem_name)
85
+ else
86
+ run_combinations(options, gem_name)
87
+ end
88
+ rescue Railstest::Error => e
89
+ puts "Error: #{e.message}"
90
+ exit 1
91
+ end
92
+
93
+ private
94
+
95
+ def run_single(options, gem_name)
96
+ detect_versions!(options)
97
+
98
+ puts "\nRailstest pondering if #{gem_name} runs on Rails #{options[:rails_version]}...\n"
99
+
100
+ validate_options!(options)
101
+ check_compatibility!(options)
102
+
103
+ puts "Running tests with Ruby #{options[:ruby_version]}, Rails #{options[:rails_version]}, #{options[:database]}"
104
+
105
+ runner = Railstest::TestRunner.new(options)
106
+ exit_status = runner.run
107
+ exit(exit_status)
108
+ end
109
+
110
+ def run_combinations(options, gem_name)
111
+ require 'open3'
112
+
113
+ workers = options[:workers]
114
+ combos = compatible_combinations(options)
115
+
116
+ if combos.empty?
117
+ puts 'No compatible combinations found for the specified constraints.'
118
+ exit 1
119
+ end
120
+
121
+ worker_label = workers == 1 ? 'sequentially' : "with #{workers} parallel workers"
122
+ puts "\nRailstest testing #{gem_name} against #{combos.length} " \
123
+ "combination#{'s' unless combos.length == 1} #{worker_label}...\n\n"
124
+
125
+ tty = $stdout.tty?
126
+ positions = {}
127
+
128
+ if tty
129
+ combos.each_with_index do |(ruby, rails), i|
130
+ puts " ⏳ Ruby #{ruby} + Rails #{rails}..."
131
+ positions["#{ruby}+#{rails}"] = i
132
+ end
133
+ end
134
+
135
+ n = combos.length
136
+ queue = Queue.new
137
+ combos.each { |c| queue << c }
138
+
139
+ mutex = Mutex.new
140
+ results = {}
141
+ bin = File.expand_path($PROGRAM_NAME)
142
+ gem_path = File.expand_path(options[:gem_path] || Dir.pwd)
143
+ base_args = ['--gem-path', gem_path]
144
+ base_args += ['--db', options[:database]] if options[:database]
145
+ base_args += ['--path', options[:test_path]] if options[:test_path]
146
+
147
+ threads = workers.times.map do
148
+ Thread.new do
149
+ loop do
150
+ ruby, rails = queue.pop(true)
151
+ key = "#{ruby}+#{rails}"
152
+ mutex.synchronize { puts " ⏳ Ruby #{ruby} + Rails #{rails}..." } unless tty
153
+ start = Time.now
154
+ output, status = Open3.capture2e(bin, *base_args, '--ruby', ruby, '--rails', rails)
155
+ elapsed = (Time.now - start).round
156
+ passed = status.exitstatus.zero?
157
+ label = " #{passed ? '✅' : '❌'} Ruby #{ruby} + Rails #{rails} (#{elapsed}s)"
158
+
159
+ mutex.synchronize do
160
+ results[key] = { passed: passed, ruby: ruby, rails: rails,
161
+ elapsed: elapsed, output: output }
162
+ if tty
163
+ lines_up = n - positions[key]
164
+ print "\e[#{lines_up}A\r\e[2K#{label}\e[#{lines_up}B\r"
165
+ else
166
+ puts label
167
+ end
168
+ end
169
+ rescue ThreadError
170
+ break
171
+ end
172
+ end
173
+ end
174
+
175
+ threads.each(&:join)
176
+
177
+ puts # blank line after the combo block
178
+
179
+ failures = results.values.reject { |r| r[:passed] }
180
+ unless failures.empty?
181
+ puts "\nFailed combinations:\n"
182
+ failures.each do |r|
183
+ puts " ❌ Ruby #{r[:ruby]} + Rails #{r[:rails]}"
184
+ puts r[:output].lines.map { |l| " #{l}" }.join
185
+ puts
186
+ end
187
+ end
188
+
189
+ passed_count = results.values.count { |r| r[:passed] }
190
+ total = results.length
191
+ puts "\n#{'═' * 50}"
192
+ puts "#{passed_count}/#{total} combinations passed"
193
+
194
+ exit(passed_count == total ? 0 : 1)
195
+ end
196
+
197
+ def show_gemspec_matrix(options, gem_name)
198
+ base_path = File.expand_path(options[:gem_path] || Dir.pwd)
199
+ constraints = parse_gemspec_constraints(base_path)
200
+
201
+ if constraints.nil?
202
+ puts 'No gemspec found in the specified path.'
203
+ exit 1
204
+ end
205
+
206
+ ruby_req, rails_req = constraints[:ruby], constraints[:rails]
207
+ minimum = Railstest::SUPPORTED_VERSIONS[:ruby][:minimum]
208
+
209
+ puts "\n#{gem_name} — declared support (gemspec)\n"
210
+ puts '═' * 50
211
+ puts
212
+
213
+ if ruby_req
214
+ note = ruby_req.satisfied_by?(Gem::Version.new('3.1')) ? \
215
+ " → railstest tests >= #{minimum} (zeitwerk requires Ruby >= #{minimum})" : ''
216
+ puts " ruby #{ruby_req}#{note}"
217
+ end
218
+ puts " rails #{rails_req}" if rails_req
219
+ puts
220
+
221
+ compat = Railstest::SUPPORTED_VERSIONS[:compatibility]
222
+ all_rubies = compat.select { |_, rails_list| rails_list.any? }.keys
223
+ all_rails = compat.values.flatten.uniq.sort_by { |v| v.split('.').map(&:to_i) }
224
+
225
+ # Determine which cells are in-matrix and in-gemspec-scope
226
+ cell = lambda do |ruby, rails|
227
+ in_matrix = compat[ruby]&.include?(rails)
228
+ return :off unless in_matrix
229
+
230
+ ruby_ok = ruby_req.nil? || ruby_req.satisfied_by?(Gem::Version.new(ruby))
231
+ rails_ok = rails_req.nil? || rails_req.satisfied_by?(Gem::Version.new(rails))
232
+ ruby_ok && rails_ok ? :in : :out
233
+ end
234
+
235
+ col_w = 6
236
+ print ' '
237
+ all_rails.each { |r| print r.ljust(col_w) }
238
+ puts
239
+ print ' ──────'
240
+ puts '─' * (all_rails.length * col_w)
241
+
242
+ all_rubies.each do |ruby|
243
+ print " #{ruby.ljust(6)}"
244
+ all_rails.each do |rails|
245
+ mark = case cell.call(ruby, rails)
246
+ when :in then '✓'
247
+ when :out then '·'
248
+ else ' '
249
+ end
250
+ print mark.ljust(col_w)
251
+ end
252
+ puts
253
+ end
254
+
255
+ in_scope = all_rubies.sum { |rb| all_rails.count { |r| cell.call(rb, r) == :in } }
256
+ puts
257
+ puts " ✓ in scope · in railstest matrix but excluded by gemspec" if all_rubies.any? { |rb| all_rails.any? { |r| cell.call(rb, r) == :out } }
258
+ puts
259
+ puts " #{in_scope} combination#{'s' unless in_scope == 1} in scope."
260
+ puts " Run 'railstest --gem-path #{options[:gem_path] || '.'}' to test them."
261
+ puts
262
+ end
263
+
264
+ def parse_gemspec_constraints(base_path)
265
+ files = Dir.glob(File.join(base_path, '*.gemspec'))
266
+ return nil if files.empty?
267
+
268
+ content = File.read(files.first)
269
+
270
+ ruby_req = nil
271
+ if content =~ /required_ruby_version\s*=\s*["']([^"']+)["']/
272
+ ruby_req = Gem::Requirement.new(::Regexp.last_match(1))
273
+ end
274
+
275
+ rails_req = nil
276
+ if content =~ /add_(?:runtime_)?dependency\s+["']rails["']([^\n]+)/
277
+ req_strings = ::Regexp.last_match(1).scan(/["']([^"']+)["']/).flatten
278
+ rails_req = Gem::Requirement.new(*req_strings) unless req_strings.empty?
279
+ end
280
+
281
+ { ruby: ruby_req, rails: rails_req }
282
+ end
283
+
284
+ def compatible_combinations(options)
285
+ ruby_filter = options[:ruby_version] ? Railstest.normalize_version(options[:ruby_version]) : nil
286
+ rails_filter = options[:rails_version] ? Railstest.normalize_version(options[:rails_version]) : nil
287
+
288
+ Railstest::SUPPORTED_VERSIONS[:compatibility].flat_map do |ruby, rails_list|
289
+ next [] if ruby_filter && Railstest.normalize_version(ruby) != ruby_filter
290
+
291
+ rails_list.filter_map do |rails|
292
+ next if rails_filter && Railstest.normalize_version(rails) != rails_filter
293
+
294
+ [ruby, rails]
295
+ end
296
+ end
297
+ end
298
+
299
+ def detect_versions!(options)
300
+ base_path = options[:gem_path] || Dir.pwd
301
+ options[:ruby_version] = detect_ruby_version(base_path) if options[:ruby_version].nil?
302
+ return unless options[:rails_version].nil?
303
+
304
+ options[:rails_version] = detect_rails_version(base_path)
305
+ end
306
+
307
+ def detect_gem_name(base_path)
308
+ gemspec_files = Dir.glob(File.join(base_path, '*.gemspec'))
309
+ return File.basename(File.expand_path(base_path)) if gemspec_files.empty?
310
+
311
+ content = File.read(gemspec_files.first)
312
+ return ::Regexp.last_match(1) if content =~ /\w+\.name\s*=\s*["']([^"']+)["']/
313
+
314
+ File.basename(gemspec_files.first, '.gemspec')
315
+ end
316
+
317
+ def detect_ruby_version(base_path)
318
+ ruby_version_file = File.join(base_path, '.ruby-version')
319
+
320
+ if File.exist?(ruby_version_file)
321
+ version = File.read(ruby_version_file).strip.sub(/^ruby-/, '')
322
+ match = version.match(/^(\d+\.\d+(?:\.\d+)?)/)
323
+ return clamp_ruby_version(match[1]) if match
324
+ end
325
+
326
+ gemspec_files = Dir.glob(File.join(base_path, '*.gemspec'))
327
+ unless gemspec_files.empty?
328
+ content = File.read(gemspec_files.first)
329
+ if content =~ /required_ruby_version\s*=\s*['"]([><=~\s]*)([\d.]+)['"]/
330
+ operator = ::Regexp.last_match(1).strip
331
+ version = ::Regexp.last_match(2)
332
+ if version =~ /^(\d+\.\d+)/ && ['~>', '>=', '', '='].include?(operator)
333
+ return clamp_ruby_version(::Regexp.last_match(1))
334
+ end
335
+ end
336
+ end
337
+
338
+ nil
339
+ end
340
+
341
+ def clamp_ruby_version(version)
342
+ return version unless version
343
+
344
+ minimum = Railstest::SUPPORTED_VERSIONS[:ruby][:minimum]
345
+ return version unless Gem::Version.new(version) < Gem::Version.new(minimum)
346
+
347
+ puts "ℹ️ Detected Ruby #{version}, but the minimum supported is #{minimum} " \
348
+ "(Rails' zeitwerk dependency requires Ruby >= #{minimum}); using #{minimum}."
349
+ minimum
350
+ end
351
+
352
+ def detect_rails_version(base_path)
353
+ gemfiles_dir = File.join(base_path, 'gemfiles')
354
+ if File.directory?(gemfiles_dir)
355
+ gemfiles = Dir.glob(File.join(gemfiles_dir, '*'))
356
+ unless gemfiles.empty?
357
+ versions = gemfiles.filter_map do |f|
358
+ basename = File.basename(f)
359
+ next if basename =~ /(main|edge|master)/i || basename =~ /\.lock$/
360
+
361
+ match = basename.match(/(\d+[-.]?\d+)/)
362
+ match[1].tr('-', '.') if match
363
+ end
364
+ versions = versions.uniq.sort_by { |v| v.split('.').map(&:to_i) }
365
+ return versions.last unless versions.empty?
366
+
367
+ puts '⚠️ Warning: No Rails version patterns (e.g., 5.2, 7.0) found in gemfiles/'
368
+ puts " Found files: #{gemfiles.map { |f| File.basename(f) }.join(', ')}"
369
+ end
370
+ end
371
+
372
+ gemfile_path = File.join(base_path, 'Gemfile')
373
+ if File.exist?(gemfile_path)
374
+ content = File.read(gemfile_path)
375
+ return ::Regexp.last_match(1) if content =~ /gem\s+['"]rails['"].*?(\d+\.\d+)/
376
+
377
+ if content =~ /^gemspec\s*$/ || content =~ /gemspec\s*\(/
378
+ gemspec_files = Dir.glob(File.join(base_path, '*.gemspec'))
379
+ unless gemspec_files.empty?
380
+ content = File.read(gemspec_files.first)
381
+ return ::Regexp.last_match(1) if content =~ /add_(?:runtime_)?dependency\s+['"]rails['"].*?(\d+\.\d+)/
382
+ end
383
+ end
384
+ end
385
+
386
+ gemspec_files = Dir.glob(File.join(base_path, '*.gemspec'))
387
+ unless gemspec_files.empty?
388
+ content = File.read(gemspec_files.first)
389
+ return ::Regexp.last_match(1) if content =~ /add_(?:runtime_)?dependency\s+['"]rails['"].*?(\d+\.\d+)/
390
+ end
391
+
392
+ nil
393
+ end
394
+
395
+ def validate_options!(options)
396
+ errors = []
397
+ hints = []
398
+
399
+ if options[:gem_path].nil?
400
+ base_path = Dir.pwd
401
+ gemfiles_dir = File.join(base_path, 'gemfiles')
402
+
403
+ unless File.directory?(gemfiles_dir)
404
+ errors << "Local mode requires a 'gemfiles/' directory"
405
+ hints << 'This gem appears to be set up for target-gem mode.'
406
+ hints << ' Use: railstest --gem-path . --ruby VERSION --rails VERSION'
407
+ hints << ''
408
+ hints << 'Or to test an external gem:'
409
+ hints << ' railstest --gem-path /path/to/gem --ruby VERSION --rails VERSION'
410
+ end
411
+
412
+ errors << 'Ruby version not specified and could not be detected' if options[:ruby_version].nil?
413
+ errors << 'Rails version not specified and could not be detected from Gemfile' if options[:rails_version].nil?
414
+ else
415
+ base_path = options[:gem_path]
416
+
417
+ if options[:ruby_version].nil?
418
+ errors << 'Ruby version not specified and could not be detected'
419
+ if options[:rails_version] && options[:rails_version].to_f >= 8.0
420
+ hints << "Rails #{options[:rails_version]} requires Ruby 3.2+"
421
+ hints << ' Use --ruby 3.2 or later'
422
+ else
423
+ hints << ' Checked: .ruby-version file and gemspec required_ruby_version'
424
+ hints << ' Use --ruby VERSION to specify'
425
+ end
426
+ end
427
+
428
+ if options[:rails_version].nil? && Dir.glob(File.join(base_path, '*.gemspec')).empty? &&
429
+ !File.exist?(File.join(base_path, 'Gemfile'))
430
+ errors << 'Rails version not specified and could not be detected from Gemfile or gemspec'
431
+ hints << ' Use --rails VERSION or add rails dependency to your gem'
432
+ end
433
+ end
434
+
435
+ return if errors.empty?
436
+
437
+ puts "Error: Missing required configuration\n\n"
438
+ errors.each { |e| puts e }
439
+ unless hints.empty?
440
+ puts ''
441
+ hints.each { |h| puts h }
442
+ end
443
+ puts "\nRun 'railstest --help' for usage information"
444
+ exit 1
445
+ end
446
+
447
+ def check_compatibility!(options)
448
+ ruby = options[:ruby_version]
449
+ rails = options[:rails_version]
450
+ compatible = Railstest.compatible?(ruby, rails)
451
+
452
+ if compatible == false
453
+ puts "\n⚠️ Warning: Ruby #{ruby} and Rails #{rails} may be incompatible\n"
454
+ recommended = Railstest.recommended_rails_versions(ruby)
455
+ puts " Recommended Rails versions for Ruby #{ruby}: #{recommended.join(', ')}" unless recommended.empty?
456
+ ruby_note = Railstest.note_for(ruby)
457
+ rails_note = Railstest.note_for(rails)
458
+ puts " Note: #{ruby_note}" if ruby_note
459
+ puts " Note: #{rails_note}" if rails_note
460
+ puts "\n Proceeding anyway, but build may fail...\n\n"
461
+ sleep 2
462
+ elsif compatible.nil?
463
+ ruby_note = Railstest.note_for(ruby)
464
+ rails_note = Railstest.note_for(rails)
465
+ if ruby_note || rails_note
466
+ puts "\nNote: #{ruby_note}" if ruby_note
467
+ puts "Note: #{rails_note}" if rails_note
468
+ puts ''
469
+ end
470
+ end
471
+ end
472
+
473
+ def normalize_rails_version(version)
474
+ version.to_s.tr('_', '.')
475
+ end
476
+ end
477
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railstest
4
+ class DatabaseManager
5
+ attr_reader :database, :compose_file
6
+
7
+ def initialize(database:, compose_file: nil)
8
+ @database = database
9
+ @compose_file = compose_file || find_compose_file
10
+ @docker_compose_cmd = detect_docker_compose_command
11
+ end
12
+
13
+ def start
14
+ return unless requires_container?
15
+
16
+ puts "Starting #{database} container..."
17
+ system("#{@docker_compose_cmd} -f #{compose_file} up -d #{database}")
18
+ end
19
+
20
+ def stop
21
+ return unless requires_container?
22
+
23
+ puts "Stopping #{database} container..."
24
+ system("#{@docker_compose_cmd} -f #{compose_file} down")
25
+ end
26
+
27
+ def wait_for_ready
28
+ return unless requires_container?
29
+
30
+ puts "Waiting for #{database} to accept connections..."
31
+
32
+ 30.times do
33
+ return true if ready?
34
+
35
+ sleep 1
36
+ end
37
+
38
+ raise Error, "#{database.capitalize} did not become ready in time."
39
+ end
40
+
41
+ def setup_database(docker_manager)
42
+ return unless requires_container?
43
+
44
+ wait_for_ready
45
+
46
+ puts "Running DB setup for #{database}..."
47
+
48
+ env_vars = {
49
+ 'DATABASE' => database,
50
+ 'TARGET_DB' => database,
51
+ 'RAILS_ENV' => 'test'
52
+ }
53
+
54
+ volumes = []
55
+ workdir = nil
56
+
57
+ if docker_manager.target_gem_mode?
58
+ volumes << "#{docker_manager.expanded_gem_path}:/app/target_gem"
59
+ workdir = '/app/test_app'
60
+ else
61
+ env_vars['BUNDLE_GEMFILE'] = "/app/gemfiles/rails_#{docker_manager.rails_version_for_gemfile}.gemfile"
62
+ end
63
+
64
+ # Try db:drop first, if it fails, just create
65
+ unless docker_manager.run_command(
66
+ ['db:drop', 'db:create', 'db:schema:load'],
67
+ env_vars: env_vars,
68
+ volumes: volumes,
69
+ workdir: workdir
70
+ )
71
+ docker_manager.run_command(
72
+ ['db:create', 'db:schema:load'],
73
+ env_vars: env_vars,
74
+ volumes: volumes,
75
+ workdir: workdir
76
+ )
77
+ end
78
+ end
79
+
80
+ def requires_container?
81
+ database && database != 'sqlite'
82
+ end
83
+
84
+ private
85
+
86
+ def find_compose_file
87
+ # First check current directory
88
+ return 'docker-compose.yml' if File.exist?('docker-compose.yml')
89
+
90
+ # Then check gem installation directory
91
+ gem_root = File.expand_path('../..', __dir__)
92
+ compose_path = File.join(gem_root, 'docker-compose.yml')
93
+ return compose_path if File.exist?(compose_path)
94
+
95
+ raise Error, 'Could not find docker-compose.yml'
96
+ end
97
+
98
+ def detect_docker_compose_command
99
+ if system('docker compose version > /dev/null 2>&1')
100
+ 'docker compose'
101
+ else
102
+ 'docker-compose'
103
+ end
104
+ end
105
+
106
+ def ready?
107
+ case database
108
+ when 'mysql'
109
+ system("#{@docker_compose_cmd} -f #{compose_file} exec -T mysql mysqladmin ping -h 127.0.0.1 -uroot > /dev/null 2>&1")
110
+ when 'postgres'
111
+ system("#{@docker_compose_cmd} -f #{compose_file} exec -T postgres pg_isready -h 127.0.0.1 -U postgres > /dev/null 2>&1")
112
+ else
113
+ true
114
+ end
115
+ end
116
+ end
117
+ end