From fa0cb37f925d72a47b3814ad97a26122b1dd2db6 Mon Sep 17 00:00:00 2001 From: Markus Schirp Date: Wed, 27 May 2026 15:59:47 +0000 Subject: [PATCH 1/3] Add mutant dependency --- Gemfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Gemfile b/Gemfile index 57e5ff3506..edc8937fdf 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,8 @@ gem "database_cleaner", "~> 2.0", require: false gem "rspec-activemodel-mocks", "~> 1.1", require: false gem "rspec-rails", "~> 6.0.3", require: false gem "rspec-retry", "~> 0.6.2", require: false +gem "mutant", require: false +gem "mutant-rspec", require: false gem "simplecov", require: false gem "simplecov-cobertura", require: false gem "rack", "< 3", require: false From 45d19567c4006d8e74c866e08fa1885407c7140b Mon Sep 17 00:00:00 2001 From: Markus Schirp Date: Wed, 27 May 2026 17:01:19 +0000 Subject: [PATCH 2/3] Add basic mutant setup --- .gitignore | 1 + bin/db-env.sh | 10 ++++++++ bin/postgres | 50 +++++++++++++++++++++++++++++++++++++++ core/config/mutant.yml | 22 +++++++++++++++++ core/spec/mutant_hooks.rb | 10 ++++++++ 5 files changed, 93 insertions(+) create mode 100644 bin/db-env.sh create mode 100755 bin/postgres create mode 100644 core/config/mutant.yml create mode 100644 core/spec/mutant_hooks.rb diff --git a/.gitignore b/.gitignore index 3e4993f90e..582f6ff6bb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .loadpath .project .ruby-version +.mutant doc Gemfile.lock Gemfile-custom diff --git a/bin/db-env.sh b/bin/db-env.sh new file mode 100644 index 0000000000..f87e7b1c43 --- /dev/null +++ b/bin/db-env.sh @@ -0,0 +1,10 @@ +# Source this to point the test suite (and mutant) at the local PG container: +# source db-env.sh +# +# Matches bin/postgres (image postgres:18, user postgres / password password, +# 127.0.0.1:5432). DB is load-bearing for `bundle exec` since the Gemfile picks +# DB gems from $DB. +export DB=postgresql +export DB_HOST=127.0.0.1 +export DB_USERNAME=postgres +export DB_PASSWORD=password diff --git a/bin/postgres b/bin/postgres new file mode 100755 index 0000000000..8d8fd67306 --- /dev/null +++ b/bin/postgres @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# Boot the PostgreSQL container used for mutant runs against solidus_core. +# Boots with user "postgres" / password "password". +# +# Fails loudly if the container is already running, so we never silently attach +# to (or clobber) an existing database. + +set -euo pipefail + +CONTAINER_NAME="postgres" +IMAGE="docker.io/library/postgres:18" +PG_USER="postgres" +PG_PASSWORD="password" + +red() { printf '\033[1;31m%s\033[0m\n' "$*"; } +green(){ printf '\033[1;32m%s\033[0m\n' "$*"; } + +# --- Complain loudly if it's already running -------------------------------- +if podman container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then + status="$(podman container inspect -f '{{.State.Status}}' "$CONTAINER_NAME")" + if [ "$status" = "running" ]; then + red "############################################################" + red "## ALREADY RUNNING: container '$CONTAINER_NAME' is up." + red "## Refusing to start a second one." + red "##" + red "## Stop it first with:" + red "## podman stop $CONTAINER_NAME && podman rm $CONTAINER_NAME" + red "############################################################" + exit 1 + fi + + # Exists but stopped: remove the stale container so we start clean. + echo "Removing stopped container '$CONTAINER_NAME'..." + podman rm "$CONTAINER_NAME" >/dev/null +fi + +# --- Start it --------------------------------------------------------------- +green "Starting '$CONTAINER_NAME' ($IMAGE) on 127.0.0.1:5432 ..." +podman run -d \ + --name "$CONTAINER_NAME" \ + -e POSTGRES_USER="$PG_USER" \ + -e POSTGRES_PASSWORD="$PG_PASSWORD" \ + -p "127.0.0.1:5432:5432" \ + "$IMAGE" >/dev/null + +green "Started. Connect with:" +cat < Date: Wed, 27 May 2026 17:07:13 +0000 Subject: [PATCH 3/3] Add mutant DB parallelism --- core/spec/mutant_hooks.rb | 90 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/core/spec/mutant_hooks.rb b/core/spec/mutant_hooks.rb index e795a22d22..1ae68ecb96 100644 --- a/core/spec/mutant_hooks.rb +++ b/core/spec/mutant_hooks.rb @@ -2,9 +2,93 @@ # Mutant hooks (see core/.mutant.yml). # -# The DummyApp boots with `config.eager_load = false`, so Zeitwerk loads classes -# lazily. Mutant discovers subjects from loaded constants, so without forcing -# eager loading the subject expressions can silently match nothing. +# Per the mutant Rails guide (docs/rails.md), parallel workers must each get +# their own database or they corrupt each other's fixtures. This is the +# PostgreSQL pattern: each worker clones the migrated test database via +# `CREATE DATABASE ... TEMPLATE`, named "_mutant_worker_". +# +# The guide's `with_root_connection` uses `ActiveRecord::Base.postgresql_connection`, +# which was removed in Rails 8.x; we open the maintenance connection with the pg +# gem directly instead. The rest follows the guide. +require "pg" + +# Eager load so subjects are discoverable (Zeitwerk lazy-loads otherwise). hooks.register(:env_infection_post) do Rails.application.eager_load! end + +# Disconnect the parent before workers clone the template database. +hooks.register(:setup_integration_post) do + base_records.each do |base| + disconnect_pool(base:) + end +end + +# Both registrations are required to isolate in both modes: +# mutation_worker_process_start for `mutant run`, test_worker_process_start for `mutant test`. +hooks.register(:test_worker_process_start) { |index:| isolate_index(index:) } +hooks.register(:mutation_worker_process_start) { |index:| isolate_index(index:) } + +def self.base_records + [ + ActiveRecord::Base, + ] +end + +def self.isolate_index(index:) + base_records.each do |base| + disconnect_pool(base:) + isolate_database(base:, index:) + end +end + +def self.isolate_database(base:, index:) + db_config = base + .connection_handler + .retrieve_connection_pool(base.connection_specification_name) + .db_config + + raw_template_database = db_config.database + raw_isolated_database = "#{raw_template_database}_mutant_worker_#{index}" + + with_root_connection do |connection| + template_database = PG::Connection.quote_ident(raw_template_database) + isolated_database = PG::Connection.quote_ident(raw_isolated_database) + + connection.exec("DROP DATABASE IF EXISTS #{isolated_database}") + connection.exec("CREATE DATABASE #{isolated_database} TEMPLATE #{template_database}") + end + + db_config._database = raw_isolated_database +end + +def self.disconnect_pool(base:) + base + .connection_handler + .retrieve_connection_pool(base.connection_specification_name) + .disconnect +end + +# Open a connection to the "postgres" maintenance database so we can issue +# CREATE/DROP DATABASE. (Replaces the guide's removed Base.postgresql_connection.) +def self.with_root_connection + base = ActiveRecord::Base + + config = base + .connection_handler + .retrieve_connection_pool(base.connection_specification_name) + .db_config + .configuration_hash + + connection = PG.connect( + host: config[:host], + port: config[:port] || 5432, + user: config[:username], + password: config[:password], + dbname: "postgres" + ) + + yield connection +ensure + connection&.close +end