diff --git a/Gemfile.lock b/Gemfile.lock index 6351c688..d30980d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,6 +14,8 @@ GEM tzinfo (~> 2.0) zeitwerk (~> 2.3) ast (2.4.2) + base64 (0.2.0) + bigdecimal (3.1.9) builder (3.2.4) bump (0.10.0) colorize (1.1.0) @@ -63,6 +65,7 @@ GEM middleware (0.1.0) minitest (5.5.1) multi_test (0.1.2) + mutex_m (0.3.0) parallel (1.23.0) parser (3.2.2.1) ast (~> 2.4.1) @@ -126,10 +129,13 @@ PLATFORMS x86_64-linux DEPENDENCIES + base64 + bigdecimal bump cucumber (~> 4.0) cuke_modeler (~> 3.6) minitest (~> 5.5.0) + mutex_m parallel_tests! racc rake diff --git a/lib/parallel_tests/cucumber/scenarios.rb b/lib/parallel_tests/cucumber/scenarios.rb index 7f9b374e..f8fb34d5 100644 --- a/lib/parallel_tests/cucumber/scenarios.rb +++ b/lib/parallel_tests/cucumber/scenarios.rb @@ -4,6 +4,7 @@ require 'cucumber' require 'parallel_tests/cucumber/scenario_line_logger' require 'parallel_tests/gherkin/listener' +require 'base64' # MRI 3.4, /ruby-3.4.1/gems/protobuf-cucumber-3.10.8/lib/protobuf.rb:1 Not fixed yet begin gem "cuke_modeler", "~> 3.0" diff --git a/lib/parallel_tests/tasks.rb b/lib/parallel_tests/tasks.rb index 70fda67c..3089b47e 100644 --- a/lib/parallel_tests/tasks.rb +++ b/lib/parallel_tests/tasks.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -require 'rake' require 'shellwords' -require_relative '../tasks/helpers.rb' +require_relative 'tasks/helpers.rb' namespace :parallel do desc "Setup test databases via db:setup --> parallel:setup[num_cpus]" task :setup, :count do |_, args| command = [$0, "db:setup", "RAILS_ENV=#{ParallelTests::Tasks::Helpers.rails_env}"] - ParallelTests::Tasks::Helpers.run_in_parallel(ParallelTests::Tasks::Helpers.suppress_schema_load_output(command), args) + ParallelTests::Tasks::Helpers. + run_in_parallel(ParallelTests::Tasks::Helpers.suppress_schema_load_output(command), args) end ParallelTests::Tasks::Helpers.for_each_database do |name| diff --git a/lib/parallel_tests/tasks/helpers.rb b/lib/parallel_tests/tasks/helpers.rb new file mode 100644 index 00000000..2f9bd42a --- /dev/null +++ b/lib/parallel_tests/tasks/helpers.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true +require 'rake' + +module ParallelTests + module Tasks + module Helpers + class << self + def rails_env + 'test' + end + + def load_lib + $LOAD_PATH << File.expand_path('../..', __dir__) + require "parallel_tests" + end + + def purge_before_load + if ActiveRecord.version > Gem::Version.new('4.2.0') + Rake::Task.task_defined?('db:purge') ? 'db:purge' : 'app:db:purge' + end + end + + def run_in_parallel(cmd, options = {}) + load_lib + + # Using the relative path to find the binary allow to run a specific version of it + executable = File.expand_path('../../../bin/parallel_test', __dir__) + command = ParallelTests.with_ruby_binary(executable) + command += ['--exec', Shellwords.join(cmd)] + command += ['-n', options[:count]] unless options[:count].to_s.empty? + command << '--non-parallel' if options[:non_parallel] + + abort unless system(*command) + end + + # this is a crazy-complex solution for a very simple problem: + # removing certain lines from the output without changing the exit-status + # normally I'd not do this, but it has been lots of fun and a great learning experience :) + # + # - sed does not support | without -r + # - grep changes 0 exitstatus to 1 if nothing matches + # - sed changes 1 exitstatus to 0 + # - pipefail makes pipe fail with exitstatus of first failed command + # - pipefail is not supported in (zsh) + # - defining a new rake task like silence_schema would force users to load parallel_tests in test env + # - simple system "set -o pipefail" returns nil even though set -o pipefail exists with 0 + def suppress_output(command, ignore_regex) + activate_pipefail = "set -o pipefail" + remove_ignored_lines = %{(grep -v #{Shellwords.escape(ignore_regex)} || true)} + + # remove nil values (ex: #purge_before_load returns nil) + command.compact! + + if system('/bin/bash', '-c', "#{activate_pipefail} 2>/dev/null") + shell_command = "#{activate_pipefail} && (#{Shellwords.shelljoin(command)}) | #{remove_ignored_lines}" + ['/bin/bash', '-c', shell_command] + else + command + end + end + + def suppress_schema_load_output(command) + suppress_output(command, "^ ->\\|^-- ") + end + + def check_for_pending_migrations + ["db:abort_if_pending_migrations", "app:db:abort_if_pending_migrations"].each do |abort_migrations| + if Rake::Task.task_defined?(abort_migrations) + Rake::Task[abort_migrations].invoke + break + end + end + end + + # parallel:spec[:count, :pattern, :options, :pass_through] + def parse_args(args) + # order as given by user + args = [args[:count], args[:pattern], args[:options], args[:pass_through]] + + # count given or empty ? + # parallel:spec[2,models,options] + # parallel:spec[,models,options] + count = args.shift if args.first.to_s =~ /^\d*$/ + num_processes = (count.to_s.empty? ? nil : Integer(count)) + pattern = args.shift + options = args.shift + pass_through = args.shift + + [num_processes, pattern, options, pass_through] + end + + def schema_format_based_on_rails_version + if active_record_7_or_greater? + ActiveRecord.schema_format + else + ActiveRecord::Base.schema_format + end + end + + def schema_type_based_on_rails_version + if active_record_61_or_greater? || schema_format_based_on_rails_version == :ruby + "schema" + else + "structure" + end + end + + def build_run_command(type, args) + count, pattern, options, pass_through = parse_args(args) + test_framework = { + 'spec' => 'rspec', + 'test' => 'test', + 'features' => 'cucumber', + 'features-spinach' => 'spinach' + }.fetch(type) + + type = 'features' if test_framework == 'spinach' + + # Using the relative path to find the binary allow to run a specific version of it + executable = File.expand_path('../../../bin/parallel_test', __dir__) + executable = ParallelTests.with_ruby_binary(executable) + + command = [*executable, type, '--type', test_framework] + command += ['-n', count.to_s] if count + command += ['--pattern', pattern] if pattern + command += ['--test-options', options] if options + command += Shellwords.shellsplit pass_through if pass_through + command + end + + def configured_databases + return [] unless defined?(ActiveRecord) && active_record_61_or_greater? + + @@configured_databases ||= ActiveRecord::Tasks::DatabaseTasks.setup_initial_database_yaml + end + + def for_each_database(&block) + # Use nil to represent all databases + block&.call(nil) + + # skip if not rails or old rails version + return if !defined?(ActiveRecord::Tasks::DatabaseTasks) || !ActiveRecord::Tasks::DatabaseTasks.respond_to?(:for_each) + + ActiveRecord::Tasks::DatabaseTasks.for_each(configured_databases) do |name| + block&.call(name) + end + end + + private + + def active_record_7_or_greater? + ActiveRecord.version >= Gem::Version.new('7.0') + end + + def active_record_61_or_greater? + ActiveRecord.version >= Gem::Version.new('6.1.0') + end + end + end + end +end diff --git a/spec/parallel_tests/tasks_spec.rb b/spec/parallel_tests/tasks/helpers_spec.rb similarity index 63% rename from spec/parallel_tests/tasks_spec.rb rename to spec/parallel_tests/tasks/helpers_spec.rb index 5afc42c2..da347b07 100644 --- a/spec/parallel_tests/tasks_spec.rb +++ b/spec/parallel_tests/tasks/helpers_spec.rb @@ -1,32 +1,32 @@ # frozen_string_literal: true require 'spec_helper' -require 'parallel_tests/tasks' +require 'parallel_tests/tasks/helpers' -describe ParallelTests::Tasks do +describe ParallelTests::Tasks::Helpers do describe ".parse_args" do it "should return the count" do args = { count: 2 } - expect(ParallelTests::Tasks.parse_args(args)).to eq([2, nil, nil, nil]) + expect(described_class.parse_args(args)).to eq([2, nil, nil, nil]) end it "should default to the prefix" do args = { count: "models" } - expect(ParallelTests::Tasks.parse_args(args)).to eq([nil, "models", nil, nil]) + expect(described_class.parse_args(args)).to eq([nil, "models", nil, nil]) end it "should return the count and pattern" do args = { count: 2, pattern: "models" } - expect(ParallelTests::Tasks.parse_args(args)).to eq([2, "models", nil, nil]) + expect(described_class.parse_args(args)).to eq([2, "models", nil, nil]) end it "should return the count, pattern, and options" do args = { count: 2, pattern: "plain", options: "-p default" } - expect(ParallelTests::Tasks.parse_args(args)).to eq([2, "plain", "-p default", nil]) + expect(described_class.parse_args(args)).to eq([2, "plain", "-p default", nil]) end it "should return the count, pattern, and options" do args = { count: 2, pattern: "plain", options: "-p default --group-by steps" } - expect(ParallelTests::Tasks.parse_args(args)).to eq([2, "plain", "-p default --group-by steps", nil]) + expect(described_class.parse_args(args)).to eq([2, "plain", "-p default --group-by steps", nil]) end it "should return the count, pattern, test options, and pass-through options" do @@ -34,7 +34,7 @@ count: 2, pattern: "plain", options: "-p default --group-by steps", pass_through: "--runtime-log /path/to/log" } - expect(ParallelTests::Tasks.parse_args(args)).to eq( + expect(described_class.parse_args(args)).to eq( [2, "plain", "-p default --group-by steps", "--runtime-log /path/to/log"] ) @@ -43,17 +43,17 @@ describe ".rails_env" do it "should be test" do - expect(ParallelTests::Tasks.rails_env).to eq("test") + expect(described_class.rails_env).to eq("test") end it "should disregard whatever was set" do ENV["RAILS_ENV"] = "foo" - expect(ParallelTests::Tasks.rails_env).to eq("test") + expect(described_class.rails_env).to eq("test") end end describe ".run_in_parallel" do - let(:full_path) { File.expand_path('../../bin/parallel_test', __dir__) } + let(:full_path) { File.expand_path('../../../bin/parallel_test', __dir__) } it "has the executable" do expect(File.file?(full_path)).to eq(true) @@ -61,37 +61,37 @@ end it "runs command in parallel" do - expect(ParallelTests::Tasks).to receive(:system) - .with(*ParallelTests.with_ruby_binary(full_path), '--exec', 'echo') - .and_return true - ParallelTests::Tasks.run_in_parallel(["echo"]) + expect(described_class).to receive(:system). + with(*ParallelTests.with_ruby_binary(full_path), '--exec', 'echo'). + and_return true + described_class.run_in_parallel(["echo"]) end it "runs command with :count option" do - expect(ParallelTests::Tasks).to receive(:system) - .with(*ParallelTests.with_ruby_binary(full_path), '--exec', 'echo', '-n', 123) - .and_return true - ParallelTests::Tasks.run_in_parallel(["echo"], count: 123) + expect(described_class).to receive(:system). + with(*ParallelTests.with_ruby_binary(full_path), '--exec', 'echo', '-n', 123). + and_return true + described_class.run_in_parallel(["echo"], count: 123) end it "runs without -n with blank :count option" do - expect(ParallelTests::Tasks).to receive(:system) - .with(*ParallelTests.with_ruby_binary(full_path), '--exec', 'echo') - .and_return true - ParallelTests::Tasks.run_in_parallel(["echo"], count: "") + expect(described_class).to receive(:system). + with(*ParallelTests.with_ruby_binary(full_path), '--exec', 'echo'). + and_return true + described_class.run_in_parallel(["echo"], count: "") end it "runs command with :non_parallel option" do - expect(ParallelTests::Tasks).to receive(:system) - .with(*ParallelTests.with_ruby_binary(full_path), '--exec', 'echo', '--non-parallel') - .and_return true - ParallelTests::Tasks.run_in_parallel(["echo"], non_parallel: true) + expect(described_class).to receive(:system). + with(*ParallelTests.with_ruby_binary(full_path), '--exec', 'echo', '--non-parallel'). + and_return true + described_class.run_in_parallel(["echo"], non_parallel: true) end it "runs aborts if the command fails" do - expect(ParallelTests::Tasks).to receive(:system).and_return false - expect(ParallelTests::Tasks).to receive(:abort).and_return false - ParallelTests::Tasks.run_in_parallel(["echo"]) + expect(described_class).to receive(:system).and_return false + expect(described_class).to receive(:abort).and_return false + described_class.run_in_parallel(["echo"]) end end @@ -103,7 +103,7 @@ def call(command, grep) shell_command = [ '/bin/bash', '-c', - Shellwords.shelljoin(ParallelTests::Tasks.suppress_output(command, grep)) + Shellwords.shelljoin(described_class.suppress_output(command, grep)) ] result = IO.popen(shell_command, &:read) [result, $?.success?] @@ -135,7 +135,7 @@ def call(command, grep) context "without pipefail supported" do before do - expect(ParallelTests::Tasks).to receive(:system).with( + expect(described_class).to receive(:system).with( '/bin/bash', '-c', 'set -o pipefail 2>/dev/null' ).and_return false @@ -153,12 +153,13 @@ def call(command, grep) describe ".suppress_schema_load_output" do before do - allow(ParallelTests::Tasks).to receive(:suppress_output) + allow(ParallelTests::Tasks::Helpers).to receive(:suppress_output) end it 'should call suppress output with command' do - ParallelTests::Tasks.suppress_schema_load_output('command') - expect(ParallelTests::Tasks).to have_received(:suppress_output).with('command', "^ ->\\|^-- ") + described_class.suppress_schema_load_output('command') + expect(ParallelTests::Tasks::Helpers).to have_received(:suppress_output). + with('command', "^ ->\\|^-- ") end end @@ -169,7 +170,7 @@ def call(command, grep) end it "should do nothing if pending migrations is no defined" do - ParallelTests::Tasks.check_for_pending_migrations + described_class.check_for_pending_migrations end it "should run pending migrations is task is defined" do @@ -177,7 +178,7 @@ def call(command, grep) Rake::Task.define_task("db:abort_if_pending_migrations") do foo = 2 end - ParallelTests::Tasks.check_for_pending_migrations + described_class.check_for_pending_migrations expect(foo).to eq(2) end @@ -186,7 +187,7 @@ def call(command, grep) Rake::Task.define_task("app:db:abort_if_pending_migrations") do foo = 2 end - ParallelTests::Tasks.check_for_pending_migrations + described_class.check_for_pending_migrations expect(foo).to eq(2) end @@ -195,45 +196,45 @@ def call(command, grep) Rake::Task.define_task("db:abort_if_pending_migrations") do foo += 1 end - ParallelTests::Tasks.check_for_pending_migrations - ParallelTests::Tasks.check_for_pending_migrations + described_class.check_for_pending_migrations + described_class.check_for_pending_migrations expect(foo).to eq(2) end end describe ".purge_before_load" do - context 'Rails < 4.2.0' do + context 'ActiveRecord < 4.2.0' do before do - stub_const('Rails', double(version: '3.2.1')) + stub_const('ActiveRecord', double(version: Gem::Version.new('3.2.1'))) end - it "should return nil for Rails < 4.2.0" do - expect(ParallelTests::Tasks.purge_before_load).to eq nil + it "should return nil for ActiveRecord < 4.2.0" do + expect(described_class.purge_before_load).to eq nil end end - context 'Rails > 4.2.0' do + context 'ActiveRecord > 4.2.0' do before do - stub_const('Rails', double(version: '4.2.8')) + stub_const('ActiveRecord', double(version: Gem::Version.new('4.2.8'))) end it "should return db:purge when defined" do allow(Rake::Task).to receive(:task_defined?).with('db:purge') { true } - expect(ParallelTests::Tasks.purge_before_load).to eq 'db:purge' + expect(described_class.purge_before_load).to eq 'db:purge' end it "should return app:db:purge when db:purge is not defined" do allow(Rake::Task).to receive(:task_defined?).with('db:purge') { false } - expect(ParallelTests::Tasks.purge_before_load).to eq 'app:db:purge' + expect(described_class.purge_before_load).to eq 'app:db:purge' end end end describe ".build_run_command" do it "builds simple command" do - command = ParallelTests::Tasks.build_run_command("test", {}) + command = described_class.build_run_command("test", {}) command.shift 2 if command.include?("--") # windows prefixes ruby executable expect(command).to eq [ "#{Dir.pwd}/bin/parallel_test", "test", "--type", "test" @@ -241,11 +242,11 @@ def call(command, grep) end it "fails on unknown" do - expect { ParallelTests::Tasks.build_run_command("foo", {}) }.to raise_error(KeyError) + expect { described_class.build_run_command("foo", {}) }.to raise_error(KeyError) end it "builds with all arguments" do - command = ParallelTests::Tasks.build_run_command( + command = described_class.build_run_command( "test", count: 1, pattern: "foo", options: "bar", pass_through: "baz baz" ) diff --git a/spec/rails_spec.rb b/spec/rails_spec.rb index f9d620a2..112fc2cf 100644 --- a/spec/rails_spec.rb +++ b/spec/rails_spec.rb @@ -7,7 +7,9 @@ def run(command, options = {}) result = IO.popen(options.fetch(:environment, {}), command, err: [:child, :out], &:read) - raise "FAILED #{command}\n#{result}" if $?.success? == !!options[:fail] + if $?.success? == !!options[:fail] + raise "FAILED #{command}\n#{result}" + end result end