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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +21 -0
- data/README.md +330 -0
- data/bin/railstest +6 -0
- data/docker-compose.yml +18 -0
- data/lib/railstest/cli.rb +477 -0
- data/lib/railstest/database_manager.rb +117 -0
- data/lib/railstest/docker_manager.rb +291 -0
- data/lib/railstest/supported_versions.rb +74 -0
- data/lib/railstest/test_runner.rb +236 -0
- data/lib/railstest/version.rb +5 -0
- data/lib/railstest.rb +12 -0
- metadata +57 -0
|
@@ -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
|