diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..15d049f0b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,208 @@ +name: CI + +on: + push: + branches: [master, develop] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Suite (Ruby 2.7.8, Postgres 15) + # Pinned, not ubuntu-latest: ruby/setup-ruby has no prebuilt 2.7.8 for 24.04. + # If 22.04 is retired, move to `container: ruby:2.7.8-bullseye` with + # PGHOST: postgres rather than chasing runner images. + runs-on: ubuntu-22.04 + timeout-minutes: 45 + + services: + postgres: + image: postgres:15 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + + env: + # test/dummy/config/database.yml defines only `test`. Anything that boots + # the dummy app in `development` -- db:migrate and db:seed both do, via + # db:install -- aborts with ActiveRecord::AdapterNotSpecified. + RAILS_ENV: test + # database.yml specifies no host, so libpq falls back to PGHOST. That + # points it at the service container instead of a unix socket that does + # not exist on the runner, without editing the tracked config. + PGHOST: localhost + PGPORT: '5432' + PGUSER: postgres + # Rails deprecations print regardless; this adds the Ruby-level ones. + RUBYOPT: '-W:deprecated' + # features/support/open_on_first_failure.rb pops the first failed page + # open via Capybara's save_and_open_page -> Launchy -> xdg-open. Useful at + # a desk, useless on a headless runner, where it only prints "Couldn't + # find a suitable web browser!". Nothing here drives a real browser: there + # are no @javascript scenarios and no Capybara driver is ever selected. + launch_on_failure: 'false' + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7.8' + bundler: '1.17.3' + bundler-cache: true + + # Travis passed --without development. Not carried over: `rake` is + # declared in the :development group and the whole chain is rake-driven. + # + # `rake` alone, deliberately -- not features:all. The default cucumber + # profile excludes @cli, @known-bug and @missing-feature, and those + # exclusions are the point of the tags. The excluded scenarios are + # measured by the non-blocking job below so the number stays visible. + # `ci:test` runs coverage:check after the suite. + - name: Full suite (units, spec, functionals, orphans, features) + run: bundle exec rake + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: coverage/ + retention-days: 30 + + excluded-features: + name: Cucumber @cli and @known-bug (reporting only) + runs-on: ubuntu-22.04 + timeout-minutes: 45 + # Everything the default profile filters out, measured but not gating. + # Phase 0 baseline: @cli is 7/34 -- `rails new` fails inside aruba and 15 of + # the 27 failures are downstream of that one command. @known-bug is 2 + # failing scenarios that are tagged precisely because they are known to + # fail. This job exists so both numbers stay visible per-run instead of + # living only in docs/rails-upgrade/phase-0-baseline.md. Make it gating + # once the numbers reach zero. + continue-on-error: true + + services: + postgres: + image: postgres:15 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + + env: + RAILS_ENV: test + PGHOST: localhost + PGPORT: '5432' + PGUSER: postgres + # This job is the one that actually trips it: the After hook is + # After('~@cli'), so it fires on the @known-bug failures, not the @cli + # ones. See the note in the test job. + launch_on_failure: 'false' + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7.8' + bundler: '1.17.3' + bundler-cache: true + + # This job gets its own empty Postgres container. The `test` job only has + # a database because ci:test starts with db:drop, db:create:all, + # db:install -- the cucumber tasks assume one already exists, and + # project:ensure_db_exists only checks that database.yml is present, not + # that the database is. The same prefix ci:test uses -- including the + # drop, which has nothing to drop on a fresh container but keeps the step + # rerunnable: db:seed is not idempotent and fails on an already-seeded + # database with "Name has already been taken". + - name: Create and seed the test database + run: bundle exec rake db:drop db:create:all db:install + + - name: Cucumber - @cli features + run: bundle exec rake features:cli + + - name: Cucumber - @known-bug scenarios + if: always() + run: bundle exec rake features:known-bugs + + next-rails: + name: Rails 5.0 (Gemfile.next) + runs-on: ubuntu-22.04 + timeout-minutes: 45 + # GATING as of Phase 2, and currently RED on purpose. + # + # Phase 2 took this job from "754 unit tests / 2 failures / 323 errors" down + # to five named application defects by borrowing two Phase 3 fixes; the + # harness itself is done. What is left is not harness work and no amount of + # it will help: + # - 2x ActiveRecord::StaleObjectError on Cms::Page + # - skip_callback :redirect_to_cms_site (content_controller.rb:11) -- 5.0 + # raises when the callback is not defined, and this one is a *load* + # error, so it takes the whole functional suite down with it + # - PublishableTestCase#test_publish_on_save + # - PortletTest#test_.blacklist + # + # Those belong to Phase 3. This job was made gating anyway, deliberately, so + # the red is visible in the merge path rather than filed away in a report. + # It goes green when Phase 3 lands, not before. + # See docs/rails-upgrade/phase-2-harness-report.md. + + services: + postgres: + image: postgres:15 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + + env: + BUNDLE_GEMFILE: Gemfile.next + RAILS_ENV: test + PGHOST: localhost + PGPORT: '5432' + PGUSER: postgres + launch_on_failure: 'false' + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7.8' + bundler: '1.17.3' + bundler-cache: true + + # Assert the version rather than trusting BUNDLE_GEMFILE. test/dummy's + # boot.rb used to reassign BUNDLE_GEMFILE unconditionally, which made every + # spawned test process silently fall back to 4.2 -- a job like this one + # would have reported green while testing the wrong Rails for four phases. + # If this step ever fails, dual-boot is broken, not Rails 5. + - name: Assert the bundle really is Rails 5.0 + run: | + bundle exec ruby -e ' + require "./test/dummy/config/environment" + v = Rails.version + abort "Expected Rails 5.0.x, booted #{v} -- dual-boot is broken" unless v.start_with?("5.0.") + puts "Booted Rails #{v}" + ' + + - name: Create and seed the test database + run: bundle exec rake db:drop db:create:all db:install + + # `if: always()` went with continue-on-error: with the job gating, a step + # that runs regardless of whether the database step succeeded only turns + # one clear failure into two confusing ones. + - name: Suite + run: bundle exec rake diff --git a/.simplecov b/.simplecov index c81887847..a8db41e63 100644 --- a/.simplecov +++ b/.simplecov @@ -1,3 +1,38 @@ +# The suite runs as five separate processes (units, spec, functionals, orphans, +# features) that merge through coverage/.resultset.json. SimpleCov discards any stored +# result older than merge_timeout, which defaults to 600s -- so on a full run, +# which takes longer than that, the earliest suites silently drop out and the +# reported percentage covers only whatever finished inside the window. SimpleCov.start 'rails' do - # Config goes here + merge_timeout 3600 + + # Gated as of Phase 3: coverage:check compares this against COVERAGE_MINIMUM_BRANCH, + # which defaults to the 70.83% measured on a cleared resultset once the Phase 3 diff + # had landed. See lib/tasks/core_tasks.rake. + enable_coverage :branch + + # Each suite must name itself. Left to CommandGuesser, two suites can guess the + # same name and overwrite each other's entry in the resultset. Set by the + # Rakefile, one prerequisite task per suite. + command_name ENV['COVERAGE_SUITE'] if ENV['COVERAGE_SUITE'] + + # Generator *templates* are copied into a user's application, not executed + # here. demo.seeds.rb alone is 249 counted lines -- 13.5% of every missed line + # in the report -- and it is a seed script: loading it would run it. + # + # Block filters, not regexes. This was once a hard constraint: SimpleCov 0.12's + # parse_filter accepted only a String, an Array, a Filter or a block and raised + # ArgumentError on a Regexp, which defaults.rb rescued around `load .simplecov` + # -- so a regex filter did not fail loudly, it abandoned the rest of this file + # with one line on stderr. 0.22 accepts regexes and the constraint is gone; the + # block form is kept because it works and rewriting it would buy nothing. + # Match on the absolute path; that is what #filename returns. + add_filter { |src| src.filename.include?("/lib/generators/") && src.filename.include?("/templates/") } + add_filter { |src| src.filename.include?("/lib/templates/") } + + # The generator *classes* stay in the denominator. Excluding them was the + # standing recommendation, on the grounds that the @cli cucumber features + # cover them out of process where SimpleCov cannot see it. Phase 0 measured + # those features: 7 of 34 scenarios pass. The 0% is a measurement gap sitting + # on top of a real testing gap, and hiding it would misreport the second one. end diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 56dda9c14..000000000 --- a/.travis.yml +++ /dev/null @@ -1,38 +0,0 @@ -language: ruby -dist: bionic -os: linux - -branches: - only: - - master - - develop - -cache: - bundler: true - -bundler_args: --without development - -before_install: - # add postgres 15 repos - - sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' - - wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo tee /etc/apt/trusted.gpg.d/pgdg.asc &>/dev/null - - sudo apt-get update - # install postgres 15 - - sudo apt install postgresql postgresql-client -y - # fix the postgres port number so that it is 5432 - - sudo sed -i 's/port = 5433/port = 5432/' /etc/postgresql/15/main/postgresql.conf - # allow all postgres connections without a password - NOT SAFE FOR PRODUCTION - - sudo sed -i -e 's/peer/trust/g' -e 's/scram-sha-256/trust/g' /etc/postgresql/15/main/pg_hba.conf - # restart postgres to pickup the config changes - - sudo -u postgres /usr/lib/postgresql/15/bin/pg_ctl restart -l /var/log/postgresql/postgresql-15-main.log -D /var/lib/postgresql/15/main - # give it a bit of time to run - - sleep 1 - # check the state of the installed postgres's - - pg_lsclusters - -install: - - psql -c 'create database browsercms_test;' -U postgres - - gem install bundler:1.17.3 - - bundle install - -script: bundle exec rake diff --git a/Gemfile b/Gemfile index 1a00477f8..ceb574728 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,11 @@ +# Dual-boot (next_rails). Gemfile.next is a *symlink* to this file, so both +# bundles evaluate the same Gemfile and this is the only thing that tells them +# apart. Note that browsercms.gemspec cannot call this -- Bundler evaluates the +# gemspec in a Gem::Specification context, so it keys off BUNDLE_GEMFILE instead. +def next? + File.basename(__FILE__) == "Gemfile.next" +end + source 'http://rubygems.org' ruby '2.7.8' @@ -7,7 +15,7 @@ gemspec # gem 'query_reviewer' # Enable for performance tuning gem 'puma', '~> 4' -gem 'railties', '~> 4.2' +gem 'railties', next? ? '~> 5.0.0' : '~> 4.2' # Uncomment to confirm that older versions work (for compaitiblity with Spree 2.2.4/bcms_spree) # gem 'paperclip', '~> 3.4.1' # For testing behavior in production @@ -17,36 +25,68 @@ end group :development do gem 'rake' + # Dual-boot tooling for the Rails upgrade: supplies `next_rails --init` + # (Gemfile.next) and `bundle_report compatibility`. A maintainer tool, so it + # belongs here and not in browsercms.gemspec. + gem 'next_rails' # gem 'debugger' # gem 'quiet_assets' end group :test, :development do - gem 'minitest' - gem 'minitest-rails' + # Rails 5.0's Rails::TestUnitReporter (railties/lib/rails/test_unit/reporter.rb) + # calls result.method, which predates Minitest::Result -- introduced in minitest + # 5.11. On a newer minitest the reporter raises NameError while formatting the + # *first* failure and takes the whole run down, so nothing is reportable. + # Pinned for the next bundle only; revisit when the hop passes 5.1. + if next? + gem 'minitest', '~> 5.10.3' + else + gem 'minitest' + end + # minitest-rails removed: it capped railties ~> 4.1 and nothing used it -- + # every reference in test/minitest_helper.rb was already commented out. The + # cheapest of the six Rails 5 blockers. (minitest_helper.rb itself stays; 19 + # test files require it.) gem 'minitest-reporters' gem 'yard' gem 'bluecloth' gem 'pry' - gem 'awesome_print' + # Not auto-required: requiring it monkeypatches Array#grep with an + # implementation that does `"str" =~ SomeClass`, which floods Ruby 2.7 with + # "deprecated Object#=~ is called on Class" warnings from every + # ActiveRecord where(hash) call. Run `require 'awesome_print'` in a console + # when you actually want `ap`. + gem 'awesome_print', require: false end group :test do gem 'pg' gem 'sass-rails' - gem 'simplecov', require: false + gem 'simplecov', '~> 0.22.0', require: false - gem 'poltergeist' gem 'm' gem 'single_test' - gem 'factory_girl_rails' - gem 'mocha', require: false + # 4.2 has assert_template and assigns built in; 5.0 extracted them. The gem + # cannot resolve on 4.2 (it needs actionpack >= 5.0.1.rc1), so this is one of + # the few places a next? branch is not a smell -- it is the only expressible + # form. Criterion 12 is about test *code*, not the Gemfile. + gem 'rails-controller-testing' if next? + gem 'factory_bot_rails', '~> 5.2' + # 1.x, not 2.x: `mocha/minitest` exists from 1.5.0, so 1.x satisfies the Rails 5 + # requirement in full. Mocha 2.0 removes the legacy entry points *and* changes + # any_instance and configuration behaviour across 109 call sites -- a + # modernisation with no Rails deadline. See phase-2-implementation-plan.md D4. + gem 'mocha', '~> 1.16', require: false # Cucumber and dependencies gem 'capybara' gem 'cucumber-rails', require: false gem 'database_cleaner' gem 'launchy' + + # ruby-prof needs this config for installation on modern macos + # bundle config --global build.ruby-prof --with-cflags="-Wno-incompatible-pointer-types" gem 'ruby-prof' gem 'aruba', '= 0.14.14' gem 'loofah', '= 2.19.1' diff --git a/Gemfile.lock b/Gemfile.lock index 51c50e047..d7ab787e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,10 @@ PATH remote: . specs: - browsercms (5.0.2) + browsercms (5.2.0) actionpack-page_caching (~> 1.0) ancestry (~> 3.0.0) - bigdecimal (~> 1.4.0) + bigdecimal bootstrap-sass ckeditor_rails (~> 4.3.0) compass-rails @@ -14,11 +14,12 @@ PATH panoramic paperclip (~> 5.0) rails (~> 4.2.0) + responders sass-rails simple_form (~> 3.1.0) term-ansicolor underscore-rails (~> 1.4) - will_paginate (~> 3.0.0) + will_paginate (= 3.3.1) GEM remote: http://rubygems.org/ @@ -71,11 +72,11 @@ GEM ffi (~> 1.9) rspec-expectations (>= 2.99) thor (>= 0.19, < 2.0) - autoprefixer-rails (10.4.15.0) + autoprefixer-rails (10.4.21.0) execjs (~> 2) awesome_print (1.7.0) - bcrypt (3.1.19) - bigdecimal (1.4.4) + bcrypt (3.1.22) + bigdecimal (4.1.2) bluecloth (2.2.0) bootstrap-sass (3.4.1) autoprefixer-rails (>= 5.2.1) @@ -93,7 +94,6 @@ GEM ckeditor_rails (4.3.4) railties (>= 3.0) climate_control (0.2.0) - cliver (0.3.2) coderay (1.1.1) compass (1.0.3) chunky_png (~> 1.2) @@ -132,24 +132,24 @@ GEM railties (>= 3, < 5.1) cucumber-wire (0.0.1) database_cleaner (1.5.3) - date (3.3.3) + date (3.5.1) delayed_job (4.1.11) activesupport (>= 3.0, < 8.0) - devise (4.9.2) + devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) diff-lcs (1.2.5) - docile (1.1.5) + docile (1.4.1) erubis (2.7.0) execjs (2.7.0) - factory_girl (4.7.0) - activesupport (>= 3.0.0) - factory_girl_rails (4.7.0) - factory_girl (~> 4.7.0) - railties (>= 3.0.0) + factory_bot (5.2.0) + activesupport (>= 4.2.0) + factory_bot_rails (5.2.0) + factory_bot (~> 5.2.0) + railties (>= 4.2.0) ffi (1.15.5) gherkin (4.0.0) globalid (0.4.2) @@ -161,21 +161,21 @@ GEM thor (>= 0.14, < 2.0) jquery-ui-rails (4.2.1) railties (>= 3.2.16) - json (2.6.3) launchy (2.4.3) addressable (~> 2.3) + logger (1.7.0) loofah (2.19.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) m (1.5.0) method_source (>= 0.6.7) rake (>= 0.9.2.2) - mail (2.8.1) + mail (2.9.1) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - metaclass (0.0.4) method_source (0.8.2) mime-types (3.3.1) mime-types-data (~> 3.2015) @@ -186,27 +186,24 @@ GEM mini_mime (1.1.5) mini_portile2 (2.8.4) minitest (5.19.0) - minitest-rails (2.2.1) - minitest (~> 5.7) - railties (~> 4.1) minitest-reporters (1.1.11) ansi builder minitest (>= 5.0) ruby-progressbar - mocha (1.2.0) - metaclass (~> 0.0.1) + mocha (1.16.1) multi_json (1.12.1) multi_test (0.1.2) - net-imap (0.3.7) + net-imap (0.4.25) date net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.2.1) + net-protocol (0.2.2) timeout - net-smtp (0.3.3) + net-smtp (0.5.1) net-protocol + next_rails (1.6.0) nio4r (2.5.9) nokogiri (1.13.10) mini_portile2 (~> 2.8.0) @@ -221,10 +218,6 @@ GEM mimemagic (~> 0.3.0) terrapin (~> 0.6.0) pg (0.19.0) - poltergeist (1.11.0) - capybara (~> 2.1) - cliver (~> 0.3.1) - websocket-driver (>= 0.2.0) pry (0.10.4) coderay (~> 1.1.0) method_source (~> 0.8.1) @@ -261,7 +254,7 @@ GEM thor (>= 0.18.1, < 2.0) rake (13.0.6) rb-fsevent (0.11.2) - rb-inotify (0.10.1) + rb-inotify (0.11.1) ffi (~> 1.0) responders (2.4.1) actionpack (>= 4.2.0, < 6.0) @@ -284,11 +277,12 @@ GEM simple_form (3.1.1) actionpack (~> 4.0) activemodel (~> 4.0) - simplecov (0.12.0) - docile (~> 1.1.0) - json (>= 1.8, < 3) - simplecov-html (~> 0.10.0) - simplecov-html (0.10.0) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) single_test (0.6.0) rake slop (3.6.0) @@ -300,15 +294,16 @@ GEM activesupport (>= 3.0) sprockets (>= 2.8, < 4.0) sync (0.5.0) - term-ansicolor (1.7.1) - tins (~> 1.0) + term-ansicolor (1.11.3) + tins (~> 1) terrapin (0.6.0) climate_control (>= 0.0.3, < 1.0) thor (1.2.2) thread_safe (0.3.6) tilt (2.0.5) - timeout (0.4.0) - tins (1.32.1) + timeout (0.6.1) + tins (1.43.0) + bigdecimal sync tzinfo (1.2.11) thread_safe (~> 0.1) @@ -317,10 +312,7 @@ GEM underscore-rails (1.8.3) warden (1.2.7) rack (>= 1.0) - websocket-driver (0.6.4) - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.2) - will_paginate (3.0.12) + will_paginate (3.3.1) xpath (2.0.0) nokogiri (~> 1.3) yard (0.9.5) @@ -337,23 +329,22 @@ DEPENDENCIES cucumber-rails database_cleaner delayed_job - factory_girl_rails + factory_bot_rails (~> 5.2) launchy loofah (= 2.19.1) m minitest - minitest-rails minitest-reporters - mocha + mocha (~> 1.16) + next_rails pg - poltergeist pry puma (~> 4) railties (~> 4.2) rake ruby-prof sass-rails - simplecov + simplecov (~> 0.22.0) single_test uglifier yard diff --git a/Gemfile.next b/Gemfile.next new file mode 120000 index 000000000..6ab79009c --- /dev/null +++ b/Gemfile.next @@ -0,0 +1 @@ +Gemfile \ No newline at end of file diff --git a/Gemfile.next.lock b/Gemfile.next.lock new file mode 100644 index 000000000..29acf4bcc --- /dev/null +++ b/Gemfile.next.lock @@ -0,0 +1,370 @@ +PATH + remote: . + specs: + browsercms (5.2.0) + actionpack-page_caching (~> 1.0) + ancestry (~> 3.0.0) + bigdecimal + bootstrap-sass + ckeditor_rails (~> 4.5.10) + compass-rails + devise (~> 4.0) + jquery-rails (~> 4.0) + jquery-ui-rails (~> 4.1) + panoramic + paperclip (~> 5.0) + rails (~> 5.0.0) + responders + sass-rails + simple_form (~> 3.5) + term-ansicolor + underscore-rails (~> 1.4) + will_paginate (= 3.3.1) + +GEM + remote: http://rubygems.org/ + specs: + actioncable (5.0.7.2) + actionpack (= 5.0.7.2) + nio4r (>= 1.2, < 3.0) + websocket-driver (~> 0.6.1) + actionmailer (5.0.7.2) + actionpack (= 5.0.7.2) + actionview (= 5.0.7.2) + activejob (= 5.0.7.2) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (5.0.7.2) + actionview (= 5.0.7.2) + activesupport (= 5.0.7.2) + rack (~> 2.0) + rack-test (~> 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + actionpack-page_caching (1.2.4) + actionpack (>= 4.0.0) + actionview (5.0.7.2) + activesupport (= 5.0.7.2) + builder (~> 3.1) + erubis (~> 2.7.0) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.0.3) + activejob (5.0.7.2) + activesupport (= 5.0.7.2) + globalid (>= 0.3.6) + activemodel (5.0.7.2) + activesupport (= 5.0.7.2) + activerecord (5.0.7.2) + activemodel (= 5.0.7.2) + activesupport (= 5.0.7.2) + arel (~> 7.0) + activesupport (5.0.7.2) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + addressable (2.4.0) + ancestry (3.0.7) + activerecord (>= 3.2.0) + ansi (1.5.0) + arel (7.1.4) + aruba (0.14.14) + childprocess (>= 0.6.3, < 4.0.0) + contracts (~> 0.9) + cucumber (>= 1.3.19) + ffi (~> 1.9) + rspec-expectations (>= 2.99) + thor (>= 0.19, < 2.0) + autoprefixer-rails (10.4.21.0) + execjs (~> 2) + awesome_print (1.7.0) + base64 (0.3.0) + bcrypt (3.1.22) + bigdecimal (4.1.2) + bluecloth (2.2.0) + bootstrap-sass (3.4.1) + autoprefixer-rails (>= 5.2.1) + sassc (>= 2.0.0) + builder (3.3.0) + capybara (2.10.1) + addressable + mime-types (>= 1.16) + nokogiri (>= 1.3.3) + rack (>= 1.0.0) + rack-test (>= 0.5.4) + xpath (~> 2.0) + childprocess (3.0.0) + chunky_png (1.4.0) + ckeditor_rails (4.5.11) + railties (>= 3.0) + climate_control (0.2.0) + coderay (1.1.1) + compass (1.0.3) + chunky_png (~> 1.2) + compass-core (~> 1.0.2) + compass-import-once (~> 1.0.5) + rb-fsevent (>= 0.9.3) + rb-inotify (>= 0.9) + sass (>= 3.3.13, < 3.5) + compass-core (1.0.3) + multi_json (~> 1.0) + sass (>= 3.3.0, < 3.5) + compass-import-once (1.0.5) + sass (>= 3.2, < 3.5) + compass-rails (4.0.0) + compass (~> 1.0.0) + sass-rails (< 5.1) + sprockets (< 4.0) + concurrent-ruby (1.3.8) + contracts (0.16.1) + crass (1.0.7) + cucumber (2.4.0) + builder (>= 2.1.2) + cucumber-core (~> 1.5.0) + cucumber-wire (~> 0.0.1) + diff-lcs (>= 1.1.3) + gherkin (~> 4.0) + multi_json (>= 1.7.5, < 2.0) + multi_test (>= 0.1.2) + cucumber-core (1.5.0) + gherkin (~> 4.0) + cucumber-rails (1.4.5) + capybara (>= 1.1.2, < 3) + cucumber (>= 1.3.8, < 4) + mime-types (>= 1.16, < 4) + nokogiri (~> 1.5) + railties (>= 3, < 5.1) + cucumber-wire (0.0.1) + database_cleaner (1.5.3) + date (3.5.1) + delayed_job (4.1.11) + activesupport (>= 3.0, < 8.0) + devise (4.9.4) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) + diff-lcs (1.2.5) + docile (1.4.1) + erubis (2.7.0) + execjs (2.7.0) + factory_bot (5.2.0) + activesupport (>= 4.2.0) + factory_bot_rails (5.2.0) + factory_bot (~> 5.2.0) + railties (>= 4.2.0) + ffi (1.15.5) + gherkin (4.0.0) + globalid (1.1.0) + activesupport (>= 5.0) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + jquery-rails (4.6.1) + rails-dom-testing (>= 1, < 3) + railties (>= 4.2.0) + thor (>= 0.14, < 2.0) + jquery-ui-rails (4.2.1) + railties (>= 3.2.16) + launchy (2.4.3) + addressable (~> 2.3) + logger (1.7.0) + loofah (2.19.1) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) + m (1.5.0) + method_source (>= 0.6.7) + rake (>= 0.9.2.2) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + method_source (0.8.2) + mime-types (3.3.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2020.0512) + mimemagic (0.3.10) + nokogiri (~> 1) + rake + mini_mime (1.1.5) + mini_portile2 (2.8.9) + minitest (5.10.3) + minitest-reporters (1.1.11) + ansi + builder + minitest (>= 5.0) + ruby-progressbar + mocha (1.16.1) + multi_json (1.12.1) + multi_test (0.1.2) + net-imap (0.4.25) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + next_rails (1.6.0) + nio4r (2.5.9) + nokogiri (1.15.7) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + orm_adapter (0.5.0) + panoramic (0.0.6) + rails (>= 3.0.7) + paperclip (5.3.0) + activemodel (>= 4.2.0) + activesupport (>= 4.2.0) + mime-types + mimemagic (~> 0.3.0) + terrapin (~> 0.6.0) + pg (0.19.0) + pry (0.10.4) + coderay (~> 1.1.0) + method_source (~> 0.8.1) + slop (~> 3.4) + puma (4.3.12) + nio4r (~> 2.0) + racc (1.8.1) + rack (2.2.23) + rack-test (0.6.3) + rack (>= 1.0) + rails (5.0.7.2) + actioncable (= 5.0.7.2) + actionmailer (= 5.0.7.2) + actionpack (= 5.0.7.2) + actionview (= 5.0.7.2) + activejob (= 5.0.7.2) + activemodel (= 5.0.7.2) + activerecord (= 5.0.7.2) + activesupport (= 5.0.7.2) + bundler (>= 1.3.0) + railties (= 5.0.7.2) + sprockets-rails (>= 2.0.0) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.5.0) + loofah (~> 2.19, >= 2.19.1) + railties (5.0.7.2) + actionpack (= 5.0.7.2) + activesupport (= 5.0.7.2) + method_source + rake (>= 0.8.7) + thor (>= 0.18.1, < 2.0) + rake (13.4.2) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + responders (3.0.1) + actionpack (>= 5.0) + railties (>= 5.0) + rspec-expectations (3.12.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.1) + ruby-prof (0.16.2) + ruby-progressbar (1.8.1) + sass (3.4.22) + sass-rails (5.0.6) + railties (>= 4.0.0, < 6) + sass (~> 3.1) + sprockets (>= 2.8, < 4.0) + sprockets-rails (>= 2.0, < 4.0) + tilt (>= 1.1, < 3) + sassc (2.4.0) + ffi (~> 1.9) + simple_form (3.5.1) + actionpack (> 4, < 5.2) + activemodel (> 4, < 5.2) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + single_test (0.6.0) + rake + slop (3.6.0) + sprockets (3.7.5) + base64 + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.2.2) + actionpack (>= 4.0) + activesupport (>= 4.0) + sprockets (>= 3.0.0) + sync (0.5.0) + term-ansicolor (1.11.3) + tins (~> 1) + terrapin (0.6.0) + climate_control (>= 0.0.3, < 1.0) + thor (1.5.0) + thread_safe (0.3.6) + tilt (2.0.5) + timeout (0.6.1) + tins (1.43.0) + bigdecimal + sync + tzinfo (1.2.11) + thread_safe (~> 0.1) + uglifier (3.0.2) + execjs (>= 0.3.0, < 3) + underscore-rails (1.8.3) + warden (1.2.9) + rack (>= 2.0.9) + websocket-driver (0.6.4) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.2) + will_paginate (3.3.1) + xpath (2.0.0) + nokogiri (~> 1.3) + yard (0.9.5) + +PLATFORMS + ruby + +DEPENDENCIES + aruba (= 0.14.14) + awesome_print + bluecloth + browsercms! + capybara + cucumber-rails + database_cleaner + delayed_job + factory_bot_rails (~> 5.2) + launchy + loofah (= 2.19.1) + m + minitest (~> 5.10.3) + minitest-reporters + mocha (~> 1.16) + next_rails + pg + pry + puma (~> 4) + rails-controller-testing + railties (~> 5.0.0) + rake + ruby-prof + sass-rails + simplecov (~> 0.22.0) + single_test + uglifier + yard + +RUBY VERSION + ruby 2.7.8p225 + +BUNDLED WITH + 1.17.3 diff --git a/RAILS_UPGRADE_TEST_PRIORITY.md b/RAILS_UPGRADE_TEST_PRIORITY.md new file mode 100644 index 000000000..94cf86ac2 --- /dev/null +++ b/RAILS_UPGRADE_TEST_PRIORITY.md @@ -0,0 +1,484 @@ +# Rails 8 Upgrade — Test Priority, Ordered by Breakage Risk + +**Date:** 2026-07-28 (reconciled against the `rails-upgrade` skill 2026-07-28) +**Baseline:** Rails **4.2.11.3**, Ruby 2.7.8, coverage 72.64% (see `TEST_COVERAGE_PLAN.md` for the coverage data this builds on) +**Goal:** not a coverage number — *confidence that the upgrade did not break BrowserCMS*. +**Method:** static sweep of `app/` and `lib/` for APIs removed or changed between Rails 4.2 and 8, cross-referenced against per-file coverage, then reconciled against the `rails-upgrade` skill (FastRuby.io methodology) — its `version-guides/`, `detection-scripts/patterns/rails-42-patterns.yml`, `rails-50-patterns.yml`, `rails-51-patterns.yml`, `rails-52-patterns.yml`, `rails-60-patterns.yml`, and `references/breaking-changes-by-version.md`. + +> **Status: reconciled for the 4.2 → 5.0 hop.** Every ⚠️-flagged version claim in the original draft has been checked against the skill and now carries an explicit verdict (§0). Claims the skill does not cover are labelled **skill silent** rather than confirmed — those are the only ones still requiring independent verification, and they are named individually. Claims about hops beyond 5.0 (Tier C removal versions) were corrected where the skill contradicted them, but the *detection sweep* was scoped to 4.2 → 5.0; re-run the skill's per-version patterns at each subsequent hop rather than trusting this document for 6.x/7.x/8.x. + +> **Correction to the original header:** the baseline was recorded as Rails 4.2.0. That is the gemspec *constraint* (`s.add_dependency "rails", "~> 4.2.0"`); `Gemfile.lock` resolves **rails 4.2.11.3**, the final 4.2 patch. This matters: the skill makes "be on the latest patch of the current series" a **mandatory pre-step (Step 0)** before any version hop. **That step is already satisfied** — no patch upgrade is needed, and the sequence in §6 can begin at the test suite. + +--- + +## 0. Reconciliation verdicts + +Every version-removal claim in the original draft, checked against the skill. **Verdict legend:** ✅ confirmed · ✏️ corrected · 🔇 skill silent (still unverified — do not treat as fact) · ➕ skill found something the sweep missed. + +### 0.1 Claims that were flagged ⚠️ + +| # | Claim as drafted | Skill source | Verdict | +|---|---|---|---| +| C1 | `before_filter` / `after_filter` / `skip_before_filter` removed in **5.1** | `rails-51-patterns.yml` → `FILTER_METHODS`, `kind: breaking`: "Rails 5.1 removes all `*_filter` controller callbacks" | ✅ **confirmed.** Note the skill also searches `lib/` for this pattern, which is why the sweep's 3 non-controller sites (`acts/content_page.rb`, `admin_tab.rb`, `authentication/controller.rb`) count. | +| C2 | `render text:` removed in **5.1** | `upgrade-5.0-to-5.1.md` §2 + `rails-51-patterns.yml` → `RENDER_TEXT`, `kind: breaking` | ✅ **confirmed.** Skill adds: use `render plain:` for text/plain, `render html:` for HTML. | +| C3 | `Relation#uniq` removed in **5.1** | `rails-51-patterns.yml` → `RELATION_UNIQ`, `kind: breaking` | ✅ **confirmed.** Skill warns the pattern false-positives on `Array#uniq`; the sweep's single site (`section_nodes_controller.rb:75`) was already receiver-verified. | +| C4 | `.deliver` removed in **5.0** | `rails-42-patterns.yml` → `kind: deprecation` at 4.2; `upgrade-4.1-to-4.2.md` §1 | ✅ **confirmed, and promoted.** The deprecation is firing **on the current version** — this is fixable today, before any bump, and the skill's methodology says deprecations get cleared *before* the hop. See ➕A4: the sweep also undercounted the sites. | +| C5 | bare `HashWithIndifferentAccess` removed in **5.2** | no matching pattern in `rails-51/52-patterns.yml`; the skill's 5.0→5.1 guide covers only the *indexing* change | 🔇 **skill silent.** 2 sites confirmed by grep (`page_component.rb:10`, `portlet.rb:228`). Fix is free and version-agnostic — qualify to `ActiveSupport::HashWithIndifferentAccess` now and stop tracking the removal version. | +| C6 | `alias_method_chain` removed in **5.1** | no skill entry | 🔇 **skill silent** — and moot. Sole occurrence is a comment (`versioning.rb:209`). No action. | +| C7 | `update_attributes` removed in **7.0** | `upgrade-5.2-to-6.0.md` §3 "`update_attributes` and `update_attributes!` are removed"; `references/breaking-changes-by-version.md` lists it under 5.2 → 6.0; `rails-60-patterns.yml` calls it "deprecated in Rails 6.0" | ✏️ **corrected — much earlier than drafted.** The skill places this at the **6.0 hop**, not 7.0. (The skill is itself inconsistent on removed-vs-deprecated at 6.0; either way it is a 6.x problem.) All 16 sites must be rewritten before the 6.0 bump. `update` works identically on Rails 4.2, so this is a **safe pre-emptive change to make now**. | +| C8 | Classic autoloader removed in **7**; `require_dependency`'s fate uncertain | `upgrade-5.2-to-6.0.md` §1 (Zeitwerk); `rails-60-patterns.yml` → `require_dependency`; `references/breaking-changes-by-version.md`: "Zeitwerk autoloader / ALL apps / Remove require_dependency" at 5.2 → 6.0 | ✏️ **corrected — one hop earlier than drafted.** Zeitwerk becomes the default at **6.0** and is where the work lands; the classic autoloader's final removal at 7.0 is the deadline, not the trigger. The skill is unambiguous that `require_dependency` calls are to be **removed**, so `content_types_controller.rb:1` is a 6.0-hop item. **This promotes B10's eager-load test** — it is the cheapest defence against the largest single item in the plan, and it is needed two hops sooner than the draft assumed. | +| C9 | `belongs_to` required by default at **5.0** | `upgrade-4.2-to-5.0.md` §3, 🔴 HIGH; `rails-50-patterns.yml` → `BELONGS_TO_REQUIRED` | ✅ **confirmed** — and see ➕A1 for a materially larger blast radius than drafted. | +| C10 | `ActionController::Parameters` is no longer a Hash at 5.0; uncertain which operations changed | `upgrade-4.2-to-5.0.md` §5, 🔴 HIGH; `rails-50-patterns.yml` → `PARAMS_AS_HASH` names `slice`, `except`, `merge`, `symbolize_keys`, `to_hash` | ✅ **confirmed, with the specifics filled in.** The skill's named methods do **not** include `.delete` — which is all 8 sites the sweep found. See ➕A2: running the skill's actual pattern surfaced 2 more sites the sweep missed, both of which *are* on the skill's list. | +| C11 | `find_by_` is still supported; only `find_all_by_*` / `find_last_by_*` / `scoped_by_*` were removed (4.1). *"The claim most worth double-checking — wrongly fixing 14 call sites would be pure waste."* | `upgrade-4.0-to-4.1.md` §1: "`activerecord-deprecated_finders` was removed as a Rails dependency. `find_all_by_*`, `find_last_by_*`, `scoped_by_*`, `find_or_initialize_by_*`, `find_or_create_by_*` no longer work out of the box"; `rails-40-patterns.yml` confirms the 4.1 removal boundary | ✅ **confirmed.** The skill's removal list matches exactly, and `find_by_` appears nowhere in it. **Do not touch the ~13 `find_by_*` call sites.** The draft's instinct was right and the waste is avoided. | +| C12 | `respond_with` + class-level `respond_to` extracted to `responders` | `rails-42-patterns.yml` flags both as `kind: **breaking**` — i.e. already breaking as of the *current* version | ✅ **confirmed, and promoted out of Tier C.** `Gemfile.lock` shows `responders (2.4.1)` arriving only as a transitive dependency of `devise`; nothing in `browsercms.gemspec` declares it. The skill's methodology puts `kind: breaking` in **fix-before-bump**. Declare `responders` explicitly in the gemspec now — a one-line, zero-risk change that removes a dependency landmine before the Devise upgrade can move it. | +| C13 | Callback halting: `return false` no longer halts (5.0); sweep found **no** `return false` inside any callback body | `upgrade-4.2-to-5.0.md` §8; `rails-50-patterns.yml` → `CALLBACK_HALT`, which the skill itself calls "a noisy pattern — only matches inside `before_*` callbacks need fixing" | ✅ **confirmed clean.** The sweep's method (inspect all 11 sites individually across `app/` **and** `lib/`) is *wider* than the skill's search paths (`app/models/`, `app/controllers/` only) and reached the same conclusion. Additionally verified: `ActiveSupport.halt_callback_chains_on_return_false` — which `rails-52-patterns.yml` flags as a boot-time `NoMethodError` at 5.2 — appears **nowhere** in the repo. No action at 5.0 or 5.2. | +| C14 | `ColumnDumper` folded into `SchemaDumper`, `column_spec` signature changed, somewhere in 5.1–6.0 | **no skill entry** for `ColumnDumper`, `column_spec`, or schema-dumper internals in any version guide or pattern file | 🔇 **skill silent.** B1 remains the top Tier-B item on the strength of its *failure mode* (a monkeypatch on a vanished module silently defines an empty module — no error, truncated `db/schema.rb`), which does not depend on knowing the exact version. But **the version claim is still unverified.** The guard test B1 prescribes — assert `ActiveRecord::ConnectionAdapters::ColumnDumper` is already defined when the patch file loads — is precisely the right response to an unverified claim: it converts the unknown into a loud failure at the hop where it actually happens. Write it before the 5.1 bump. | +| C15 | Rails 5 rewrote the attribute layer; internal reads route through `_read_attribute`, bypassing the `read_attribute` alias chain | **no skill entry** for `read_attribute` / `_read_attribute` / the attribute-object rewrite | 🔇 **skill silent.** B2's ranking stands on the alias-chain fragility and the 0.19x test-to-source ratio, not on the version claim. As with C14, the prescribed test (assert `read_attribute` **and** `_read_attribute` both resolve dynamic attributes) is version-agnostic and will fail loudly at whichever hop breaks it. | +| C16 | Two-argument `connection.quote(value, column)` deprecated and removed | **no skill entry** | 🔇 **skill silent.** B7's hand-built SQL in `publishing.rb:143-146` is worth a test regardless — the prescribed test (assert `publish!` flips `published` in the database, read back through a fresh query) tests the *behaviour*, so it survives being wrong about the version. | +| C17 | Paperclip 5.3.0 is EOL and will not work on Rails 8 | not a Rails API claim; the skill routes this to **Step 4.5** (`workflows/gem-compatibility-workflow.md`, `next_rails` `bundle_report compatibility`) and **Step 4.6** (boot smoke test against `Gemfile.next`) | ✅ **confirmed as a process gap, not a knowledge gap.** Stop reasoning about gem EOL by hand. The skill's per-lockfile compatibility check will bucket Paperclip — and `mocha`, `factory_girl`, `cucumber`, `capybara`, `database_cleaner`, `aruba`, `poltergeist`, `compass-rails`, `jquery-rails`, `simplecov` — into required-bumps / blockers / already-compatible with real data. **Run it before finalising Phase 0's scope.** B4's validation-macro tests remain correct and necessary either way; they are the acceptance criteria for whatever replaces Paperclip. | + +### 0.2 What the skill's patterns found that the sweep missed + +Running the skill's `rails-42` and `rails-50` pattern sets against `app/` and `lib/` surfaced six items absent from the original draft. Coverage figures are from `coverage/index.html`; per-line hit counts from `coverage/.resultset.json`. + +| # | Finding | Location | Coverage | Why it matters | +|---|---|---|---|---| +| ➕A1 | **`belongs_to` blast radius is 29 declarations, not 24 — and 5 of them are injected into every model that uses a behavior** | the drafted 24 in `app/models/` **plus** `behaviors/userstamping.rb:16-17` (`created_by`, `updated_by`), `behaviors/categorizing.rb:16` (`category`), `behaviors/versioning.rb:115` (version row → parent), `behaviors/dynamic_attributes.rb:168` (`base_class`) | `userstamping.rb` 100%, `categorizing.rb` 88.89%, `versioning.rb` 96.91%, `dynamic_attributes.rb` 91.47% | The 24 static declarations affect 24 models. The 5 injected ones affect **every model in BrowserCMS and every downstream project that uses the behavior.** `created_by` / `updated_by` becoming required would fail every save made without a logged-in user — and the *only* existing `userstamping` tests are the nil-user cases, so they would go red immediately. That is fortunate, not sufficient: it is the one place the flag fails loudly. `Cms::Category#parent` (self-referential) and every polymorphic `belongs_to` fail silently into validation errors instead. | +| ➕A2 | **Two more `ActionController::Parameters`-as-Hash sites, both using methods the skill explicitly names** | `content_controller.rb:72` — `params.except(:controller, :action, :path)` inside `render_editing_frame`, passed to `ActionDispatch::Http::URL.url_for`; `path_helper.rb:33-36` — `params.clone` then `.delete` ×2 then `.merge!`, passed to `polymorphic_path` | `content_controller.rb:72` hit **153×**; `path_helper.rb:33-36` hit **49×** — both **covered** | B9 grows from 8 sites to 10. But note the asymmetry: these two are on well-exercised lines, so a `Parameters`-vs-`Hash` breakage here surfaces as a **loud CI failure**. The four sites in `form_fields_controller.rb:16`, `forms_controller.rb:33` (both **0% files**) and the untested branches of `pages_controller.rb:126-128` / `sections_controller.rb:43` are the dangerous ones. **This sharpens B9's priority rather than raising it: test the uncovered `.delete` sites, and let CI catch `.except` / `.clone` / `.merge!`.** | +| ➕A3 | **`HTML::FullSanitizer` — a Rails-4.2-era API kept alive by a transitive gem that cannot survive the 5.0 bump** | `lib/cms/content_filter.rb:12` — `HTML::FullSanitizer.new.sanitize(c[key]).strip`. Also asserted directly at `test/functional/cms/inline_controller_test.rb:7` | `content_filter.rb` **100%** (8 relevant lines) | The skill's `rails-42-patterns.yml` flags sanitizer usage as `kind: **breaking**`. `HTML::FullSanitizer` does not exist in Rails 4.2 itself — it comes from `rails-deprecated_sanitizer (1.0.4)`, pulled in only because `rails-dom-testing (1.0.9)` requires it. `rails-dom-testing 1.x` is capped at `activesupport < 5.0`, so **the moment Rails 5 resolves, `rails-dom-testing` goes to 2.x, `rails-deprecated_sanitizer` disappears from the bundle, and `content_filter.rb:12` raises `NameError`.** Loud and covered, so Tier C — but it is a *gem-topology* failure invisible to a code grep, which is exactly what the skill's Step 4.6 boot smoke test exists to catch. Rewrite to `ActionView::Base.full_sanitizer` / `Rails::Html::FullSanitizer` before the bump. Note `inline_controller_test.rb:7` asserts on the doomed gem — the draft correctly observed this test says nothing about its controller; it turns out to be worse than useless, it is a test of a dependency about to be removed. | +| ➕A4 | **`.deliver!` at a second, *uncovered* site** | `email_message.rb:18` — `m.deliver!`. (`:15` is `def self.deliver!`, a definition, not a call.) The draft found only `:58` `.deliver` | `:58` hit **14×** (covered); **`:18` hit 0× — uncovered** | The draft listed 1 site and it was the covered one. The skill's `rails-42` pattern covers `.deliver!` as well as `.deliver`, and the bang form needs `deliver_now!`, not `deliver_now`. An uncovered mail-delivery call on the form-notification path is exactly the class of thing that fails in production rather than CI. | +| ➕A5 | **`File.exists?` at 5 sites, not 1 — two of them uncovered** | `list_portlet.rb:22` (**0 hits**), `lib/cms/caching.rb:42` (24 hits), `lib/cms/attachments/attachment_serving.rb:44` (9 hits), `lib/tasks/core_tasks.rake:51`, `content_block_generator.rb:26` (the only one the draft found) | `list_portlet.rb` 36.36% / 14 missed; `caching.rb` 100%; `attachment_serving.rb` 88.46% | Undercounted 5× in the original draft. `caching.rb` and `attachment_serving.rb` are live request-path code, not generator code. Mechanical fix (`File.exist?`), no test needed — but the count matters for scoping. | +| ➕A6 | **`config.serve_static_assets` in the test harness's own dummy app** | `test/dummy/config/environments/test.rb:11`, `test/dummy/config/environments/production.rb:20` | n/a (config) | `rails-42-patterns.yml` flags this as `kind: deprecation`; it is renamed to `config.public_file_server.enabled` at 5.0. Both sites are in **`test/dummy`** — the app the entire suite boots against. A config that stops being recognised takes the whole suite down, so this belongs in Phase 0 (harness) rather than in the application backlog. Trivial to fix; easy to overlook precisely because it is not application code. | +| ➕A7 | **No `ApplicationRecord`; 28 models inherit `ActiveRecord::Base` directly** | 28 files under `app/models/cms/`; no `app/models/application_record.rb` | Models group: 91.82% | `upgrade-4.2-to-5.0.md` §2 makes this a 🔴 HIGH item and `rails-50-patterns.yml` classifies it `kind: **migration**` — i.e. **fix-when-ready, not fix-before-bump.** Rails 5 does not require it; the generator convention does. For an isolated engine (`isolate_namespace Cms`) the correct target is a `Cms::ApplicationRecord`, not the app-level `ApplicationRecord` the guide describes. **Deliberately deferred.** Recorded here so it is a decision rather than an oversight, and flagged because `lib/browsercms.rb:36-67` does `ActiveRecord::Base.send(:include, ...)` at require time — introducing an intermediate base class interacts with that, and with Zeitwerk at 6.0. Revisit as part of the 6.0 autoloading work, not now. | + +### 0.3 The engine problem the skill's guide does not address + +The skill's 4.2 → 5.0 guide offers an escape hatch for `belongs_to` required-by-default: + +```ruby +# config/application.rb +config.active_record.belongs_to_required_by_default = false +``` + +**BrowserCMS cannot use it.** `load_defaults` and `belongs_to_required_by_default` appear **nowhere** in this repository (verified: zero occurrences outside `vendor/bundle`), because BrowserCMS is a mountable engine — `lib/cms/engine.rb:7` declares `isolate_namespace Cms`. The flag is owned by the **host application's** `config/application.rb`, and every downstream BrowserCMS project sets it independently. + +Three consequences that reorder the plan: + +1. **B5 is not deferrable.** The usual mitigation — bump the gem, leave `load_defaults` at 4.2, decide later — is unavailable to a library. A host app on `load_defaults 5.0` will exercise required-by-default against BrowserCMS's models the day it upgrades, whatever BrowserCMS's own dummy app is configured to do. +2. **All 29 declarations must be correct under *both* settings.** `optional: true` is explicit and behaves identically on Rails 4.2, so the entire audit is a **safe pre-emptive change to land now, on 4.2** — which also makes it reviewable in isolation instead of buried in the bump. +3. **The test must force the flag on.** A test written against the dummy app's defaults proves nothing. Set `belongs_to_required_by_default = true` in the test environment (or per-test) so the assertions actually bind. Without this, B5's "highest confidence-per-hour item in Tier B" quietly tests nothing. + +The same reasoning applies to every `load_defaults`-gated behaviour change across all nine hops. The skill's **Step 7** (delegate to the `rails-load-defaults` skill, walk the config changes one tier at a time *after* the version bump) assumes an application. For an engine, the honest position is that BrowserCMS must be correct across the *range* of host `load_defaults` values it claims to support — which is a decision to make and document, not a config line to flip. + +--- + +## 1. The finding that reorders everything + +**The test suite will not run under Rails 8.** Not "will fail" — will not boot. Before a single line of the upgrade can be validated, the harness needs rebuilding: + +| Dependency | Pinned | Problem | Call sites | +|---|---|---|---| +| `mocha` | 1.2.0 | `require 'mocha/setup'` (`test/test_helper.rb:13`) was removed in Mocha 2.0. `require "mocha/mini_test"` (`spec/minitest_helper.rb:8`) became `mocha/minitest`. | **109** (`.expects(` 88, `.stubs(` 15, `mock(` 6) | +| `factory_girl` / `factory_girl_rails` | 4.7.0 | Renamed to `factory_bot` in 2017; `factory_girl` is EOL and will not support Rails 8. | **38** `FactoryGirl` references + every `create(:x)` | +| `minitest/unit` | — | `require 'minitest/unit'` (`test/test_helper.rb:9`, `spec/minitest_helper.rb:7`) — a Minitest 4 compatibility shim. | 2 requires, suite-wide effect | +| `cucumber` / `cucumber-rails` | 2.4.0 / 1.4.5 | Rails 8 needs cucumber-rails 3.x. `features/cucumber.feature` *already documents* Rails-4 incompatibility warnings. | 17 features, ~4,860 LOC | +| `capybara` | 2.10.1 | Needs 3.x. Selector/matcher semantics changed in 3.0. | all feature step definitions | +| `database_cleaner` | 1.5.3 | Split into `database_cleaner-active_record` 2.x. | `test/support/database_helpers.rb` | +| controller test call style | — | `get :show, :id => 5` was removed in Rails 5.1 in favour of `get :show, params: {id: 5}`. | `content_block_controller_test.rb:48,63,68`, `links_controller_test.rb:38,41,49,52,60,63`, `pages_controller_test.rb:136,139`, `sections_controller_test.rb:92`, `file_blocks_controller_test.rb:22` | +| `assigns` / `assert_template` | — | Extracted to the `rails-controller-testing` gem in Rails 5.0; not in the `Gemfile`. | 4 test files | +| `simplecov` | 0.12.0 | 2016. No branch coverage, so every figure in the coverage plan is line-only. | — | +| `aruba` | 0.14.14 | Drives the `@cli` generator features. | 9 feature files | + +**Consequence for sequencing:** writing new tests against the current harness means writing them twice. **Phase 0 below is harness migration, and it is not optional.** The 9,807 lines of existing test code are the asset being protected here; porting them is cheaper than rewriting them, but it has to happen before new tests are worth writing. + +There is a silver lining: migrating the harness *is* a coverage-preserving refactor, and the existing 72.64% becomes the regression check on the migration itself. Port the harness, confirm coverage holds at 72.64% on Rails 4.2, *then* start the Rails upgrade. + +--- + +## 2. The prioritization principle: silence, not likelihood + +The instinct is to prioritize by "what will break." That produces the wrong order, because **loud breakage is self-reporting.** + +`before_filter` was removed in Rails 5.1. There are 37 calls across 17 files — 14 controllers plus `lib/cms/acts/content_page.rb`, `lib/cms/admin_tab.rb`, and `lib/cms/authentication/controller.rb`. On Rails 8 the application will not boot — `NoMethodError` at class definition time. That needs *zero* tests. It needs a grep and a sed. Writing a test to catch it is writing a test to tell you something the boot sequence already screams. + +What needs tests is code that **keeps running and quietly does something different**: a monkeypatch whose target method vanished (so the patch defines a method nobody calls), a validation that now fires where it didn't, an attribute read that silently returns `nil`. + +So the ordering below is: + +1. **Tier A — Harness migration.** Prerequisite for everything. +2. **Tier B — Silent behavior change.** Where tests are the *only* detector. Highest value per test. +3. **Tier C — Loud breakage.** Grep-and-fix. Tests are for regression-locking after the fix, not for finding it. + +Coverage-per-effort ordering from `TEST_COVERAGE_PLAN.md` §3 is a legitimate secondary lens — use it to sequence *within* a tier, and for the long tail after Tier B is done. + +--- + +## 3. Tier B — Silent behavior change (write these tests) + +Ranked by blast radius × silence × current test weakness. + +### B1. `schema_dumper.rb` — a monkeypatch on a module that was removed + +**File:** `lib/cms/extensions/active_record/connection_adapters/abstract/schema_dumper.rb` — **62.5% covered, 3 of 8 lines missed. Lowest coverage of any extension file.** + +```ruby +module ActiveRecord + module ConnectionAdapters + module ColumnDumper + def column_spec(column, types) +``` + +`ColumnDumper` was folded into `SchemaDumper` in the Rails 5.1–6.0 range, and `column_spec`'s signature changed (the `types` argument went away). 🔇 **Skill silent (C14) — the skill has no entry for `ColumnDumper`, `column_spec`, or schema-dumper internals at any version. The version claim remains unverified.** It does not need to be verified for this item to rank first, because the ranking rests on the *failure mode*, not the version — and the guard test below turns the unknown into a loud failure at whichever hop it actually lands. + +Why this is the top item: **when the target module no longer exists, `module ActiveRecord::ConnectionAdapters::ColumnDumper` does not fail. It silently defines a brand-new, empty module that nothing includes.** The patch evaporates. No error, no warning. + +And we know exactly what that failure looks like, because the file's own comment documents it happening already: + +> *"...any table with a boolean default (every content table has published/deleted/archived) blew up with "can't modify frozen String" and was skipped by the dumper, leaving `db/schema.rb` missing half its tables."* + +A silently-truncated `db/schema.rb` is close to the worst possible upgrade outcome: it corrupts every developer's database and every CI run downstream, and it looks like a working build. + +**Test to write:** dump the schema for a table with boolean defaults and assert the output contains the `published`/`deleted`/`archived` columns with their defaults. Assert on the *dumped output*, not on `column_spec`'s return value — the point is to detect the patch going missing, so the test must exercise the real dumper end to end. Add a guard test that fails loudly if `ActiveRecord::ConnectionAdapters::ColumnDumper` is not already defined when the file loads. + +### B2. `dynamic_attributes.rb` — manual alias_method chain on the attribute API + +**File:** `lib/cms/behaviors/dynamic_attributes.rb` — 91.47%, 11 missed. `nonversioned_class` (5 missed) is the largest gap. + +```ruby +# lines 186-195 +alias_method :method_missing_without_dynamic_attributes, :method_missing +alias_method :method_missing, :method_missing_with_dynamic_attributes +private +alias_method :read_attribute_without_dynamic_attributes, :read_attribute +alias_method :read_attribute, :read_attribute_with_dynamic_attributes +alias_method :write_attribute_without_dynamic_attributes, :write_attribute +alias_method :write_attribute, :write_attribute_with_dynamic_attributes +``` + +Rails 5 rewrote the attribute layer around an `Attribute` object model and routes internal reads through `_read_attribute`, not `read_attribute`. 🔇 **Skill silent (C15) — no entry for `read_attribute` / `_read_attribute` / the attribute-object rewrite in any version guide or pattern file. Unverified.** The item's rank stands on the alias-chain fragility and the 0.19x test-to-source ratio, both of which are measured facts about this codebase. The failure mode is partial and silent: attributes written through one path, read back through another, returning `nil` for values that are present in the database. + +The 91% line coverage is misleading here. High line coverage on a method-aliasing patch tells you the lines execute; it does not tell you the *chain is intact*. A test that reads a dynamic attribute it just wrote passes identically whether the patch is working or Rails is handling it natively. + +**Test to write:** assert the chain explicitly — that `read_attribute` and `_read_attribute` both resolve dynamic attributes, that `respond_to?` agrees with `method_missing`, and that a dynamic attribute survives `save` → `reload` → read. Then a round-trip through `assign_attributes` and through direct `[]=`. Also cover `nonversioned_class`, which currently has none. + +### B3. `schema_statements.rb` — the migration DSL every BrowserCMS project depends on + +**File:** `lib/cms/extensions/active_record/connection_adapters/abstract/schema_statements.rb` — 86.79%, 7 missed, 53 relevant lines. + +Reopens `ActiveRecord::ConnectionAdapters::SchemaStatements` to add `create_content_table`, `cms_`, and friends. Internally calls `create_table`, `change_table`, `column_exists?` — all of which have shifted signatures and keyword-argument requirements across six majors. + +Blast radius is the largest in the codebase: **every migration in BrowserCMS and in every downstream project that uses it.** Unlike B1, this one is likely to fail loudly (`ArgumentError` on `create_table table_name, options` once options become keyword arguments) — but it fails at *migration* time, which in practice means it fails in someone's deploy rather than in CI. + +**Test to write:** a migration test that calls `create_content_table` with each option combination (`versioned: true/false`, `name: true/false`) and asserts the resulting column set on both the content table and the `_versions` table. This is cheap and there is currently no test that runs the DSL. + +### B4. Paperclip 5.3.0 is end-of-life + +**Files:** `lib/cms/behaviors/attaching.rb` (82.67%, 26 missed), `app/models/cms/attachment.rb` (92.37%, 9 missed), `lib/cms/attachments/configuration.rb` + +Not a Rails API change — a dead dependency. Paperclip was deprecated in 2018 in favour of ActiveStorage and will not work on Rails 8. Either migrate to ActiveStorage (a large, semantics-changing project: `has_attached_file` → `has_one_attached`, different storage layout, different URL generation) or move to the `kt-paperclip` fork. + +✅ **Confirmed as a process gap (C17), not a knowledge gap.** The skill does not answer gem questions from a table — it has a dedicated step for them: **Step 4.5**, `workflows/gem-compatibility-workflow.md`, running `next_rails`'s `bundle_report compatibility` against `Gemfile.next` per lockfile, escalating to the railsbump API only under defined conditions, and bucketing every gem into *required bumps* / *blockers* / *already compatible*. **Stop hand-reasoning about gem EOL and run that check** — it covers Paperclip alongside `mocha (1.2.0)`, `factory_girl (4.7.0)`, `cucumber (2.4.0)`, `capybara (2.10.1)`, `database_cleaner (1.5.3)`, `aruba (0.14.14)`, `poltergeist (1.11.0)`, `compass-rails`, `jquery-rails (3.1)`, and `simplecov (0.12.0)` in one pass, with real resolver data instead of recollection. Step **4.6** (boot smoke test against `Gemfile.next`) then catches the gems that *resolve* cleanly but call removed Rails internals at runtime — the failure class that produced ➕A3. + +The validation-macro tests below are correct and necessary regardless of which replacement wins; they are the acceptance criteria for it. + +The untested parts are precisely the parts that differ between Paperclip and any replacement: + +| Method | Missed lines | +|---|---| +| `validates_attachment_size` | 9 | +| `validates_attachment_content_type` | 6 | +| `validates_attachment_presence` | 3 | +| `handle_setting_attachment_path` | 3 | + +**All three validation macros are untested.** ActiveStorage's validation story is completely different (it has none built in — you need `active_storage_validations` or custom validators). Without tests pinning current behavior, a migration will silently drop file-size and content-type enforcement — a security-relevant regression on a public upload path. + +⚠️ Note before starting: `validates_attachment_presence` is **defined twice**, at `attaching.rb:89` and `:98`. The first is dead code silently overwritten by the second. + +**Test to write:** for each macro, one test that a valid attachment passes and one that an invalid one produces a specific error on a specific attribute. Pin the current messages. These become the acceptance criteria for whatever replaces Paperclip. + +### B5. `belongs_to` required by default + +**Evidence:** 24 `belongs_to` declarations in `app/models`. **Zero** carry `optional: true` or `required: false`. ✅ *Both figures re-verified.* + +✏️ **Corrected scope (➕A1): the real count is 29, and the 5 additions matter more than the 24.** Five further live `belongs_to` calls sit inside the behaviors, where they are injected into every model that uses the mixin: + +| Site | Association | Coverage | +|---|---|---| +| `lib/cms/behaviors/userstamping.rb:16-17` | `created_by`, `updated_by` (`Cms::User`) | 100% | +| `lib/cms/behaviors/categorizing.rb:16` | `category` (`Cms::Category`) | 88.89% | +| `lib/cms/behaviors/versioning.rb:115` | version row → parent record | 96.91% | +| `lib/cms/behaviors/dynamic_attributes.rb:168` | `base_class` | 91.47% | + +(Grep also matches `belongs_to_category?` / `@belongs_to_category` in `categorizing.rb` and a `:belongs_to_attachment` option guard in `acts/content_block.rb` — those are method and option names, not associations. Excluded.) + +The 24 static declarations affect 24 models. **The 5 injected ones affect every model in BrowserCMS and in every downstream project that uses the behavior** — `userstamping` alone is included 199 times per the coverage run's hit count. + +✅ **Version confirmed (C9):** `upgrade-4.2-to-5.0.md` §3 lists this as 🔴 HIGH for the 5.0 hop; `rails-50-patterns.yml` → `BELONGS_TO_REQUIRED`, with the skill's own instruction to "review each match to determine if the association can be nil" and to "add `optional: true` **only** to associations that can legitimately be nil." + +Silent in the worst way: models that saved fine now fail validation, and because BrowserCMS swallows some save failures into `flash` (`Cms::Portlet#store_errors_in_flash`, 2 missed lines — untested), the failure may surface as a UI no-op rather than an exception. + +Genuinely optional associations here are likely to include `Cms::SectionNode#node` (polymorphic, `:24`), `Cms::Connector#connectable` (polymorphic, `:5`), `Cms::Attachment#attachable` (polymorphic, `:19`), `Cms::Tagging#taggable` (polymorphic, `:4`), **`Cms::Category#parent` (self-referential — every root category has a nil parent, so required-by-default breaks the category tree outright)**, `Cms::Task#assigned_by` / `#assigned_to`, and `userstamping`'s `created_by` / `updated_by`. + +**One piece of luck worth naming:** the only existing `userstamping` tests are the *nil-user* cases (the happy path is untested — see `TEST_COVERAGE_ANALYSIS.md` §3.5). Under required-by-default those tests go **red immediately**. That is the single place this flag fails loudly instead of silently, and it is an accident of the coverage gap rather than a designed safety net. + +**Test to write:** for each of the **29**, a test that either (a) asserts the association is required, or (b) asserts a record saves without it. **Critically — set `config.active_record.belongs_to_required_by_default = true` in the test environment first (see §0.3).** BrowserCMS is an engine with no `load_defaults` of its own, so tests written against the dummy app's inherited defaults will pass whether or not the declarations are correct, and this "highest confidence-per-hour item" will quietly assert nothing. Mechanical, fast, and it forces a decision on all 29 before any host app flips the flag. `optional: true` behaves identically on Rails 4.2, so **land the whole audit now, on 4.2, as a reviewable standalone change.** + +### B6. `versioning.rb` — documented dependence on ActiveRecord's `save` call chain + +**File:** `lib/cms/behaviors/versioning.rb` — 96.91%, 5 missed, 162 relevant lines. Well covered by line count. + +Lines 206–225 are a comment block enumerating the exact order in which `ActiveRecord::Base` composes `save`: + +> *"ActiveRecord 3 now uses basic inheritance rather than alias_method_chain. The order in which ActiveRecord::Base includes methods (at the bottom of activerecord) repeatedly overrides save/save! with chains of 'super' ... Callstack order as observed: AR::Transactions#save, AR::Dirty#save, AR::Validations#save, ActiveRecord::Persistence#save..."* + +Code that documents its dependence on an *observed* internal call order is code that will break when that order changes — and it has changed repeatedly since Rails 3. + +96.91% line coverage with 162 lines is good, so the risk is not "untested" but "tested for the wrong thing." Line coverage cannot tell you whether a test asserts that versioning happens *in the right order relative to validation and dirty-tracking*. + +**Action:** this is an audit before it is a test-writing task. Read `test/unit/behaviors/versioning_test.rb` and `publishing_mini_test.rb` and answer: does anything assert that a failed validation produces no new version? That `version_comment` reflects the changes from *this* save? That a rolled-back transaction leaves no orphan version row? Add whichever of those is missing. Budget a day for reading before writing. + +### B7. `publishing.rb` — hand-built SQL against connection internals + +**File:** `lib/cms/behaviors/publishing.rb` — 96.51%, 3 missed. + +```ruby +# :143-146 +self.class.connection.update( + "UPDATE #{self.class.quoted_table_name} SET published = #{self.class.connection.quote(true, self.class.columns_hash["published"])} ..." +) +``` + +`connection.quote(value, column)` — the two-argument form was deprecated and removed; `columns_hash` returns different objects than it did in 4.2. 🔇 **Skill silent (C16) — no entry for `connection.quote` arity or `columns_hash` return types. Unverified.** Likely loud (`ArgumentError`), but on a code path (`publish!` on a versioned record) that runs in production far more than in tests. The prescribed test asserts *behaviour* rather than the API shape, so it holds regardless of which version the arity change lands in. + +**Test to write:** assert that `publish!` actually flips `published` in the database for both versioned and non-versioned models — reading back through a fresh query, not through the in-memory object. + +### B8. `soft_deleting.rb` — `default_scope` plus a startup rescue + +**File:** `lib/cms/behaviors/soft_deleting.rb` — 92.86%, 3 missed. + +```ruby +default_scope {where(:deleted => false)} +rescue ... + handle_missing_table_error_during_startup("Can't set a default_scope for soft_deleting", e) +``` + +Two upgrade interactions: `default_scope` composition semantics have shifted (notably around `unscoped` and `or`), and the rescue exists to survive class loading before the table exists — which is exactly the behavior Zeitwerk's eager-loading changes. Under Rails 7+ eager loading, this rescue may swallow a real error at boot or fire in new situations. + +**Test to write:** assert `deleted` records are excluded by default, included under `unscoped`, and that the default scope composes correctly with `where` and `or`. + +### B9. `ActionController::Parameters` is not a Hash + +**10 sites** treating a params sub-hash as a Hash — 8 found by the original sweep, **2 more surfaced by running the skill's own `PARAMS_AS_HASH` pattern** (➕A2): + +``` +app/controllers/cms/pages_controller.rb:126-128 params[:page].delete :hidden / :archived / :visibility +app/controllers/cms/sections_controller.rb:43 params[:section].delete('group_ids') +app/controllers/cms/form_fields_controller.rb:16 params[:form_field].delete(:form_id) +app/controllers/cms/forms_controller.rb:33 params[:form].delete(:new_entry) +app/helpers/cms/path_helper.rb:34-35 filtered_params.delete(:action) / (:controller) +app/helpers/cms/path_helper.rb:33,36 params.clone → .merge!(:order => ...) → polymorphic_path ← NEW +app/controllers/cms/content_controller.rb:72 params.except(:controller, :action, :path) → url_for ← NEW +``` + +✅ **Version confirmed (C10)** — `upgrade-4.2-to-5.0.md` §5, 🔴 HIGH — **with an important asymmetry the original draft could not see.** The skill's pattern names `slice`, `except`, `merge`, `symbolize_keys`, `to_hash`. It does **not** name `.delete`, which is all 8 of the sweep's original sites. The two new sites are the ones that use the methods the skill actually flags: + +- `path_helper.rb:33` clones `params` (a `Parameters`, not a `Hash`), mutates it with `.delete` ×2 and `.merge!`, then hands it to `polymorphic_path` — passing unpermitted `Parameters` into a URL helper is exactly what Rails 5 stopped tolerating. +- `content_controller.rb:72` passes `params.except(...)` into `ActionDispatch::Http::URL.url_for(params:)`, which wants a Hash. This is inside `render_editing_frame` — the CMS edit-mode iframe, i.e. the admin's primary editing surface. + +**But both new sites are well covered** — `content_controller.rb:72` is hit **153×**, `path_helper.rb:33-36` **49×**. A `Parameters`-vs-`Hash` breakage there raises on a line CI executes, so **CI is already the detector and no new test is warranted.** + +**So this finding sharpens B9's targeting rather than raising its rank.** Write tests for the sites CI *cannot* see: + +`.delete` still exists on `Parameters`; what changed is that the object is no longer a `Hash`, so surrounding code that expected Hash semantics (`merge`, `each` yielding pairs, implicit `to_hash` coercion, permitted-state propagation) may behave differently. + +Coverage status makes this worse than it looks: + +- `pages_controller.rb#strip_visibility_params` — **3 of its lines are untested** (85.87% file) +- `sections_controller.rb:43` — inside the 9 missed lines +- `form_fields_controller.rb:16` — **0% file** +- `forms_controller.rb:33` — **0% file** +- `path_helper.rb:33-36` — inside `sortable_column_path` (`:32`); **these lines are covered (49 hits)**; the file's 14 missed lines are concentrated in `link_to_usages` (`:40`) and `engine` (`:65`) +- `content_controller.rb:72` — **covered (153 hits)**; file is 92.16% + +So four of the ten sites are in code with **no test at all**, and those four are the entire job here. These are cheap tests (assert the stripped key is absent from the resulting record) and they sit directly on a security boundary: `strip_visibility_params` and the `group_ids` deletion are *authorization* logic — they exist to stop a non-admin from setting fields they shouldn't. `form_fields_controller.rb` and `forms_controller.rb` are both **0%-coverage files**, which is why the Forms subsystem holds its rank in §6. + +### B10. Zeitwerk and `require_dependency` + +**File:** `app/controllers/cms/content_types_controller.rb:1` — `require_dependency "cms/application_controller"`. **The file is 0% covered.** + +✏️ **Corrected — one hop earlier than drafted (C8), which promotes this item.** The draft placed Zeitwerk at Rails 7. The skill places it at **6.0**: `references/breaking-changes-by-version.md` lists "Zeitwerk autoloader / impact: ALL apps / fix: Remove `require_dependency`, fix naming" under **5.2 → 6.0**, and `upgrade-5.2-to-6.0.md` §1 is unambiguous — "Remove **all** `require_dependency` calls." The classic autoloader's final removal at 7.0 is the *deadline*; 6.0 is where the work happens and where a red suite appears. + +For a mountable engine with `isolate_namespace Cms` and a `Cms::` namespace spread across `app/models/cms/`, `app/controllers/cms/`, and `lib/cms/`, Zeitwerk's stricter file-path-to-constant-name rules are a substantial migration in their own right — and `TEST_COVERAGE_ANALYSIS.md` §3.6 identifies `lib/cms/engine.rb` (135 LOC, a **7-line test with 1 assertion**) as the single biggest concentration of upgrade work in the codebase, pushing 6 directories onto `ActiveSupport::Dependencies.autoload_paths`, an API that does not exist under Zeitwerk. + +**Being two hops sooner than assumed is the most consequential correction in this reconciliation.** The eager-load test below is one hour of work and is the cheapest possible defence against the largest single item in the plan. It should be written in Phase 1, and its results will scope the 6.0 hop. + +Watch specifically: `lib/cms/behaviors.rb:32` and `lib/cms/concerns.rb:6` build class names from filenames with `File.basename(b, ".rb").camelize` and `constantize` them at load time, and `lib/browsercms.rb:36-67` does `ActiveRecord::Base.send(:include, ...)` at require time. Runtime `constantize` against eager-loaded constants plus monkeypatching `ActiveRecord::Base` during initialization is the classic Zeitwerk failure pattern. + +**Test to write:** a boot/eager-load test — `Rails.application.eager_load!` and assert every expected `Cms::` constant resolves. One test, catches an entire class of autoload regressions. Do this early; it is the cheapest high-signal test available. + +--- + +## 4. Tier C — Loud breakage (grep and fix; test only to lock the fix) + +Ordered by how much code sits behind them. **None of these need a test to be *found*.** Fix them, then add a test only where the fix has a behavioral choice in it. + +**Removal versions are now skill-sourced.** ✅ = confirmed against the skill · ✏️ = corrected · 🔇 = skill has no entry (unverified). + +| API | Removed | Sites | Notes | +|---|---|---|---| +| `respond_with` + class-level `respond_to` | **already breaking at 4.2** ✏️ | 6 | ✏️ **Promoted out of Tier C into fix-before-bump.** `rails-42-patterns.yml` classifies both as `kind: **breaking**` on the *current* version, not a future one. Works today only because `devise` pulls `responders 2.4.1` in transitively — `Gemfile.lock:142` confirms it arrives as a Devise dependency and **nothing in `browsercms.gemspec` declares it.** Declare it explicitly now: one line, zero risk, and it defuses the landmine *before* the Devise upgrade can move it. `content_controller.rb:79` is the main page-serving path (202 hits — CI will catch a regression). | +| `HTML::FullSanitizer` | **breaks at 5.0** ➕ | 1 + 1 test | ➕ **New (A3) — missed entirely by the original sweep.** `content_filter.rb:12`. `rails-42-patterns.yml` flags sanitizer usage as `kind: breaking`. The constant comes from `rails-deprecated_sanitizer (1.0.4)`, in the bundle only because `rails-dom-testing (1.0.9)` requires it — and that gem is capped at `activesupport < 5.0`. **Resolving Rails 5 removes the gem and `content_filter.rb:12` raises `NameError`.** File is 100% covered so it fails loudly, but a code grep cannot see it — this is the failure class the skill's Step 4.6 boot smoke test exists for. Also: `test/functional/cms/inline_controller_test.rb:7` asserts on the doomed gem directly. | +| `before_filter` / `after_filter` / `around_filter` / `skip_before_filter` | 5.1 ✅ | **37 across 17 files** | ✅ `rails-51-patterns.yml` → `FILTER_METHODS`, `kind: breaking`. Mechanical rename to `_action`. Boot-time failure. Safe to do now — `_action` works on 4.2. | +| `render :text =>` / `render text:` | 5.1 ✅ | 4 | ✅ `upgrade-5.0-to-5.1.md` §2 + `rails-51-patterns.yml` → `RENDER_TEXT`. → `render plain:` (skill: `render html:` if HTML was intended). **Both production sites are on untested error branches:** `content_block_controller.rb:138` ("Not Implemented") and `form_fields_controller.rb:43` ("Fail", 500). Worth a test *because* they're error paths nothing exercises. | +| `Relation#uniq` | 5.1 ✅ | 1 | ✅ `rails-51-patterns.yml` → `RELATION_UNIQ`. `section_nodes_controller.rb:75` → `.distinct`. Inside `nodes_to_update_on_success` (`:73`), which has **2 untested lines**, in a file at 41.18% whose `move_to_position` has 9 more. | +| `.deliver` / `.deliver!` | 5.0 ✅ (deprecated **at 4.2**) | **2** ➕ | ✅ `rails-42-patterns.yml`, `kind: deprecation` — **already warning on the current version, so fixable today.** ➕ **Undercounted (A4):** `email_message.rb:58` `.deliver` (14 hits, covered) **and `email_message.rb:18` `m.deliver!` — 0 hits, uncovered.** The bang form needs `deliver_now!`, not `deliver_now`. (`:15` `def self.deliver!` is a definition, not a call.) On the form-notification path (`form_entries_controller#submit`, **0% covered**). | +| `config.serve_static_assets` | 5.0 ➕ | 2 | ➕ **New (A6).** `test/dummy/config/environments/test.rb:11` and `production.rb:20` → `config.public_file_server.enabled`. `rails-42-patterns.yml`, `kind: deprecation`. **These are in the dummy app the entire suite boots against — this is a Phase 0 harness item, not application backlog.** Easy to miss because it isn't application code. | +| `update_attributes` / `update_attributes!` | **6.0** ✏️ (was drafted 7.0) | **16** | ✏️ **Corrected — a full hop earlier (C7).** `upgrade-5.2-to-6.0.md` §3 and `references/breaking-changes-by-version.md` both place this at 5.2 → 6.0. → `update` / `update!`, which work identically on 4.2, so **make this change now.** Note `guest_user.rb:48` *defines* `update_attributes` — check callers before renaming. | +| `require_dependency` (Zeitwerk) | **6.0** ✏️ (was drafted 7.0) | 1 | ✏️ **Corrected (C8).** `content_types_controller.rb:1`; file is **0% covered**. See B10 — this is the tip of the largest item in the plan, and it arrives two hops sooner than the draft assumed. | +| bare `HashWithIndifferentAccess` | 🔇 unverified (drafted 5.2) | 2 | 🔇 **Skill silent (C5).** `page_component.rb:10` (3 hits, covered) and `portlet.rb:228` (**0 hits, uncovered**) → `ActiveSupport::HashWithIndifferentAccess`. Fix is free and version-agnostic; qualify both now and stop tracking the removal version. | +| unversioned `ActiveRecord::Migration` | 5.0 🔇 | 2 | 🔇 Skill has no pattern for migration versioning, though `upgrade-4.2-to-5.0.md` implies it. `db/migrate/20130327184912_browsercms400.rb`, `20080815014337_browsercms300.rb` → `[4.2]`. Low risk, do it at the 5.0 bump. | +| `File.exists?` | Ruby (deprecated) | **5** ➕ | ➕ **Undercounted 5× (A5).** `list_portlet.rb:22` (**0 hits, uncovered**), `lib/cms/caching.rb:42` (24 hits), `lib/cms/attachments/attachment_serving.rb:44` (9 hits), `lib/tasks/core_tasks.rake:51`, `content_block_generator.rb:26` (the only one originally found). Two are **live request-path** code, not generator code. → `File.exist?`. Mechanical. | +| `match "*path", via: [...]` | — | 1 | `route_extensions.rb:62` — the CMS catch-all route. Still valid syntax; verify glob + format behavior. 98.57% covered. | +| `jquery_ujs` | — | 2 | `application.js:5`, `page_editor.js:2` → `rails-ujs`/Turbo. `jquery-rails 3.1.5` is far behind. Route through the skill's Step 4.5 gem check. | +| `compass-rails` + `sass-rails` | — | gemspec:35,37 | **Compass is EOL (2018).** Rails 8 defaults to Propshaft (`references/breaking-changes-by-version.md`, 7.2 → 8.0). `app/assets/stylesheets/cms/*.scss` needs a new pipeline. Not unit-testable — needs the feature suite. Also see the skill's `references/js-compressor-sprockets-mismatch.md` if the JS compressor breaks en route. | + +**Sites that can be fixed today, on Rails 4.2, with no dual-boot conditional** — the new API works on both sides, so these are backwards-compatible changes that can be reviewed and deployed independently of any bump (the skill's Step 6 principle, and its "deploy small changes to production before the version bump" methodology): + +`before_filter` → `before_action` (37) · `update_attributes` → `update` (16) · `.deliver`/`.deliver!` → `deliver_now`/`deliver_now!` (2) · `File.exists?` → `File.exist?` (5) · bare → `ActiveSupport::HashWithIndifferentAccess` (2) · `belongs_to ... optional: true` (29, §B5) · declare `responders` in the gemspec (1 line) · `render text:` → `render plain:` (4). + +**That is ~96 mechanical changes that reduce the 5.0/5.1/6.0 hops without a single version bump, and every one of them is independently reviewable.** Doing them first shrinks the diff that the actual bump has to be debugged against — which is the whole point of the pre-bump phase. + +--- + +## 5. Checked and clean — do *not* spend time here + +Negative findings, recorded so nobody re-investigates: + +- **`return false` to halt callbacks.** Rails 5 changed halting to `throw(:abort)`. All **11** real `return false` sites in `app/` + `lib/` were inspected individually; **none is in a callback body**: + - predicates — `first?`/`last?` (`acts_as_list.rb:159,165`), `live?` (`publishing.rb:180`), `visible?`/`deletable?` (`section_node.rb:63-66,89`) + - helpers — `deliver!` (`email_message.rb:56`), `database_exists?` (`extensions/active_record/base.rb:26`) + - a guard clause in a yielding method — `exec_if_related` (`dynamic_attributes.rb:354`) + + The two `before_*` callbacks in `acts_as_list.rb` are `remove_from_list_without_saving` → `remove_from_list(false)`, which returns `nil` because `update_attribute(...) if save` is skipped, and `add_to_list_bottom`, which ends in an assignment. `nil` never halted the chain, so neither is affected. **No action needed.** (Two further grep hits are not Ruby statements at all: `task.rb:58` is a comment, and `application_helper.rb:37` is `return false` inside a JavaScript `onchange` string.) +- **`alias_method_chain`** (removed 5.1) appears only inside a comment at `versioning.rb:209`. No live usage. +- **`attr_accessible`** — all 10 occurrences are commented out. `protected_attributes` is not in the Gemfile. +- **`attribute_changed?` / `_was` inside `after_save`** (semantics changed in 5.1/5.2) — only in comments at `attaching.rb:213` and `taggable.rb:47`. +- **`Fixnum` / `Bignum`**, **`serialize`**, **`dependent: :restrict`** — zero occurrences. +- **`find_by_` dynamic finders** — `find_by_login`, `find_by_path`, `find_by_code`, `find_by_from_path` are present, but `find_by_` is **still supported**; only `find_all_by_*`, `find_last_by_*`, and `scoped_by_*` were removed (4.1). Most hits are locally-defined `def self.find_by_key` methods anyway. **Not a risk.** ✅ **CONFIRMED against the skill (C11).** `upgrade-4.0-to-4.1.md` §1 gives the removal list as exactly `find_all_by_*`, `find_last_by_*`, `scoped_by_*`, `find_or_initialize_by_*`, `find_or_create_by_*` — when `activerecord-deprecated_finders` stopped being a bundled dependency at 4.1 — and `find_by_` appears nowhere in it. `rails-40-patterns.yml` independently confirms the 4.1 boundary. **Do not touch the ~13 call sites.** This was flagged as the claim most worth checking; it held, and the waste is avoided. + +### Additionally verified clean during reconciliation + +Ten more skill patterns were run against the codebase and returned **zero** hits. Recorded so the next person does not re-run them: + +| Skill pattern | Version | Result | +|---|---|---| +| `redirect_to :back` (`REDIRECT_TO_BACK_DEPRECATED` / `REDIRECT_TO_BACK`) | deprecated 5.0, removed 5.1 | **0 sites.** The skill flags this as 🔴 HIGH for the very first hop, so it was the most likely miss. Clean. | +| `render nothing: true` (`RENDER_NOTHING`) | removed 5.1 | 0 sites | +| `use_transactional_fixtures` (`USE_TRANSACTIONAL_FIXTURES`) | removed 5.1 | 0 sites in `test/` or `spec/` | +| `class_name:` with an unquoted constant (`CLASS_NAME_CONSTANT`) | **raises `ArgumentError` at 5.2** | **0 sites in browsercms.** Worth noting: `TEST_COVERAGE_ANALYSIS.md` §5.3 finds **11 such sites in `cms`** and describes them as breaking "under modern Rails autoloading." The skill pins it precisely — `rails-52-patterns.yml` says Rails 5.2 raises `ArgumentError: A class was passed to :class_name but we are expecting a string` at model load time, finishing a deprecation begun in 5.1. So cms's item 17 is a **hard 5.2 boot failure**, not a vague future autoloading concern. Fix is free (quote the constant) and works on 4.2. | +| `ActiveSupport.halt_callback_chains_on_return_false` | `NoMethodError` at boot, 5.2 | 0 sites (also `error_on_ignored_order_or_limit`: 0) | +| String `if:` / `unless:` conditions on filters and model callbacks | 5.1 / 5.2 | 0 sites | +| `assert_tag` / `TagAssertions` | deprecated 4.2 | 0 sites | +| `serialize :attr, SomeCoder` | breaking 4.2 | 0 sites | +| `form_authenticity_token` in non-Rails forms | breaking 4.2 | 0 sites in `app/` or `lib/` | +| `Timecop` | 4.2 | not in the bundle | +| `ActionDispatch::Http::UploadedFile` in tests (`UPLOAD_FILE_TEST`) | 5.0 → `Rack::Test::UploadedFile` | **0 sites in test code.** Matches appear only in `test/dummy/log/test.log`. Uploads go through a custom `Cms::MockFile` shim (`test/support/factory_helpers.rb:68-82`), used by ~15 tests. The shim insulates the suite from this change — but it is Paperclip-shaped, so it becomes part of the B4 attachment decision rather than a Rails-5 fix. | +| dynamic `:controller/:action` route segments | deprecated 5.2 | 0 sites | +| `protected_attributes` gem / live `attr_accessible` | breaking 5.0 | 0 sites (all `attr_accessible` occurrences already commented out — reconfirms the original finding) | +| Ruby version floor | 2.2.2+ at 5.0 | **Ruby 2.7.8** — clears 5.0 (2.2.2+), 6.0 (2.5+), and 7.0 (2.7+). Becomes a **hard gate at 7.2, which needs 3.1+.** Plan the Ruby upgrade into the 7.1 → 7.2 hop, not earlier. | + +--- + +## 6. Recommended sequence + +**Reconciled against the skill's mandated workflow.** The original phases were sound but omitted four of the skill's required steps. They are inserted below, marked ★. + +**★ Step 0 — Verify latest patch. ✅ ALREADY SATISFIED.** The skill makes this a mandatory pre-step before any hop. `Gemfile.lock` resolves **rails 4.2.11.3**, the final 4.2 patch. No action. (The draft's "Rails 4.2.0" was the gemspec constraint, not the resolved version.) + +**★ Step 1 — Establish that the suite passes, and get CI. BLOCKING.** The skill's Step 1 is explicit: *if any tests fail, STOP; do not proceed until all tests pass.* Two things must be true before anything else happens: +- The suite is green. `git log` shows `[CMS-420] tests are running` — "running" is not "passing." Confirm the actual pass/fail count and record it as the baseline alongside the 72.64%. +- **There is a working CI.** The only CI config is a dead `.travis.yml`; there is no `.github/`. The skill treats CI as non-negotiable (`workflows/ci-sync-workflow.md` is mandatory *before every upgrade PR*), and `TEST_COVERAGE_ANALYSIS.md` ranks this item 2. **You cannot run a nine-hop upgrade without a green button.** Port to GitHub Actions (Ruby 2.7.8, Postgres) now. + +**Phase 0 — Harness migration (Rails 4.2, no upgrade yet).** factory_girl → factory_bot, mocha → 2.x, drop `minitest/unit`, cucumber/capybara → current, add `rails-controller-testing` (✅ the skill confirms this is the fix for the `assigns` / `assert_template` extraction at 5.0), convert the positional controller-test calls, bump simplecov and enable branch coverage. **Add ➕A6: `config.serve_static_assets` → `config.public_file_server.enabled` in `test/dummy/config/environments/{test,production}.rb`** — the suite's own dummy app, easy to overlook. Also delete `$VERBOSE = nil` from `test/test_helper.rb`: the skill's methodology treats deprecation warnings as the upgrade roadmap (`references/deprecation-warnings.md`, and `RUBYOPT="-W:deprecated"` in `references/testing-checklist.md`), and that line hides them. +**Acceptance: coverage still reads 72.64% on Rails 4.2.** Anything less means the port lost tests. + +**★ Step 2 — Set up dual-boot with `next_rails`. Do this BEFORE any bump.** Entirely absent from the original plan. There is no `Gemfile.next` and no `next_rails` in the bundle. The skill puts this at Step 2, immediately after tests pass, and delegates to the `dual-boot` skill for `next_rails --init`. It also mandates a code pattern: **when a fix genuinely cannot work on both versions, branch on `NextRails.next?` — never on `respond_to?` or other feature detection.** Most fixes here need no branch at all (see the ~96 backwards-compatible changes listed at the end of §4). + +**Phase 1 — Backwards-compatible fixes and cheap high-signal tests, still on 4.2.** Reordered by the reconciliation: +1. **B10 eager-load test first** — one test, one hour. ✏️ Promoted: Zeitwerk lands at **6.0**, not 7.0, so this defends the plan's largest item two hops sooner than assumed. +2. **B5 `belongs_to` audit — now 29 declarations, not 24** (➕A1), with `belongs_to_required_by_default = true` forced in the test env (§0.3). Not deferrable: BrowserCMS is an engine and the host app owns the flag. +3. **B3** (`create_content_table` DSL) and **B1** (schema dump, including the `ColumnDumper`-is-defined guard test — the right response to an unverified claim, C14). +4. **The ~96 mechanical backwards-compatible changes** from the end of §4. All work on 4.2; land them as reviewable standalone commits, and per the skill's methodology, deploy them before the bump. + +**★ Step 4.5 / 4.6 — Gem compatibility and boot smoke test.** Also absent from the original plan, and it is where ➕A3 came from. Run `bundle_report compatibility` against `Gemfile.next` per the skill's `workflows/gem-compatibility-workflow.md` to bucket every gem (Paperclip, mocha, factory_girl, cucumber, capybara, database_cleaner, aruba, poltergeist, compass-rails, jquery-rails, simplecov) into required-bumps / blockers / already-compatible with real resolver data. Then run a Rails-loading command against `Gemfile.next` (`BUNDLE_GEMFILE=Gemfile.next bundle exec rspec --dry-run`) to catch gems that resolve fine but call removed internals at runtime. **`HTML::FullSanitizer` (➕A3) is exactly that failure class** — invisible to a code grep, invisible to the resolver, fatal at boot. **Run this before finalising Phase 0's scope**, because its output determines how much of the harness migration is actually forced. + +**Phase 2 — Remaining Tier C fixes.** Whatever the pre-emptive pass in Phase 1 could not do on 4.2. Add tests only for the `render :text` error branches and `Relation#uniq` in `move_to_position`, since those are untested code paths regardless. + +**Phase 3 — The hard silent ones.** B2 (attribute chain — 🔇 version unverified, but the test is version-agnostic), B6 (versioning audit), B4 (attachment decision + the three untested validation macros), **B9 narrowed to the four uncovered `.delete` sites** (➕A2: the two new `.except` / `.clone` sites are covered, so CI is already the detector — do not spend test budget there). Each needs real thought; none should be rushed. + +**Phase 4 — Begin the version-by-version upgrade.** 4.2 → 5.0 → 5.1 → 5.2 → 6.0 → 6.1 → 7.0 → 7.1 → 7.2 → 8.0. The skill is emphatic that **version skipping is not allowed**, and adds two rules the draft omitted: +- **★ Re-run the skill's detection patterns at each hop.** This document's sweep is reconciled for **4.2 → 5.0 only**. Do not treat its 6.x/7.x/8.x rows as a detection pass. +- **★ Re-check Step 0 (latest patch) after every hop**, and run `workflows/ci-sync-workflow.md` before every upgrade PR — stale CI is the skill's stated most common cause of red builds on upgrade branches. +- **★ Step 7 — align `load_defaults` *after* each version bump**, as a separate change, delegating to the `rails-load-defaults` skill. But see §0.3: for an engine this is a support-range decision, not a config line. +- Ruby 2.7.8 carries through 7.1; **3.1+ is a hard gate at 7.2.** Plan that Ruby bump into the 7.1 → 7.2 hop. + +**Phase 5 — Coverage-per-effort backlog** from `TEST_COVERAGE_PLAN.md` §3, as regression pressure demands. + +--- + +## 7. Is the coverage adequate? + +The question this document exists to answer. Measured against the skill's `references/testing-checklist.md` categories: + +| Checklist area | Current state | Verdict | +|---|---|---| +| Unit / model tests | 91.82%, 47 files | ✅ **Adequate.** Also the layer Rails 5→8 changes least. | +| Library / concern tests | 73.04%, 91 files | ⚠️ **Adequate in aggregate, wrong in distribution.** The monkeypatches and behaviors carrying the upgrade risk are the thin ones (`dynamic_attributes` 0.19x test-to-source, `schema_dumper` 62.5%). Tier B targets exactly these. | +| Controller tests | **57.71%**, 612 missed lines | ❌ **Not adequate.** This is where params, `render :text`, and `*_filter` changes land. Four 0% files (`form_entries` 140 lines, `form_fields` 74, `forms` 35, `content_types` 18) hold two Tier-C breakages and three of B9's four dangerous sites. | +| Integration / multi-step workflows | none in this repo | ❌ **Absent.** No test chains create → edit → publish → connect → render → version → revert. See `TEST_COVERAGE_ANALYSIS.md` §Phase 3. | +| System / feature (JS, forms, uploads, navigation) | 53 Cucumber features, 4,860 LOC | ⚠️ **Exists but brittle and unmeasured.** On Poltergeist/PhantomJS (abandoned 2018), `aruba` hard-pinned, `@cli` features excluded from the default task. **The true pass rate is still unknown** — establish it before relying on it. | +| Auth / authorization | permission join-models untested; `persistent_user.rb` (209 LOC) has no test | ❌ **Not adequate**, and the worst possible regression (permissions failing *open*). B9's `strip_visibility_params` and `group_ids` deletion are authorization logic on untested lines. | +| Email | `email_message.rb` 93.55% — but the `.deliver!` call at `:18` is **uncovered** (➕A4) | ⚠️ One concrete hole, now identified. | +| Assets / asset compilation | not unit-testable; Sprockets 3 + EOL Compass | ❌ Needs the feature suite, which needs Phase 0 first. Deferred to the 7.2 → 8.0 Propshaft hop. | +| Background jobs / ActionCable / API | not applicable | — | +| Boot / eager-load | `engine.rb`: 135 LOC, **7-line test, 1 assertion** | ❌ **The single largest gap relative to risk.** B10 addresses it for one hour of work. | + +**Answer: not yet — but the gap is specific, bounded, and now fully enumerated.** Three things are true: + +1. **The 72.64% aggregate is adequate; its distribution is not.** Coverage is inversely correlated with upgrade risk — 92% on models that barely change, 58% on controllers where the breakage lives. +2. **Tier B plus Phase 0 closes the gap that matters.** Every item is named, located to a line, and priced. Nothing in the reconciliation added a new category of risk — it corrected versions, expanded three counts, and found one gem-topology landmine. +3. **The largest remaining unknown is not coverage at all.** It is whether the suite currently passes and whether the 53 Cucumber features are green. Both are unmeasured, both are answerable in days, and the skill makes both blocking. **Answer those before committing to any of the estimates in this document.** + +The reconciliation's net effect on the plan: **one item promoted two hops earlier (B10/Zeitwerk), one broadened by 5 injected declarations (B5), one narrowed to 4 sites from 10 (B9), one new fix-before-bump landmine (`HTML::FullSanitizer`), three undercounts corrected (`.deliver!`, `File.exists?`, `serve_static_assets`), one claim vindicated (`find_by_*` — 13 call sites saved from pointless churn), and four missing workflow steps restored (patch check, dual-boot, gem compatibility, boot smoke test).** Four version claims remain unverified and are labelled as such; in every case the prescribed test is version-agnostic, so none of them blocks starting. + +## 8. Where this diverges from the coverage-per-effort plan + +Items promoted by risk that the coverage plan ranked low or not at all: + +| Item | Coverage-plan rank | Why promoted | +|---|---|---| +| `schema_dumper.rb` (B1) | unranked — only 3 missed lines | Silent monkeypatch loss corrupts `db/schema.rb` for everyone downstream | +| `belongs_to` audit (B5) | unranked — not a coverage gap | **29** silent validation flips (24 static + 5 injected into every model via the behaviors), and as an engine BrowserCMS cannot opt out — the host app owns the flag | +| Eager-load test (B10) | unranked | One test covers an entire failure class — and Zeitwerk arrives at **6.0**, not 7.0 | +| `create_content_table` (B3) | unranked — 7 missed lines | Every migration in every downstream project | +| `versioning.rb` audit (B6) | unranked — 96.91% covered | High coverage hides untested *ordering* assumptions | +| Paperclip validations (B4) | Phase 6, rank 44 | EOL dependency on a public upload path | +| `HTML::FullSanitizer` (➕A3) | unranked — file is **100% covered** | A gem-topology break no coverage metric can surface: the constant's supplying gem leaves the bundle when Rails 5 resolves | +| `config.serve_static_assets` (➕A6) | unranked — config, not code | Renamed at 5.0, and it is in the **dummy app the whole suite boots against** | + +Items demoted, despite being large coverage gaps: **`demo.seeds.rb`** (249 missed — a generator template, filter it), the **generators** (198 missed — out-of-process, unmeasurable), and the **Devise shim helpers** (50 missed — may not survive the Devise upgrade at all, so testing them now is likely wasted). + +Item demoted *by the reconciliation*: **the two new B9 sites** (`content_controller.rb:72`, `path_helper.rb:33-36`). They are real `Parameters`-as-Hash hazards, but at 153 and 49 hits they are on lines CI executes — so CI is the detector and test budget belongs elsewhere. Worth stating explicitly, because the reflex on finding a new breakage site is to write a test for it. + +The **Forms subsystem** (249 missed lines, `TEST_COVERAGE_PLAN.md` Phase 2) holds its high rank under both lenses — it is both the largest genuine coverage gap and the location of two Tier-C breakages (`params[:form_field].delete`, `render text:`) plus a `.deliver` call. It stays near the front. + +--- + +## Appendix — Reconciliation method and limitations + +- **Authority:** the `rails-upgrade` skill (FastRuby.io methodology, "The Complete Guide to Upgrade Rails"), read at `ombulabs-ai/rails-upgrade/3.3.0/rails-upgrade/`. Sources cited per claim in §0.1. +- **Detection scope:** the skill's `rails-42` and `rails-50` pattern sets were run in full against `app/` and `lib/` (plus `test/`, `spec/`, `config/`, `db/`, `Gemfile*` where a pattern's `search_paths` specified them). Selected `rails-51`, `rails-52`, and `rails-60` patterns were run where the original draft made a claim about those versions. **The 6.x / 7.x / 8.x hops have not had a detection pass** — re-run the skill's patterns at each hop. +- **Coverage figures** are read from `coverage/index.html` (the run at `d0d108cc`), with per-line hit counts from `coverage/.resultset.json`. **A trap worth recording:** the `.resultset.json` RSpec suite entry reports every file as fully relevant / zero covered for files that suite never loaded. Naively merging the four suites' arrays therefore *inflates* missed-line counts — `path_helper.rb` reads 26% merged that way versus its true 68.18%. Use `index.html` for file totals; use `.resultset.json` only for per-line hits, and skip any suite where `relevant == total` for the file in question. +- **Every file:line reference in this document was re-verified by grep during reconciliation.** Counts corrected: `.deliver` 1 → 2, `File.exists?` 1 → 5, `belongs_to` 24 → 29, B9 sites 8 → 10. +- **Four claims remain unverified** (C5, C14, C15, C16 — bare `HashWithIndifferentAccess` removal version, `ColumnDumper`/`column_spec`, the `_read_attribute` attribute-layer rewrite, two-arg `connection.quote`). The skill has no entry for any of them. They are labelled 🔇 throughout rather than silently promoted to fact. In each case the prescribed test asserts behaviour rather than an API version, so **none of them blocks starting**, and each will fail loudly at whichever hop actually breaks it. +- **Effort estimates were not revisited** and remain the original draft's. diff --git a/Rakefile b/Rakefile index ec7d5ceba..eec1b52ac 100644 --- a/Rakefile +++ b/Rakefile @@ -21,7 +21,17 @@ Bundler::GemHelper.install_tasks require 'rake/testtask' require 'single_test/tasks' -Rake::TestTask.new('units') do |t| +# Each suite runs in its own process and merges into coverage/.resultset.json +# under a name SimpleCov otherwise *guesses*. Two suites that guess alike +# overwrite each other, so name them explicitly. Returns a task name suitable +# for use as a prerequisite; the test subprocess inherits the environment. +def coverage_suite(name) + suite_task = "coverage:suite:#{name.downcase.tr(' ', '_')}" + task(suite_task) { ENV['COVERAGE_SUITE'] = name } + suite_task +end + +Rake::TestTask.new('units' => coverage_suite('Unit Tests')) do |t| t.libs << 'lib' t.libs << 'test' t.pattern = 'test/unit/**/*_test.rb' @@ -29,14 +39,14 @@ Rake::TestTask.new('units') do |t| t.warning = false end -Rake::TestTask.new('spec') do |t| +Rake::TestTask.new('spec' => coverage_suite('RSpec')) do |t| t.libs << 'lib' t.libs << 'spec' t.pattern = 'spec/**/*_spec.rb' t.warning = false end -Rake::TestTask.new('test:functionals' => ['project:ensure_db_exists', 'app:test:prepare']) do |t| +Rake::TestTask.new('test:functionals' => ['project:ensure_db_exists', 'app:test:prepare', coverage_suite('Functional Tests')]) do |t| t.libs << 'lib' t.libs << 'test' t.pattern = 'test/functional/**/*_test.rb' @@ -44,18 +54,38 @@ Rake::TestTask.new('test:functionals' => ['project:ensure_db_exists', 'app:test: t.warning = false end +# test/*_test.rb, test/helpers/** and the dummy app's own tests under +# test/dummy/test/** are matched by none of the patterns above, so until this +# task existed they were in the repo but never run. `rake app:test` looks like +# the answer and is not -- it exits 0 having run nothing, because Rails 4.2 +# hands it the literal top-level task name ("app:test") and matches no sub-task. +Rake::TestTask.new('test:orphans' => ['project:ensure_db_exists', 'app:test:prepare', coverage_suite('Orphan Tests')]) do |t| + t.libs << 'lib' + t.libs << 'test' # so the dummy app's `require "test_helper"` finds the engine's + t.test_files = FileList[ + 'test/*_test.rb', + 'test/helpers/**/*_test.rb', + 'test/dummy/test/**/*_test.rb' + ] + t.verbose = false + t.warning = false +end + require 'cucumber' require 'cucumber/rake/task' -Cucumber::Rake::Task.new(:features, "Run all (fast) scenarios without known bugs or missing features") do |t| +Cucumber::Rake::Task.new({:features => coverage_suite('Cucumber Features')}, "Run all (fast) scenarios without known bugs or missing features") do |t| t.cucumber_opts = "launch_on_failure=false features --format progress --tags ~@cli -t ~@missing-feature -t ~@known-bug" end -Cucumber::Rake::Task.new('features:all', 'Runs all scenarios (including slow/missing/etc') do |t| +Cucumber::Rake::Task.new({'features:all' => coverage_suite('Cucumber Features (all)')}, 'Runs all scenarios (including slow/missing/etc') do |t| t.cucumber_opts = "launch_on_failure=false features --format progress" end -Cucumber::Rake::Task.new('features:cli' => ['project:ensure_db_exists', 'app:test:prepare']) do |t| +# @cli scenarios shell out through aruba, so their coverage is collected in a +# child process SimpleCov cannot see. Named anyway, so the entry is distinct +# rather than overwriting the in-process cucumber result with an empty one. +Cucumber::Rake::Task.new('features:cli' => ['project:ensure_db_exists', 'app:test:prepare', coverage_suite('Cucumber CLI Features')]) do |t| t.cucumber_opts = "features --format progress --tags @cli" end @@ -83,7 +113,7 @@ end desc 'Runs all the tests, specs and scenarios.' task :test => ['project:ensure_db_exists', 'app:test:prepare'] do - tests_to_run = %w(test:units spec test:functionals features) + tests_to_run = %w(test:units spec test:functionals test:orphans features) run_tests(tests_to_run) end @@ -98,11 +128,21 @@ def run_tests(tests_to_run) end.compact if errors.any? + errors.each { |e| $stderr.puts "FAILED: #{e[:task]} -- #{e[:exception].message}" } + raise "Test failures in: #{errors.collect { |e| e[:task] }.join(', ')}" end end # Build and run against Postgres. task 'ci:test' => ['db:drop', 'db:create:all', 'db:install', 'test'] + +# Checked once, after the chain, rather than through SimpleCov's own +# minimum_coverage -- that is enforced in every test process's at_exit, so the +# first suite would fail the build for not meeting the whole chain's threshold +# on its own. `test` raises on failure, so a red suite short-circuits this, +# which is the right order: coverage from a failing run means nothing. +Rake::Task['ci:test'].enhance { Rake::Task['coverage:check'].invoke } + task :default => 'ci:test' require 'yard' diff --git a/TEST_COVERAGE_ANALYSIS.md b/TEST_COVERAGE_ANALYSIS.md new file mode 100644 index 000000000..1f976d96d --- /dev/null +++ b/TEST_COVERAGE_ANALYSIS.md @@ -0,0 +1,392 @@ +# Test Coverage Analysis — browsercms & cms + +**Date:** 2026-07-27 +**Purpose:** Establish the regression net required before upgrading browsercms from Rails 4.2 to Rails 8. +**Method:** Static analysis — source-to-test file mapping, test corpus inspection, and parsing of the existing SimpleCov report in `cms/coverage/`. No suites were executed; see [Appendix B](#appendix-b--getting-real-numbers) for how to produce real numbers. + +--- + +## 1. Executive summary + +The upgrade cannot start yet. Not because coverage is low in aggregate — it isn't catastrophic — but because **coverage is inversely correlated with upgrade risk**. The code most likely to break across six Rails majors is precisely the code with no tests. + +| | browsercms | cms | +|---|---|---| +| App + lib Ruby | 13,540 LOC | ~1,638 tracked LOC (+ ~929 untracked) | +| Test + spec code | 9,807 LOC | ~1,180 LOC | +| Cucumber features | 4,860 LOC (53 features) | — (none) | +| Test files | 92 minitest + 11 specs | 16 specs (~79 examples, 3 pending, 1 empty) | +| Measured coverage | **unknown — never measured** | 69.18% line / 22.79% branch (July 2025, inflated) | +| CI | Travis (`.travis.yml`, dead — no `.github/`) | GitHub Actions, `--fail-fast`, no coverage gate | + +### The five findings that matter + +1. **browsercms has never had its coverage measured.** `.simplecov` exists (`SimpleCov.start 'rails'`) and `test/test_helper.rb` requires it, but there is no `coverage/` directory, no CI publishing, and no threshold. Every number in this document for browsercms is a *file-existence* proxy, not line coverage. Real line coverage is very likely well below what the file counts suggest. + +2. **The controller layer is 78% untested.** 32 of 46 controllers have no test file of any kind. Controllers are where Rails 5's `get :action, params: {}` change, strong parameters, `render :text` removal, and `ActionController::Parameters` no longer being a Hash will all bite. See [§3.2](#32-controllers--the-single-largest-gap). + +3. **The monkeypatches are the real bomb, and 5 of 10 are completely untested.** `lib/cms/extensions/` reopens `ActiveModel::Errors`, `ActionView::Base`, `ActiveSupport::Cache::FileStore`, `NilClass`, `String`, `Hash`, and `ActiveRecord::ConnectionAdapters::SchemaStatements`. Several patch APIs that Rails rewrote entirely (Errors in 6.1) or that Ruby 3 now provides natively (`Hash#except`). These fail *silently*, not loudly. See [§3.4](#34-libcmsextensions--highest-breakage-risk-per-line). + +4. **The test suite itself will not run after the upgrade.** `test/test_helper.rb` uses `Devise::TestHelpers` (removed in Devise 4.2), `mocha/setup` (deprecated), `assert_template`/`assigns` (extracted to `rails-controller-testing` in Rails 5), 88 old-style `get :action, param` calls (removed in Rails 5), and `factory_girl` (renamed `factory_bot` in 2017). Fixing the harness is a prerequisite to using it, and touching all of it at once destroys its value as a regression baseline. See [§4](#4-the-test-harness-itself-blocks-the-upgrade). + +5. **The content-block lifecycle is covered only in browsercms's Cucumber suite, and not at all in cms.** browsercms's features do exercise most of the flow — `manage_custom_blocks.feature` (create → delete → render on public page → view version history), `manage_html_blocks.feature` (Save And Publish, edit-then-republish, draft viewing), `add_content_to_pages.feature` (create from page editor → connect to page → assert render) — and all run in the default `rake` task. But no single test chains create → edit → publish → connect → render → version → revert, and **cms has zero coverage of any of it**: 33 `acts_as_content_block` models show "100% covered" because only the macro line executed at load, and 19 `Cms::ContentBlockController` subclasses plus 34 `content_blocks` route entries have none. Cucumber is also the most fragile part of the suite under upgrade (Capybara/Poltergeist, PhantomJS-era). Treat it as a real but brittle asset to be *migrated*, not as a gap. + +**Recommendation:** ~5–7 weeks of test work before the first `gem 'rails', '~> 5.0'` bump. Phases 0–2 ([§6](#6-the-plan)) are non-negotiable; Phase 3 can run in parallel with the upgrade itself. + +--- + +## 2. Why file-existence is the wrong metric (and what to do about it) + +Three distinct measurement problems are in play: + +**browsercms:** no coverage has ever been collected. A file having `foo_test.rb` next to `foo.rb` says nothing about whether the 400-line file has 4 assertions or 400. `dynamic_attributes.rb` is 384 lines with a 73-line test — nominally "tested," effectively not. + +**cms:** the July 2025 SimpleCov report is inflated three ways. +- `spec/spec_helper.rb` has **no `add_filter`**, so the 23 spec files (481 lines, 89% "covered") are counted as application code. Excluding them, real coverage is **63.2%**, not 69.18%. +- **No `track_files`**, so 75 Ruby files (~932 lines) that were never `require`d during the run are *absent* from the report rather than showing 0%. `app/controllers` reads 82.7% because only 4 of 56 controllers were loaded — real file-level controller coverage is **7%**. +- The run is ~12 months stale; two files in it no longer exist, and two files with specs (`discount_tours_controller.rb`, `content_splitter_service.rb`) are missing from it entirely. + +Treat 63.2% as a ceiling, and the true figure as closer to **~40% of all application code**. + +**Fix first (30 minutes, Phase 0):** add `add_filter '/spec/'` + `track_files 'app/**/*.rb'` etc. to cms's SimpleCov config, and wire browsercms's existing `.simplecov` into a rake task that writes a report. You cannot manage what you have not measured, and every phase below should be validated against a real delta. + +--- + +## 3. browsercms — gap analysis + +Legend: **DIRECT** = a matching `*_test.rb` / `*_spec.rb` exists. **mention-only** = class name appears in some test but has no dedicated file. **NONE** = no reference anywhere in `test/`, `spec/`, or `features/`. + +### 3.1 Models — 24 DIRECT / 12 mention-only / 11 NONE (of 47) + +Models are the best-covered layer. The notable gaps: + +| File | LOC | Status | Why it matters for the upgrade | +|---|---|---|---| +| `app/models/cms/persistent_user.rb` | 209 | **NONE** | Largest untested model. Devise integration point; Devise majors gate the whole upgrade. | +| `app/models/cms/section.rb` | 286 | mention-only | Largest model without a dedicated test. `ancestry` gem + tree traversal; `ancestry ~> 3.0` is Rails-4-era. | +| `app/models/cms/section_node.rb` | 169 | mention-only | `acts_as_list` (vendored fork) + `touch` semantics, both changed in Rails 5/6. | +| `app/models/cms/view_context.rb` | 53 | **NONE** | Wraps `ActionView` internals — high churn area across Rails versions. | +| `app/models/cms/dynamic_view.rb` | 81 | mention-only | DB-backed view resolution via `panoramic`; interacts with view path lookup. | +| `app/models/cms/guest_user.rb` | 57 | mention-only | Auth null-object; pairs with `persistent_user`. | +| `app/models/cms/group_permission.rb`, `group_section.rb`, `user_group_membership.rb`, `group_type_permission.rb` | 12/9/9/8 | **NONE** | The entire join-model layer of the permission system is untested. Small files, but permissions failing open is the worst possible upgrade regression. | +| `app/models/cms/email_message_mailer.rb` | 12 | **NONE** | `ActionMailer` API changed in Rails 5 (`deliver` → `deliver_now`). | +| `app/models/cms/page_route_option.rb`, `page_route_condition.rb`, `page_route_requirement.rb` | 7/5/5 | **NONE** | Feed dynamic route generation. | + +### 3.2 Controllers — the single largest gap + +**11 DIRECT / 4 mention-only / 31 NONE of 46.** By line count, 1,289 of 2,311 controller LOC (**56%**) sit in files with no test. + +(`inline_content_controller.rb` has a test file — `test/functional/cms/inline_controller_test.rb` — but it contains a single `assert_equal` on `HTML::FullSanitizer` and never touches the controller. It is counted DIRECT below for accuracy and listed in the untested table anyway, because it provides no protection.) + +Largest untested: + +| File | LOC | Notes | +|---|---|---| +| `cms/form_entries_controller.rb` | 140 | Public form submission — user-facing, handles params. | +| `cms/resource_controller.rb` | 125 | **Base class for much of the admin UI.** Untested inheritance root. | +| `cms/users_controller.rb` | 125 | User CRUD + Devise. | +| `cms/dynamic_views_controller.rb` | 81 | Template editing; touches view resolution. | +| `cms/section_nodes_controller.rb` | 78 | Sitemap drag/drop — AJAX + `acts_as_list`. | +| `cms/form_fields_controller.rb` | 74 | | +| `cms/connectors_controller.rb` | 73 | Core page↔block wiring. | +| `cms/page_route_options_controller.rb` | 58 | | +| `cms/page_routes_controller.rb` | 51 | Dynamic routing admin. | +| `cms/inline_content_controller.rb` | 50 | In-place editing. Nominal test file exists but asserts nothing about the controller. | +| `cms/attachments_controller.rb` | 48 | File serving; Paperclip + `send_file`. | +| `cms/base_controller.rb` | 21 | Another untested inheritance root. | +| `cms/sites/passwords_controller.rb`, `passwords_controller.rb` | 32/22 | Password reset — Devise controllers. | +| `cms/portlet_controller.rb`, `portlets_controller.rb` | 21/35 | Portlet rendering pipeline. | +| ...plus 17 smaller controllers, all NONE | | | + +Two untested base controllers (`resource_controller`, `base_controller`) is the structural problem: a single change in their filter chain silently alters 30+ subclasses with nothing to catch it. + +### 3.3 Helpers — 5 DIRECT / 1 mention-only / 11 NONE (of 17) + +Helpers are ~40% of the rendering surface and mostly dark: + +| File | LOC | Status | +|---|---|---| +| `cms/ui_elements_helper.rb` | 168 | **NONE** — largest untested helper; generates raw HTML strings, uses `next_tabindex` (an untested `ActionView::Base` monkeypatch). | +| `cms/path_helper.rb` | 115 | **mention-only** — included into `ActiveSupport::TestCase` by `test_helper.rb:158` but never asserted against. URL generation for content blocks; route helper behavior changed across versions, and cms monkeypatches it. | +| `cms/template_support.rb` | 18 | **NONE** | +| `cms/section_nodes_helper.rb` | 101 | **NONE** — sitemap rendering. | +| `cms/content_block_helper.rb` | 55 | **NONE** | +| `cms/form_tag_helper.rb` | 38 | **NONE** — wraps `ActionView` form tag helpers, which changed substantially in Rails 5.1 (`form_with`) and 6 (default `local: true`). | +| `cms/sites/devise_shim_helper.rb` | 31 | **NONE** — literally a compatibility shim, untested. | +| `cms/mobile_helper.rb` | 29 | **NONE** | +| `cms/sites/authentication_helper.rb` | 25 | **NONE** | +| `cms/nav_menu_helper.rb` | 23 | **NONE** | +| `login_portlet_helper.rb`, `forgot_password_portlet_helper.rb` | 10/9 | **NONE** | + +### 3.4 `lib/cms/extensions/` — highest breakage risk per line + +Ten monkeypatch files, 244 lines. These are loaded by a bare `Dir[].each { require }` glob (`lib/cms/extensions.rb`, untested). + +| File | LOC | Patches | Tested | Upgrade hazard | +|---|---|---|---|---| +| `active_record/connection_adapters/abstract/schema_statements.rb` | 97 | `SchemaStatements` | ✅ `test/unit/schema_statements_test.rb` (80) | **Reopens a Rails internal module with no wrapper.** Calls `create_table table_name, options, &block` — the positional-options signature changed in Rails 5+. Best-tested extension, still the riskiest file. | +| `active_record/base.rb` | 37 | `ActiveRecord::Base` | ✅ (25 lines) | Adds `updated_on_string`, `database_exists?`. | +| `active_model/name.rb` | 12 | `ActiveModel::Name` | ❌ **NONE** | Self-described **Rails 3.2 back-compat shim** re-adding `foreign_key`. Depended on by `dynamic_attributes.rb` (384 LOC / 73 test LOC). | +| `active_record/errors.rb` | 12 | `ActiveModel::Errors` | ❌ **NONE** | `add_from_hash` calls `errors.add(k, v)`. **`ActiveModel::Errors` was rewritten in Rails 6.1** — this will change semantics silently. Used live by `app/portlets/email_page_portlet.rb`. | +| `action_view/base.rb` | 12 | `ActionView::Base` | ❌ **NONE** | `next_tabindex`, used by the untested 168-LOC `ui_elements_helper.rb`. | +| `active_support/cache/file_store.rb` | 8 | `ActiveSupport::Cache::FileStore` | ❌ **NONE** | **Reopens the class with no wrapper** — a load-order change creates a phantom class rather than raising. Uses the private `cache_path`. | +| `nil.rb` | 18 | `NilClass` | ❌ **NONE** (only `round_bytes` incidentally) | Defines `to_formatted_s` on `NilClass` — direct collision risk with Rails 7 core-ext changes. | +| `string.rb` | 23 | `String` | ⚠️ partial (13 lines, only `pluralize_unless_one`) | Defines `String#indent`, **which collides with Rails' own**. `markdown`, `to_slug` untested. | +| `hash.rb` | 10 | `Hash` | ⚠️ partial | Overrides **Ruby 3.0+ native `Hash#except`** via `reject`. Should simply be deleted. | +| `integer.rb` | 15 | `Integer` | ✅ | Fine. | + +**5 of 10 fully untested, 2 partial.** These are ~10-line files — writing characterization tests for all of them is under a day's work and is the highest ROI in the entire plan. + +### 3.5 `lib/cms/behaviors/` — the core mixins + +16 files, 2,081 LOC, auto-included into `ActiveRecord::Base` by a glob-and-`constantize` loader (`lib/cms/behaviors.rb`, untested). Test-to-source ratio matters more than presence here: + +| Behavior | LOC | Test LOC | Ratio | Assessment | +|---|---|---|---|---| +| `dynamic_attributes.rb` | 384 | 73 | **0.19x** | **Worst ratio in the repo.** EAV via `method_missing` + `ActiveModel::Name` shim. | +| `versioning.rb` | 368 | 140 | **0.38x** | Callback + STI machinery. Callback ordering changed in Rails 5 (`return false` no longer halts). | +| `attaching.rb` | 375 | 736 | 1.96x | Best covered. Paperclip 5 → needs replacement anyway. | +| `publishing.rb` | 195 | 238 | 1.22x | Adequate. | +| `rendering.rb` | 195 | 68 | 0.35x | Thin. | +| `connecting.rb` | 141 | 93 | 0.66x | Thin. | +| `soft_deleting.rb` | 94 | 0 | — | **No dedicated test.** 2 incidental mentions. | +| `taggable.rb` | 66 | 113 | 1.71x | Good. | +| `hiding.rb` | 46 | 0 | — | **Zero references to `Hiding` / `is_hideable` anywhere in tests.** The `hidden` *attribute* is touched by `page_test.rb` visibility assertions; the mixin's own scopes and API are completely dark. | +| `searching.rb` | 45 | 85 | 1.89x | Good. | +| `archiving.rb` | 43 | 0 | — | Incidental `archive` mentions only. | +| `userstamping.rb` | 39 | 26 | 0.67x | Both tests cover nil-user cases; **the happy path (user present → `created_by` set) is untested.** | +| `flush_cache_on_change.rb` | 30 | 0 | — | **Zero references.** `after_save`/`after_destroy` → `Cms::Cache.flush`. | +| `categorizing.rb` | 30 | 0 | — | The category *models* are tested; the mixin is not. | +| `naming.rb` | 16 | 23 | 1.44x | Fine. | +| `namespacing.rb` | 14 | — | — | Empty body, deprecated shim. Delete. | + +### 3.6 Engine, routing, and autoloading — near-zero coverage on the hardest part + +| File | LOC | Test | Hazard | +|---|---|---|---| +| `lib/cms/engine.rb` | 135 | **7-line test, 1 assertion** | Pushes 6 dirs onto `ActiveSupport::Dependencies.autoload_paths` — **classic autoloader only, incompatible with Zeitwerk** (mandatory from Rails 7). Initializer anchored `:after => 'action_controller.deprecated_routes'`, an initializer that **no longer exists**. Calls `routes_reloader.reload!` in `after_initialize`. Sprockets-3-style `assets.precompile`. This one file is the single biggest concentration of upgrade work and it has essentially no test. | +| `lib/cms/route_extensions.rb` | 157 | 62 (0.4x) | Mixed into `ActionDispatch::Routing::Mapper`; the test stubs a fake builder rather than exercising a real `Mapper`. | +| `lib/cms/behaviors.rb` / `concerns.rb` / `acts.rb` / `extensions.rb` | 34/6/7/5 | **NONE** | Glob + `constantize` + include-into-`ActiveRecord::Base`. Zeitwerk forbids this pattern. | +| `lib/cms/polymorphic_single_table_inheritance.rb` | 18 | **NONE** | Overrides AR STI column behavior. | +| `lib/cms/configuration/devise.rb` | 256 | **NONE** | Second-largest untested file. Devise is a hard upgrade gate. | +| `lib/cms/authentication/controller.rb` | 120 | **NONE** direct | | +| `lib/cms/configure_simple_form.rb` + `_bootstrap.rb` | 200 | Cucumber only | SimpleForm 3.1 → 5.x DSL changes. | +| `lib/cms/attachments/configuration.rb` | 88 | **NONE** | Runs in `to_prepare` on every dev request. | +| `lib/acts_as_list.rb` | 295 | **NONE** | **Largest untested file in `lib/`.** A vendored fork of the gem. Deep AR callback/scope code. | + +**~2,050 of 6,142 `lib/` lines (33%) have no dedicated test**, and the untested set is disproportionately monkeypatches, engine config, and gem glue. + +--- + +## 4. The test harness itself blocks the upgrade + +Independent of coverage, the browsercms suite cannot execute on Rails 5+ without a rewrite: + +| Issue | Count / location | Breaks at | +|---|---|---| +| `get :action, param_hash` (positional params) | **88 occurrences** in `test/` | Rails 5 (requires `params: {}`) | +| `assert_template` | 19 | Rails 5 (extracted to `rails-controller-testing`) | +| `render :text` in tested controllers | 4 | Rails 5.1 | +| `assigns(...)` | 11 | Rails 5 (same gem) | +| `Devise::TestHelpers` | `test/test_helper.rb:199` | Devise 4.2 (→ `Devise::Test::ControllerHelpers`) | +| `mocha/setup` | `test/test_helper.rb` | Mocha 2 (→ `mocha/minitest`) | +| `factory_girl` / `FactoryGirl` | throughout both repos | renamed `factory_bot` in 2017 | +| `MonitorMixin`/`recycle!` monkeypatch | `test/test_helper.rb` (guarded, self-disables on Rails 5+) | benign | +| `$VERBOSE = nil` | `test/test_helper.rb` | hides deprecation warnings — **the single most useful signal during a Rails upgrade** | +| Travis CI | `.travis.yml`; no `.github/` | Travis OSS is effectively dead; **browsercms has no working CI** | + +Also: `test/unit/lib/cms_domain_support_test.rb` and `test/unit/lib/cms/domain_support_test.rb` are duplicate/overlapping tests of the same file. + +**Implication for sequencing:** modernizing the harness (Phase 1) must happen *before* writing new tests, or every new test gets written twice. And `$VERBOSE = nil` must be removed early — deprecation warnings are how you find the next thing to fix. + +On the cms side: **Capybara is not in the Gemfile or lockfile at all**, yet two `spec/features/**` specs call `visit`/`click_on` (a third, `spec/features/cms/admin/sitemap_spec.rb`, is a **0-byte file**). Nearly all their assertions are `pending`. Feature-level coverage of cms is effectively zero. CI runs with `--fail-fast`, which during an upgrade means one early failure hides the entire tail of the suite — remove it before starting. + +--- + +## 5. cms — the integration surface + +cms is the regression detector that matters most: it is the real consumer of every browsercms API. 52 Ruby files under `app/`/`lib/`/`config/` reference `Cms::` directly (110 including `db/migrate`), plus 57 migrations. At the view layer, 9 views reference `Cms::` constants explicitly, but the coupling is far wider through implicit CMS locals — **`@content_block` appears 260 times** across `app/views/`, `@page` 32, `Cms::ContentType` 30 — and 17 engine views are overridden in-app. + +### 5.1 Highest-risk files (heavy CMS coupling × poor coverage) + +| File | Line cov | Branch cov | Coupling | +|---|---|---|---| +| `config/initializers/browsercms_overrides.rb` | **28.8%** (64/222) | **0/67** | 567 physical lines, **40 `Cms::` refs.** Monkeypatches `Cms::Attachment` (~145 lines), `Authentication::Controller#current_user`, `ContentController#show`, `PagesController#new`, `Page.currently_connected_to`, `RenderingHelper#show`, `PageHelper#page_header`, `SectionNode#touch_node`, `Behaviors::Publishing#publishable?`, `Behaviors::Searching#is_searchable`, and more. **This one file is 24% of all uncovered lines in cms.** | +| `app/helpers/cms/menu_helper.rb` | **9.8%** (4/41) | **0/28** | Reimplements the engine's own helper inside the app. | +| `config/initializers/override_csrf_encode_decode.rb` | **25.9%** | 1/8 | **Hard-fails boot with `exit(1)` unless `Rails.version == '4.2.11'`.** Reimplements `masked_authenticity_token` / `valid_authenticity_token?`. This aborts the app on the first boot after any version bump. | +| `config/initializers/override_bcms_partial_error_rescue.rb` | **15.4%** | 0/8 | Replaces `RenderingHelper#render_connectable`. | +| `config/initializers/override_show_link.rb` | **20.0%** | 0/6 | Replaces `Cms::PathHelper#link_to_usages`; branches on `Cms::Portlet === block`. | +| `lib/audio_file_uploader.rb` | 22.2% | 0/4 | `Cms::Section.find_by_path`, builds attachments. | +| `lib/connectable.rb` | 23.1% | 1/6 | `Cms::Connector` heuristics. | +| `app/helpers/cms/destination_helper.rb` | 28.0% | 0/4 | 10 `@content_block` calls. | +| `app/helpers/application_helper.rb` | 44.0% (40/91) | 0/6 | Raw SQL against `data_file_path`, `attachable_type`. | +| `lib/image_extractor.rb` | **no data (0%)** | — | 145 lines, 8 `Cms::` refs. | + +Aggregate: the 50 tracked integration-surface files are **60.0% line covered**; another 30 surface files have no coverage data at all. + +### 5.2 The illusion of coverage + +- `config/routes.rb` reads **100%** — because routes are drawn at boot. It carries zero signal that any of the **34 `content_blocks` CRUD surfaces** work. +- The 33 `acts_as_content_block` models read **100%** — several are 3-line class bodies where only the macro line executed at load time. +- **All 19 `Cms::ContentBlockController` subclasses in `app/controllers/rse/` have zero coverage.** (40 files in `app/controllers/rse/` total.) +- **No test in cms exercises the create/edit/publish/render lifecycle of a content block.** (browsercms's Cucumber suite does — see Finding 5 in §1.) + +### 5.3 Rails-4-isms in cms + +**11 associations across 3 files pass `class_name:` a bare constant rather than a string** — which breaks under modern Rails autoloading: `app/models/rse/article.rb` (8, including `class_name: Cms::Connector`), `app/models/rse/tile.rb` (2), `app/models/rse/site_tile.rb` (1). Other content-block models (`audio_tour.rb`, `page_link.rb`, `extra_link.rb`, `generic_subsection.rb`, `audio_europe.rb`, `image_collection_image.rb`) use the correct string form and are not affected. + +Also in cms: 4 `before_filter`, `Rse::VideoEpisode::Version.class_eval` patching a CMS-generated versions class, and `monkeypatch_active_record.rb` patching `PostgreSQLAdapter`. No live `attr_accessible` (only a comment in `config/application.rb` and a commented line in `db/browsercms.seeds.rb`). + +In browsercms: **38 filter macros** — 34 `before_filter`, 2 `after_filter`, 2 `skip_before_filter`, zero `around_filter` (all renamed in Rails 5.1); **16 `update_attributes`** (removed in Rails 7: 7 in models, 4 in controllers, 5 in behaviors); **~13 live dynamic `find_by_*` call sites** (17 non-comment matches, but 4 are hand-written `def self.find_by_*` definitions in `site.rb`, `dynamic_view.rb`, `section.rb`, `content_type.rb`); **4 `render :text`** sites (removed in Rails 5.1) in `tests/pretend_controller.rb` ×2, `content_block_controller.rb:138`, `form_fields_controller.rb:43`; 1 `alias_method_chain` (removed in Rails 5.1). All **11 `attr_accessible` lines are already commented out** — dead code, no work required. + +### 5.4 Dependency gates + +cms pulls `browsercms 5.2.0` from a private Gem Fury source and **Rails 4.2.10 from `gems.railslts.com`** (Rails LTS — a commercial patched fork). The LTS source disappears the moment you move off 4.2, so the very first bump changes the dependency topology. `faker` is pinned to a git tag from `github.com/stympy/faker`, a repo since renamed — a bundle-resolution hazard on any `bundle update`. + +--- + +## 6. The plan + +Sequencing principle: **make the suite runnable and measurable → freeze current behavior with characterization tests → build end-to-end safety nets → only then bump Rails.** Characterization tests assert what the code *does today*, not what it should do; their job is to scream when a Rails upgrade silently changes semantics. + +### Phase 0 — Instrumentation (2–3 days) + +Do this first. Everything after depends on being able to see the delta. + +1. **cms SimpleCov fix.** Add `add_filter '/spec/'`, `add_filter '/vendor/'`, `track_files 'app/**/*.rb'`, `track_files 'lib/**/*.rb'`, `track_files 'config/initializers/**/*.rb'`, plus `add_group`s. Re-run. Expect the headline to drop from 69% into the low 40s — that is the real baseline. +2. **browsercms coverage.** Wire the existing `.simplecov` into a rake task that emits HTML + JSON. Add the same filters/`track_files`. **Get the first real number this repo has ever had.** +3. **Remove `--fail-fast`** from `.github/workflows/ci.yml` in cms. Add `--format documentation` so failures are legible. +4. **Stand up CI for browsercms.** Port `.travis.yml` to GitHub Actions (Ruby 2.7.8, Postgres). Travis is dead; browsercms currently has no CI at all. This is a hard blocker — you cannot do a six-version upgrade without a green button. +5. **Set a coverage floor** in both repos (`minimum_coverage` at whatever the real baseline is) so the upgrade cannot silently delete tests. +6. **Find out whether the 53 Cucumber features actually pass.** They are the only end-to-end coverage that exists anywhere, and they run on Poltergeist/PhantomJS (abandoned since 2018). If a large share are already red, Phase 3 grows substantially — better to know on day one. + +**Exit criteria:** both suites run green in GitHub Actions and publish a real coverage number, and the true pass rate of the Cucumber suite is known. + +### Phase 1 — Harness modernization (1 week) + +Mechanical, unglamorous, and blocking. Do it while still on Rails 4.2 so failures are unambiguous. + +1. `factory_girl` → `factory_bot` in both repos (`FactoryGirl::Syntax::Methods` → `FactoryBot::Syntax::Methods`, block syntax `m.name 'Root'` → `m.name { 'Root' }`). +2. `mocha/setup` → `mocha/minitest`. +3. Add `rails-controller-testing` to preserve the 19 `assert_template` and 11 `assigns` call sites through Rails 5. +4. Mechanically convert the **88 positional `get :action, params`** calls to `get :action, params: {...}` — Rails 4.2 accepts the keyword form, so this is a safe pre-emptive change. +5. `Devise::TestHelpers` → `Devise::Test::ControllerHelpers`. +6. **Delete `$VERBOSE = nil`** from `test/test_helper.rb`. Fix or explicitly silence the resulting noise. Deprecation warnings are your upgrade roadmap. +7. Add Capybara + `rack_test` to cms, un-`pending` the three feature specs, delete or write `spec/features/cms/admin/sitemap_spec.rb` (currently 0 bytes). +8. De-duplicate `cms_domain_support_test.rb` / `cms/domain_support_test.rb`. + +**Exit criteria:** suites green, no positional-param or `factory_girl` call sites remain, deprecation warnings visible in CI logs. + +### Phase 2 — Characterization tests on the breakage surface (2–3 weeks) + +Ordered by (risk × exposure) ÷ effort. This is the actual safety net. + +**2a. Monkeypatch characterization — ~1 day, highest ROI in the plan.** +One test file per extension, asserting exact current behavior: +- `active_record/errors.rb` — `add_from_hash` with single/multiple/empty/nil hashes. *(Rails 6.1 rewrote `ActiveModel::Errors`; this is the most likely silent breakage in the codebase.)* +- `active_model/name.rb` — `foreign_key` output for namespaced and plain models. +- `action_view/base.rb` — `next_tabindex` sequencing across calls. +- `active_support/cache/file_store.rb` — `flush` behavior and `cache_path` dependency. +- `nil.rb` — all four methods, **especially `to_formatted_s`**. +- `string.rb` — `indent` (**document the collision with Rails' own**), `markdown`, `to_slug`. +- `hash.rb` — `except`; then **delete the patch** and confirm Ruby 3 native behavior is identical. +- Extend `schema_statements_test.rb` to cover `create_content_table` / `drop_content_table` option-passing, since the `create_table` signature changed. + +**2b. Untested behaviors — ~3 days.** +`hiding.rb` (46 LOC, completely dark), `flush_cache_on_change.rb` (30, zero refs), `soft_deleting.rb` (94), `archiving.rb` (43), `categorizing.rb` (30). Plus the **`userstamping` happy path** — currently only nil-user cases are asserted, so a broken `created_by` would pass CI today. + +**2c. Deepen the two thinnest large behaviors — ~1 week.** +`dynamic_attributes.rb` (384 LOC / 73 test LOC) and `versioning.rb` (368 / 140). Both are `method_missing` + AR-callback machinery, and **Rails 5 changed callback halting** (`return false` no longer halts a chain — it must be `throw :abort`). Target ≥1.0x test-to-source ratio. Pay particular attention to callback ordering and `_versions` table generation. + +**2d. Engine and autoloading — ~3 days.** +Tests asserting: which paths land in `autoload_paths`; the initializer ordering contract; the `assets.precompile` list; that all 16 behaviors are actually included into `ActiveRecord::Base`; that `Cms::RouteExtensions` reaches `ActionDispatch::Routing::Mapper`. These will *all* need rewriting for Zeitwerk — which is precisely the point. They document the contract you must reproduce. + +**2e. Controller smoke tests — ~1 week.** +Not deep tests. For each of the 32 untested controllers: authenticate, hit each action, assert a non-5xx response and correct redirect-vs-render. Start with the two base classes (`resource_controller`, `base_controller`), then `form_entries`, `users`, `connectors`, `attachments`, `section_nodes`, `page_routes`, and the two Devise `passwords_controller`s. This is where Rails 5's param and `render :text` changes surface. + +**Exit criteria:** every file in [§3.4](#34-libcmsextensions--highest-breakage-risk-per-line) and [§3.5](#35-libcmsbehaviors--the-core-mixins) has a dedicated test; no controller is entirely dark. + +### Phase 3 — End-to-end content-block lifecycle (1–2 weeks) + +**The most valuable single deliverable in the plan.** browsercms's Cucumber suite already covers much of this flow (see Finding 5), so Phase 3 has two halves: + +**3a. Protect the existing Cucumber coverage.** It runs on `capybara` + `poltergeist` (PhantomJS, abandoned) and `cucumber-rails`, and `aruba` is hard-pinned to `0.14.14`. This stack will not survive the upgrade untouched. Migrate the driver to `cuprite` or headless Chrome **while still on Rails 4.2**, so you find out now whether the 53 features actually pass. If a meaningful share are already red, that changes the plan — this is worth checking in Phase 0. + +**3b. Build the equivalent in cms**, where coverage is genuinely zero. One integration spec per representative content-block archetype: +1. plain block (`Rse::Accordion`) +2. block with attachments (`Rse::ImageBlock`, `Rse::AudioRadio` with its 17 attachments) +3. taggable block (`Rse::VideoClip`) +4. versioned/publishable block (`Rse::Article`) +5. addressable block (`Rse::Destination`) + +Each covering the **full chain in one test: create → edit → publish → connect to page → render on the public page → view version history → revert.** browsercms's Cucumber scenarios cover these steps but split across separate features; chaining them is what catches state-transition bugs. That single flow exercises `acts_as_content_block`, `Connecting`, `Publishing`, `Versioning`, `Attaching`, `Taggable`, `RenderingHelper`, `PathHelper`, the `content_blocks` route DSL, and `ContentBlockController` — i.e. most of the surface [§5.2](#52-the-illusion-of-coverage) shows as falsely covered. + +Also in Phase 3: +- Characterization tests for `config/initializers/browsercms_overrides.rb` (567 physical / 222 relevant lines, 0/67 branches, 40 `Cms::` refs). Given that it monkeypatches ~145 lines of `Cms::Attachment`, **strongly consider upstreaming those overrides into browsercms** where they can be tested properly, rather than testing them from the app side. +- **Resolve `override_csrf_encode_decode.rb` before the first bump.** Its `exit(1)` version guard will abort boot immediately. Decide now whether to reimplement against the new Rails or drop it. +- Factories for the content-block models — currently **30 of 33 have none** (only `Rse::Tour`, `Rse::TourDestinationCategory`, `Rse::TourTypeCategory` do). + +### Phase 4 — Ongoing, during the upgrade + +- Bump one Rails minor at a time (4.2 → 5.0 → 5.1 → 5.2 → 6.0 → 6.1 → 7.0 → 7.1 → 7.2 → 8.0). Green suite at every step. +- Keep `deprecation_behavior = :raise` in test env from 5.0 onward. +- Zeitwerk is the hardest single step (7.0). Phase 2d's tests are what make it tractable. +- Replace, don't port: **Paperclip** (dead since 2018 → ActiveStorage or Shrine), **Devise** (config rewrite), **SimpleForm 3.1 → 5.x**, **jquery-rails 3.1 → 4.x**, the **vendored `acts_as_list` fork** (295 untested lines — adopt the maintained gem), **Sprockets 3 → Propshaft or Sprockets 4**, and the **Rails LTS gem source** (disappears at 4.2). +- Ratchet the coverage floor upward after each phase. + +--- + +## 7. Priority-ordered backlog + +| # | Item | Repo | Effort | Risk addressed | +|---|---|---|---|---| +| 1 | Fix SimpleCov config; get real baselines | both | 0.5d | Flying blind | +| 2 | GitHub Actions CI for browsercms | browsercms | 1d | **No CI at all** | +| 3 | Remove `--fail-fast`; remove `$VERBOSE = nil` | both | 0.5d | Hidden failures & deprecations | +| 3b | Establish true Cucumber pass rate; migrate off Poltergeist | browsercms | 2–3d | **Only E2E coverage that exists, on a dead driver** | +| 4 | Harness modernization (Phase 1) | both | 1w | Suite won't run on Rails 5 | +| 5 | Monkeypatch characterization tests | browsercms | 1d | **Silent semantic breakage** | +| 6 | Untested behaviors (hiding, flush_cache, soft_delete, archiving, categorizing, userstamping happy path) | browsercms | 3d | Dark core mixins | +| 7 | Content-block lifecycle integration specs | cms | 1–2w | **Core flow, zero coverage in cms** | +| 8 | Deepen `dynamic_attributes` + `versioning` | browsercms | 1w | Rails 5 callback halting | +| 9 | Engine/autoload contract tests | browsercms | 3d | **Zeitwerk migration** | +| 10 | Controller smoke tests (31 files) | browsercms | 1w | 56% of controller LOC dark | +| 11 | `browsercms_overrides.rb` characterization / upstream | cms | 1w | 24% of cms's uncovered lines | +| 12 | Resolve `override_csrf_encode_decode.rb` `exit(1)` guard | cms | 0.5d | **Boot failure on first bump** | +| 13 | Helper tests (11 untested, esp. `ui_elements`, `path_helper`, `form_tag_helper`) | browsercms | 1w | Rendering surface | +| 14 | Factories for 31 content-block models | cms | 3d | Enables everything above | +| 15 | Permission join-model tests | browsercms | 2d | **Auth failing open** | +| 16 | Delete dead code (`sequence.rb`, `namespacing.rb`, `hash.rb` patch, 11 commented `attr_accessible` lines, `override_bcms_associations.rb`, `page_helper_old.rb.old`, cms's legacy 12-file `test/` tree, duplicate domain-support tests) | both | 1d | Reduces surface to port | +| 17 | Fix 11 bare-constant `class_name:` associations in cms (`article.rb`, `tile.rb`, `site_tile.rb`) | cms | 0.5d | Breaks under modern autoloading | + +**Phases 0–2 ≈ 5–6 weeks. Phase 3 ≈ 1–2 weeks, partly parallelizable.** Items 1–3 should start today regardless of anything else. + +--- + +## Appendix A — Method and limitations + +- Source-to-test mapping by filename convention **and** class-name occurrence across the full test/spec/feature corpus, so "NONE" means no textual reference of any kind — a strong signal. +- **"DIRECT" does not mean well-tested.** It means a file exists. Test-to-source line ratios are given wherever they were checked, and several DIRECT files (`engine_configuration_test.rb`: 7 lines for a 135-line engine; `commands_actions_test.rb`: 22 lines for 106; `inline_controller_test.rb`: asserts nothing about its controller) are effectively smoke tests or worse. +- Figures in this document were independently fact-checked against both repos on 2026-07-27; corrections have been applied. Remaining unverified items are the `lib/` untested-line estimate (~2,050 of 6,142), the 60.0% integration-surface figure, the "~40% of all application code" projection, and all effort estimates. +- browsercms line coverage is **unmeasured**. All browsercms figures are file-existence proxies and should be treated as optimistic. +- cms figures derive from a stale, unfiltered SimpleCov run; see [§2](#2-why-file-existence-is-the-wrong-metric-and-what-to-do-about-it). + +## Appendix B — Getting real numbers + +```bash +# browsercms (Ruby 2.7.8, Postgres) +cd browsercms +createdb browsercms_test +bundle install +bundle exec rake # units + spec + functionals + features +open coverage/index.html + +# cms (Ruby 3.1.6, Postgres 16, needs Gem Fury + Rails LTS credentials) +cd cms +bundle install +bundle exec rake db:test:prepare +bundle exec rspec spec --format documentation # note: drop --fail-fast +open coverage/index.html +``` + +Run these before starting Phase 1 so the Phase 0 baseline is measured rather than estimated. If the numbers differ materially from this analysis, §6 priorities should be re-sorted against them. diff --git a/TEST_COVERAGE_PLAN.md b/TEST_COVERAGE_PLAN.md new file mode 100644 index 000000000..d798f8be2 --- /dev/null +++ b/TEST_COVERAGE_PLAN.md @@ -0,0 +1,337 @@ +# Test Coverage Plan — browsercms + +**Date:** 2026-07-28 +**Source:** `coverage/index.html` + `coverage/.resultset.json`, produced by the run at `d0d108cc [CMS-420] tests are running` +**Suites merged:** Unit Tests, RSpec, Functional Tests, Cucumber Features (4 suites, 219 files, all of `app/` + `lib/`) +**Ordering principle:** coverage gained per unit of effort. Cheapest first. + +This supersedes the estimates in `TEST_COVERAGE_ANALYSIS.md`, which were static guesses made before the suite ran. Those numbers were wrong in both directions. + +--- + +## 1. Where we actually stand + +| Group | Files | Relevant lines | Covered | Missed | % | +|---|---|---|---|---|---| +| Models | 47 | 1,601 | 1,470 | 131 | **91.82%** | +| Libraries | 91 | 2,901 | 2,119 | 782 | **73.04%** | +| Ungrouped (inputs, portlets, presenters) | 18 | 207 | 135 | 72 | **65.22%** | +| Helpers | 17 | 587 | 339 | 248 | **57.75%** | +| Controllers | 46 | 1,447 | 835 | 612 | **57.71%** | +| **All files** | **219** | **6,743** | **4,898** | **1,845** | **72.64%** | + +The distribution is concentrated. **The top 10 files hold 44% of all missed lines; the top 30 hold 69%.** That is good news: this is a short list of targeted jobs, not a 219-file slog. + +### 1.1 The headline number is misleading in two directions + +**465 of the 1,845 "missed" lines do not exist as code.** + +Thirty-five files have 0.00% coverage. For every one of them, SimpleCov reports `relevant lines == total lines`, including blank lines, comments, and bare `end`s: + +``` +lib/cms/version.rb loc=11 relevant=11 (the file is 4 lines of code and 7 of comment/blank) +app/models/cms/templates.rb loc=31 relevant=31 (really ~22) +lib/cms/module_installation.rb loc=31 relevant=31 (really ~8) +``` + +This is what SimpleCov does for a file it never loaded — with no `Coverage` data to work from, it marks every line missed. Estimating real relevant lines by stripping blanks, comments, and non-executable keywords gives **640, not 1,105**. So the honest baseline, once those files are merely *loaded* by anything, is closer to **78%** than 72.64%. + +Practical consequence: the percentage will move faster than the missed-line count suggests, because the denominator shrinks as well as the numerator growing. + +**In the other direction: coverage is inversely correlated with upgrade risk.** Models — the layer least changed by Rails 5→8 — are at 92%. Controllers, where `get :action, params: {}`, strong parameters, `render :text`, and `before_filter`→`before_action` all bite, are at 58%. The 612 missed controller lines are the ones the upgrade will actually break. + +--- + +## 2. Phase 0 — fix the harness before writing a single test + +None of this is test-writing. All of it changes the number and the signal, and all of it should land first because it changes what the rest of the plan is measuring against. + +### 0a. Filter generator *templates* out of coverage — 30 minutes + +Four files under coverage are not library code. They are ERB-adjacent templates copied into a user's application by a generator, and are only executed inside a freshly generated Rails app: + +| File | Counted lines | +|---|---| +| `lib/generators/browser_cms/demo_site/templates/demo.seeds.rb` | 249 | +| `lib/templates/active_record/model/model.rb` | 17 | +| `lib/generators/cms/portlet/templates/portlet.rb` | 12 | +| `lib/generators/cms/portlet/templates/portlet_helper.rb` | 5 | +| **Total** | **283** | + +`demo.seeds.rb` alone is the single largest "gap" in the report — **13.5% of all missed lines** — and it is a seed script. Loading it would execute it. Testing it means running `rails g browser_cms:demo_site` and asserting on the resulting database, which the `@cli` cucumber features already do out of process. + +In `.simplecov`: + +```ruby +SimpleCov.start 'rails' do + add_filter %r{^/lib/generators/.*/templates/} + add_filter %r{^/lib/templates/} +end +``` + +**72.64% → 75.82%**, no tests written. + +### 0b. Delete dead code instead of testing it — 1 hour + +| File | Counted lines | Why | +|---|---|---| +| `lib/cms/commands/to_version400.rb` | 10 | Upgrade command for BrowserCMS 4.0.0. Two majors dead. | +| `app/portlets/deprecated_placeholder.rb` | 12 | Named for its own obsolescence. | + +Both are already zero-coverage. A Rails 8 upgrade is exactly the right moment to remove them. Also review `lib/cms/form_builder/deprecated_inputs.rb` (45%, 12 missed) and `app/inputs/cms_text_area_input.rb` (0%, 10 missed) — but note `features/content_blocks/deprecated_form_inputs.feature` exists, so the deprecated inputs are still contractually alive. Test those; delete the other two. + +**→ 76.08%** + +### 0c. Decide what to do about `lib/generators` — 1 hour to decide + +Five generator files sit at 0%, totalling 198 counted lines (~102 real): + +``` +lib/generators/cms/content_block/content_block_generator.rb 103 +lib/generators/cms/portlet/portlet_generator.rb 38 +lib/generators/cms/template/template_generator.rb 29 +lib/generators/cms/install/install_generator.rb 16 +lib/generators/browser_cms.rb 12 +``` + +**They are not untested.** `features/generators/*.feature` and `features/commands/*.feature` cover them — five and four files respectively. Two things hide that from the report: + +1. The default `rake features` task excludes them: `--tags ~@cli`. +2. Even when run, they execute via **aruba**, which shells out to `rails g ...` in a **child process**. SimpleCov in the parent process cannot see child-process coverage. (The pinned `simplecov (0.12.0)` predates `SimpleCov.at_fork` entirely, so there is no configuration escape hatch short of a `.simplecov` inside each generated app.) Generator coverage will read 0% no matter how thoroughly cucumber exercises them. + +Two honest options: + +- **Exclude** `lib/generators` from coverage and rely on the `@cli` features (which should be re-enabled in CI regardless). **→ 78.49%** +- **Add in-process tests** using `Rails::Generators::TestCase` — `test/test_helper.rb:17` already does `require "rails/generators/test_case"`, so the harness is ready. (`test/unit/generators/install_generator_test.rb` is *not* a head start: despite the filename it is an `ActiveSupport::TestCase` asserting on `default_engine_path`, and never invokes a generator.) This is real work (see Phase 7) but it is fast, in-process, debuggable, and it is where Rails 8's generator API changes (`hook_for`, `Rails::Generators::ResourceHelpers`, `File.exists?` → removed) will surface. `content_block_generator.rb:26` calls `File.exists?` — **that method is gone in modern Ruby**, and nothing in the measured suite touches it. + +Recommendation: exclude for now to get an honest denominator, and schedule Phase 7. Do not leave it at 0% and pretend it is a coverage gap — it is a *measurement* gap with one genuine landmine in it. + +### 0d. Wire up the orphaned tests — 2 hours + +`Rakefile` only globs three patterns: + +```ruby +t.pattern = 'test/unit/**/*_test.rb' +t.pattern = 'spec/**/*_spec.rb' +t.pattern = 'test/functional/**/*_test.rb' +``` + +Ten test files are therefore outside the `rake test` / `rake ci:test` chain entirely: + +``` +test/assumptions_test.rb +test/helpers/cms/content_types_helper_test.rb +test/dummy/test/controllers/design_controller_test.rb +test/dummy/test/functional/cms/catalogs_controller_test.rb +test/dummy/test/functional/content_page_controller_test.rb +test/dummy/test/helpers/design_helper_test.rb +test/dummy/test/models/deprecated_input_test.rb +test/dummy/test/unit/helpers/content_page_helper_test.rb +test/dummy/test/unit/portlets/find_category_portlet_test.rb +test/dummy/test/unit/portlets/uses_helper_portlet_test.rb +``` + +A distinction worth drawing: `test/assumptions_test.rb` and `test/helpers/cms/content_types_helper_test.rb` are genuinely unreachable — no task can run them. The eight `test/dummy/test/**` files *are* reachable via `rake app:test` (the `Rakefile` sets `APP_RAKEFILE` to the dummy app and loads `engine.rake`), but nothing in the `rake test` chain invokes it, and it played no part in the coverage run. Wire `app:test` into `rake test`, and add a fourth `Rake::TestTask` for `test/helpers/**` and the root-level test. + +Expect this to hurt before it helps. `test/helpers/cms/content_types_helper_test.rb` is: + +```ruby +it "must be a real test" do + flunk "Need real tests" +end +``` + +It will fail the moment it is wired in. That is the point — a red test is information, an unrun test is not. The `test/dummy/**` tests cover the engine-host integration path (`acts_as_content_page`, custom portlets, design helpers), which is precisely the surface a Rails 8 upgrade of a *mountable engine* threatens. + +### 0e. Re-enable the commented-out Forms feature — 30 minutes to confirm scope + +`features/content_blocks/forms.feature` has every scenario commented out under the header: + +```gherkin +# Forms a broken +``` + +This single act of commenting-out is why three controllers are at exactly 0.00% and why the Forms subsystem is the largest genuine gap in the codebase. Whatever "broken" meant, it needs a ticket, not a comment block. + +**Phase 0 total: 72.64% → ~78.5%, zero new tests, roughly one day of work.** + +--- + +## 3. The ordered backlog + +> **Superseded for sequencing by `RAILS_UPGRADE_TEST_PRIORITY.md`.** The order below maximises coverage gained per hour worked, which is the right lens for paying down debt but the wrong one for validating a Rails upgrade. That document re-ranks the same work by breakage risk, and — importantly — establishes that the **test harness itself must be migrated before any of this is worth writing** (`mocha 1.2.0`, `factory_girl 4.7.0`, `cucumber 2.4.0` and the positional controller-test call style will not run on Rails 8). Use this section for effort estimates and per-file detail; use that one for what to do first. + +Percentages below are cumulative and assume Phase 0 is done (baseline 78.49%, 4,898/6,240). Each phase models realistic per-file targets, not 100%. + +| # | Phase | Effort | Missed lines addressed | Cumulative | +|---|---|---|---|---| +| 1 | Pure-Ruby quick wins | ~2 days | ~180 | **81.1%** | +| 2 | Forms subsystem controllers | ~4 days | 249 | **84.4%** | +| 3 | Remaining 0% controllers | ~3 days | 173 | **86.9%** | +| 4 | Partially-covered controllers | ~5 days | 187 | **89.0%** | +| 5 | Helpers | ~4 days | 194 | **91.6%** | +| 6 | Long tail — inputs, portlets, behaviors | ~4 days | 145 | **93.2%** | +| 7 | In-process generator tests | ~3 days | (restores 198 excluded lines at ~70%) | — | + +--- + +### Phase 1 — Pure-Ruby quick wins (~2 days, → 81.1%) + +No HTTP, no fixtures, no view context. Highest lines-per-hour in the codebase. Do these first; they build momentum and they are safe to hand to whoever is least familiar with the codebase. + +| Rank | Target | Missed | Effort | Notes | +|---|---|---|---|---| +| 1 | `app/models/cms/templates.rb` | 31 | 15 min | `self.default_body` is a pure heredoc. One assertion covers 23 lines. | +| 2 | `lib/cms/module_installation.rb` | 31 | 1 h | 16 of 31 are class body — a bare `require` covers them. `mount_engine` needs a stubbed generator. | +| 3 | `lib/cms/acts/cms_user.rb` | 16 | 2 h | `able_to_view?` (7), `able_to?` (3). `test/unit/behaviors/cms_user_test.rb` already exists — extend it. Permission logic; worth real assertions, not smoke tests. | +| 4 | `lib/acts_as_list.rb` | 16 | 3 h | `first?`, `last?`, `bottom_position_in_list`, `decrement_positions_on_higher_items`, `insert_at`. Vendored gem — **and `acts_as_list` semantics change under Rails 8's `touch`/`optimistic locking` defaults.** Higher upgrade value than its size suggests. | +| 5 | `app/models/cms/section_node.rb` | 13 | 2 h | `move_before` (4), `move_after` (4). Sitemap integrity. | +| 6 | `lib/cms/version.rb` | 11 | 5 min | `assert_match /\d+\.\d+\.\d+/, Cms.version`. Currently 0% because nothing requires it at test time. | +| 7 | `lib/cms/behaviors/dynamic_attributes.rb` | 11 | 3 h | `nonversioned_class` (5). Touches `method_missing` + `read_attribute` — **`ActiveRecord` attribute API is one of the most-changed areas 4.2→8.** | +| 8 | `lib/cms/behaviors/hiding.rb` | 10 | 1 h | `hide!`, `unhide`, `unhide!` — three one-line bang methods. | +| 9 | `app/models/cms/attachment.rb` | 9 | 2 h | `configuration_value`, `dynamically_return_styles`, `public?`. | +| 10 | `app/models/cms/content_type.rb` | 9 | 1 h | `self.find_by_key` (3), `content_block_type_for_list` (3). `content_type_spec.rb` exists. | +| 11 | `app/models/cms/page.rb` / `section.rb` | 8 + 8 | 3 h | Already 96%/94%. Cheap top-up on well-understood models. | +| 12 | `app/models/cms/portlet.rb` | 18 | 3 h | `store_hash_in_flash`, `url_for_success/failure`, `self.get_subclass`. Flash-based control flow — fragile across upgrades. | +| 13 | `app/presenters/cms/user_presenter.rb` | 3 | 15 min | `as_json`. `spec/cms/presenters/user_presenter_spec.rb` exists. | +| 14 | `lib/sequence.rb` | 6 | 15 min | 4 real lines. | +| 15 | `app/helpers/cms/content_types_helper.rb` | 4 | 10 min | **The module is empty.** Delete it and its flunking test, or give it a reason to exist. | + +### Phase 2 — Forms subsystem (~4 days, → 84.4%) + +The largest genuine gap in the codebase: **249 missed lines across three controllers, all at exactly 0.00%**, because `forms.feature` is commented out (§0e). + +Mitigating factor that makes this cheaper than it looks: the *models* are well specified. `spec/cms/form_spec.rb`, `form_fields_spec.rb`, and `form_entry_spec.rb` all exist and pass, and `test/factories/factories.rb` has a `:form` factory. You are writing controller tests against known-good models. + +Aggravating factor: `Cms::FormField` and `Cms::FormEntry` have **no factories** — `form_entry_spec.rb` builds them inline via `Cms::FormEntry.for(form)`. Add factories first. + +| Rank | Target | Missed | Effort | Notes | +|---|---|---|---|---| +| 16 | `app/controllers/cms/form_entries_controller.rb` | 140 | 2 d | `submit` (23), `bulk_update` (18), `index` (14), `update` (9), `create` (9), `columns_for_index` (8), `save_entry_failure` (6). `submit` is the **public, guest-accessible** endpoint (`allow_guests_to [:submit]`) — highest-value single test in the repo. It also sends mail via `Cms::EmailMessage.create!`, so needs `ActionMailer` assertions. | +| 17 | `app/controllers/cms/form_fields_controller.rb` | 74 | 1 d | `create` (17), `update` (10), `insert_at` (9), `destroy` (6). All JSON/AJAX. `render json:` behaviour and `params[:form_field].delete(:form_id)` — **mutating `ActionController::Parameters` in place is not permitted the way it was in 4.2.** This code will break on upgrade. | +| 18 | `app/controllers/cms/forms_controller.rb` | 35 | 1 d | `new` (10) — unconventional: `new` writes to the database (`@block.save!`) to enable AJAX field association. `associate_form_fields` (8) does `params[:field_ids].split(" ")`. Two `before_filter`s to convert. Test the weirdness before changing it. | + +### Phase 3 — Remaining 0% controllers (~3 days, → 86.9%) + +Small, self-contained, and each one follows the existing `test/functional/cms/*_test.rb` pattern — `ActionController::TestCase` + `include Cms::ControllerTestHelper` (defined at `test/test_helper.rb:170`), with `create(:user)` / `create(:section)` factories for setup. Good parallel work for multiple people. + +| Rank | Target | Missed | Effort | Notes | +|---|---|---|---|---| +| 19 | `app/controllers/cms/page_route_options_controller.rb` | 58 | 1 d | `create` (10), `update` (9), `destroy` (7), `object_name` (6). Uses `update_attributes` — **removed in Rails 7.** Also metaprogrammed `resource`/`object_name`, plus two sibling controllers (#23) inherit from it. | +| 20 | `app/controllers/cms/portlet_controller.rb` | 21 | 4 h | `execute_handler` (16). **This is a security boundary** — it dispatches `@portlet.send(params[:handler])` and guards it with a `method_defined?` blacklist. It has never been tested. Write the "handler must not reach a private/inherited method" test regardless of coverage goals. | +| 21 | `app/controllers/cms/page_components_controller.rb` | 20 | 4 h | `update` (10), `new` (5). `respond_to :json` / `respond_with` — extracted from core into the `responders` gem. It works today only because `devise 4.9.4` pulls in `responders 2.4.1` transitively; nothing declares it. **Declare `responders` explicitly in the gemspec before the upgrade**, or this breaks the moment Devise's dependencies shift. | +| 22 | `app/controllers/cms/content_types_controller.rb` | 18 | 3 h | `index` (9). `format.js { render layout: false }`. Trivial once a `ContentType` exists. | +| 23 | `app/controllers/cms/toolbar_controller.rb` | 18 | 3 h | `index` (11). Reads five `params` keys and `Page#as_of_version`. Pure read path. | +| 24 | `app/controllers/cms/passwords_controller.rb` | 22 | 1 d | Subclasses `Devise::PasswordsController`. Only ~13 real lines and all four methods just `use_page_title` then `super`. **Effort is all in the Devise-in-an-engine test setup**, not the assertions. Consider deferring — low value, high friction. | +| 25 | `app/controllers/cms/user_controller.rb` | 8 | 30 min | One action, `render json: Cms::UserPresenter.new(current_user)`. Pair with #13. | +| 26 | `page_route_conditions_controller.rb` + `page_route_requirements_controller.rb` | 4 + 4 | 30 min | Two lines each; both inherit from #19. Free once #19 is done. | + +### Phase 4 — Partially-covered controllers (~5 days, → 89.0%) + +Each of these already has a test file. You are extending, not creating — which means the setup cost is paid and the effort is genuinely per-assertion. The named methods below are the untested ones. + +| Rank | Target | Missed | Effort | Untested methods | +|---|---|---|---|---| +| 27 | `content_block_controller.rb` (84%) | 30 | 1 d | `bulk_update` (11), `update` (4), `after_update_on_edit_conflict` (2), `after_update_on_error` (2). The **edit-conflict and error paths are exactly what silently changes** when `ActiveRecord` validation/callback ordering shifts. | +| 28 | `connectors_controller.rb` (38%) | 24 | 1 d | `destroy` (17), `create` (7). 17 missed lines in one `destroy` means a deep branch tree around connector ordering. | +| 29 | `section_nodes_controller.rb` (41%) | 20 | 1 d | `move_to_position` (9), `slow_index` (6), `repair_sitemap` (3). Drag-and-drop sitemap reordering; pairs with #4 and #5. | +| 30 | `resource_controller.rb` (70%) | 19 | 1 d | `create`/`update`/`destroy` (4 each). **Base class for most CMS controllers** — every line covered here is leverage on every subclass. | +| 31 | `inline_content_controller.rb` (29%) | 17 | 4 h | `update` (15). One method, 15 branches. Mercury/inline editing path. | +| 32 | `pages_controller.rb` (86%) | 13 | 4 h | `update` (3), `destroy` (3), `strip_visibility_params` (3). `strip_visibility_params` is **strong-parameters manipulation — a Rails 5 flashpoint.** | +| 33 | `users_controller.rb` (84%) | 11 | 4 h | `disable` (4), `enable` (2), `update_password` (1). | +| 34 | `attachments_controller.rb` (64%) | 9 | 4 h | `create` (5), `destroy` (3). File upload — `Rack::Test::UploadedFile` API changes. | +| 35 | `sections_controller.rb` (84%), `content_controller.rb` (92%), `links_controller.rb` (85%), `dynamic_views_controller.rb` (84%) | 9+8+7+7 | 1 d | Top-up work. `content_controller#preview` (4) is the notable one. | + +### Phase 5 — Helpers (~4 days, → 91.6%) + +Helpers are 57.75% — tied with controllers for worst — but the effort profile is completely different. `ActionView::TestCase` gives you the view context for free (see `test/unit/helpers/menu_helper_test.rb` for the pattern), and helper methods are mostly pure string/tag builders. **The catch: assertions on generated HTML are brittle**, and these helpers emit Bootstrap-2-era markup that a Rails 8 upgrade may well change. Assert on structure (`assert_select`), not on exact strings. + +| Rank | Target | Missed | Effort | Untested methods | +|---|---|---|---|---| +| 36 | `ui_elements_helper.rb` (51%) | 43 | 1 d | `delete_menu_button` (14), `publish_menu_button` (6), `versions_menu_button` (6), `edit_content_menu_button` (4), `view_content_menu_button` (4), `select_content_type_tag` (4). Six independent button builders — parallelizable, and `delete_menu_button` alone is 14 lines. Best single helper target. | +| 37 | `section_nodes_helper.rb` (30%) | 39 | 1 d | `icon_tag` (14), `closable_data` (5), `draggable_class?` (3), `guest_accessible_icon_tag` (3), `figure_out_target_section` (3). Lowest-coverage helper in the codebase. `figure_out_target_section` is real logic, not markup. | +| 38 | `application_helper.rb` (74%) | 28 | 1 d | `link_to_check_all` (5), `link_to_uncheck_all` (5), `searchable_sections` (4), `page_versions` (4), `select_per_page` (2). `test/unit/helpers/application_helper_test.rb` exists. | +| 39 | `content_block_helper.rb` (17%) | 25 | 4 h | `content_block_tr_tag` (17), `block_row_tag` (7). **Note the source comment: `block_row_tag` is marked "Delete once we confirm that content_block_tr_tag below works."** Resolve that question first — you may be able to delete 7 of these 25 lines instead of testing them. | +| 40 | `page_helper.rb` (75%) | 17 | 4 h | `render_portlet` (6), `container_has_block?` (5), `cms_toolbar` (2), `deprecated_set_page_title_usage` (2). | +| 41 | `path_helper.rb` (68%) | 14 | 4 h | `link_to_usages` (11), `link_to_addressable_content` (3). Route-helper generation — **engine route helpers are upgrade-sensitive.** | +| 42 | `template_support.rb` | 18 | 2 h | `self.included` (14). Not a helper — a controller mixin. Covered by including it into a test-only controller. Cheap. | +| 43 | `rendering_helper.rb` (84%), `form_tag_helper.rb` (73%) | 6 + 4 | 3 h | Top-up. | + +**Deliberately excluded from Phase 5:** the Devise shim helpers — `app/helpers/cms/sites/devise_shim_helper.rb` (31 counted, ~12 real), plus `app/helpers/login_portlet_helper.rb` (10) and `app/helpers/forgot_password_portlet_helper.rb` (9), both of which sit at the top of `app/helpers/`, outside the `cms/` namespace. These exist purely to fake a Devise mapping inside a portlet's view context; `devise_shim_helper.rb` contains a `main_app` method whose entire body is commented out. Testing them means reconstructing Devise's controller/mapping context by hand for ~25 real lines of shim. **Poor value — and they may not survive the upgrade anyway.** Revisit after Devise is upgraded. + +### Phase 6 — Long tail (~4 days, → 93.2%) + +| Rank | Target | Missed | Effort | Notes | +|---|---|---|---|---| +| 44 | `lib/cms/behaviors/attaching.rb` (83%) | 26 | 1 d | `validates_attachment_size` (9), `validates_attachment_content_type` (6), `validates_attachment_presence` (3). Three validation macros, zero coverage between them. `test/unit/behaviors/attaching_test.rb` exists. **Validation macro internals are a Rails 8 risk area.** Note before starting: `validates_attachment_presence` is **defined twice**, at lines 89 and 98 — the first is dead code silently overwritten by the second. Delete one, then test. | +| 45 | `app/portlets/list_portlet.rb` (36%) | 14 | 4 h | `render` (8), `view_as_full_path` (3). `test/unit/portlets/list_portlet_test.rb` exists. | +| 46 | `app/portlets/email_page_portlet.rb` (26%) | 14 | 4 h | `deliver` (9), `render` (4). Needs `ActionMailer::Base.deliveries` assertions; `test/unit/models/email_page_portlet_test.rb` exists. `features/portlets/email_friend_portlet.feature` also exists — check why it isn't reaching `deliver`. | +| 47 | `app/inputs/attachments_input.rb` (0%) | 14 | 4 h | `input` (12). SimpleForm custom input. `spec/inputs/name_input_spec.rb` is the pattern. | +| 48 | `lib/cms/authentication/controller.rb` (68%) | 11 | 4 h | `access_denied` (4), `logout_keeping_session!` (3), `logout_killing_session!` (2). **Session-reset semantics change across Rails versions** — worth more than 11 lines suggests. | +| 49 | `lib/cms/mobile_aware.rb` (73%) | 9 | 2 h | `print_request_info` (7) — a debug method. Consider deleting instead. | +| 50 | `app/inputs/cms_text_area_input.rb`, `file_picker_input.rb`, `name_input.rb`, `app/portlets/helpers/cms/list_portlet_helper.rb` | ~20 | 1 d | Small SimpleForm inputs. | +| 51 | `lib/cms/form_builder/deprecated_inputs.rb` (45%) | 12 | 3 h | Six methods, 2 missed lines each — a uniform deprecation-warning pattern. One shared test loop covers all six. | +| 52 | `content_rendering_support.rb` (86%), `archiving.rb` (80%), `content_block_form_builder.rb` (56%) | 8+6+7 | 1 d | Top-up. | + +### Phase 7 — In-process generator tests (~3 days) + +Only if §0c is decided in favour of testing rather than excluding. Use `Rails::Generators::TestCase`; `test_helper.rb` is already set up for it. + +Do this one regardless of the coverage decision: + +> `content_block_generator.rb:27` calls `File.exists?`, which was deprecated in Ruby 2.1 and **removed**. `alter_the_migration` (30 missed lines) is the single largest untested method in the codebase and does text surgery on generated migration files. Nothing in the measured suite touches either. + +`install_generator.rb` (16) and `browser_cms.rb` (12) are nearly free — mostly class body, covered by loading. + +--- + +## 4. What is not worth testing + +Being explicit about the ceiling, since the question was whether 100% is feasible. **It is not, and chasing it would waste weeks.** + +| Target | Missed | Why we stop | +|---|---|---| +| `lib/generators/.../demo.seeds.rb` | 249 | Generator template. Executed only inside a generated app, by design. Filter it. | +| `lib/cms/commands/actions.rb` | 33 | `run_bundle_install`, `run_bundle_update`, `generate_installation_script`, `find_custom_blocks` — shells out to Bundler and writes files to disk. `test/unit/lib/cms/commands_actions_test.rb` already covers the 39% that is testable. The rest belongs to the aruba `@cli` features and should stay there. | +| Devise shim helpers | ~50 counted / ~25 real | See Phase 5 note. Reconstructing Devise's mapping context by hand is more code than the shims contain. | +| `lib/templates/`, `lib/generators/**/templates/` | 34 | Same as `demo.seeds.rb`. | +| Error/rescue branches throughout | ~100 | `rescue` bodies that log and re-raise. Testable, but each one costs a mock and buys one line. | +| `print_request_info`, `log_update` | ~10 | Debug logging. | + +Realistic ceiling on the **filtered** denominator (~5,980 lines): **92–93%.** On the raw 6,743-line denominator as the report reads today: **~85%.** Anything past that is error branches, shell-outs, and Devise scaffolding. + +--- + +## 5. Recommended sequencing against the Rails 8 upgrade + +Coverage-per-effort ordering (Phases 1→6) and upgrade-risk ordering are not the same, and the difference matters. Three items are cheap *and* sit directly on the upgrade blast radius. **Pull them forward regardless of their rank:** + +1. **#17 `form_fields_controller.rb`** — mutates `ActionController::Parameters` in place (`params[:form_field].delete(:form_id)`). Will break. +2. **#19 `page_route_options_controller.rb`** — `update_attributes`, removed in Rails 7. Will break. +3. **#21 `page_components_controller.rb`** — `respond_with`, extracted from core. Will break. + +And two are cheap and sit on a security boundary rather than an upgrade one: + +4. **#20 `portlet_controller.rb#execute_handler`** — untested `send(params[:handler])` dispatch guard. +5. **#3 `cms_user.rb#able_to_view?`** — untested permission check. + +Suggested order: **Phase 0 → items 1–5 above → Phase 1 → Phase 2 → Phase 3 → begin the upgrade → Phases 4–6 as regression pressure demands.** + +Add a coverage floor to CI once Phase 0 lands so the number cannot regress silently: + +```ruby +SimpleCov.minimum_coverage 78 +``` + +Raise it at the end of each phase. Two caveats: `.travis.yml` is dead and there is no `.github/workflows/` in this repo, so the floor is only meaningful once CI exists to enforce it; and `simplecov` is pinned at **0.12.0** (2016), which has no branch coverage — so every figure in this document is *line* coverage only. Bumping SimpleCov to a version with `enable_coverage :branch` would be worth doing early, because branch coverage on the controllers is the number that actually predicts upgrade breakage, and it will read considerably lower than 72%. + +--- + +## Appendix — reproducing these numbers + +```bash +bundle exec rake test # runs units, spec, functionals, features; writes coverage/ +open coverage/index.html +``` + +Per-file and per-method figures in this document were extracted from `coverage/index.html` (the authoritative merged report) rather than from `coverage/.resultset.json`. The raw resultset stores per-suite arrays in which a file never loaded by a given suite appears with every line marked `0`; naively unioning the four suites overstates missed lines by roughly 3× on files like `content_block_controller.rb` (206 vs. the true 30). diff --git a/app/controllers/cms/base_controller.rb b/app/controllers/cms/base_controller.rb index 5d5d3ccf6..0a479d11f 100644 --- a/app/controllers/cms/base_controller.rb +++ b/app/controllers/cms/base_controller.rb @@ -1,8 +1,8 @@ module Cms class BaseController < Cms::ApplicationController - before_filter :redirect_to_cms_site + before_action :redirect_to_cms_site before_action :authenticate_cms_user! - before_filter :cms_access_required + before_action :cms_access_required layout 'cms/application' diff --git a/app/controllers/cms/connectors_controller.rb b/app/controllers/cms/connectors_controller.rb index eb93836ad..8eb7240e2 100644 --- a/app/controllers/cms/connectors_controller.rb +++ b/app/controllers/cms/connectors_controller.rb @@ -1,7 +1,7 @@ module Cms class ConnectorsController < Cms::BaseController - before_filter :load_page, :only => [:new, :create] + before_action :load_page, :only => [:new, :create] def new @block_type = ContentType.find_by_key(params[:block_type] || session[:last_block_type] || 'html_block') diff --git a/app/controllers/cms/content_block_controller.rb b/app/controllers/cms/content_block_controller.rb index b30702d50..6d23ddf54 100644 --- a/app/controllers/cms/content_block_controller.rb +++ b/app/controllers/cms/content_block_controller.rb @@ -135,7 +135,7 @@ def versions if model_class.versioned? load_block else - render :text => "Not Implemented", :status => :not_implemented + render :plain => "Not Implemented", :status => :not_implemented end end @@ -264,7 +264,7 @@ def after_update_on_error # update related methods def update_block load_block - @block.update_attributes(model_params()) + @block.update(model_params()) end # Returns the parameters for the block to be saved. diff --git a/app/controllers/cms/content_controller.rb b/app/controllers/cms/content_controller.rb index 95d20fd29..2ce987861 100644 --- a/app/controllers/cms/content_controller.rb +++ b/app/controllers/cms/content_controller.rb @@ -8,15 +8,19 @@ class ContentController < Cms::ApplicationController include Cms::MobileAware helper MobileHelper - skip_before_filter :redirect_to_cms_site - before_filter :redirect_non_cms_users_to_public_site, :only => [:show, :show_page_route] - before_filter :construct_path, :only => [:show] - before_filter :construct_path_from_route, :only => [:show_page_route] - before_filter :try_to_redirect, :only => [:show] - before_filter :try_to_stream_file, :only => [:show] - before_filter :load_page, :only => [:show, :show_page_route] - before_filter :check_access_to_page, :except => [:edit, :preview] - before_filter :select_cache_directory + # There is deliberately no skip of :redirect_to_cms_site here. That callback is + # registered only on Cms::BaseController (base_controller.rb:3), which is a + # sibling of this class, not an ancestor -- so there has never been anything to + # skip. 4.2's skip_callback silently deleted nil; 5.0 raises ArgumentError. + + before_action :redirect_non_cms_users_to_public_site, :only => [:show, :show_page_route] + before_action :construct_path, :only => [:show] + before_action :construct_path_from_route, :only => [:show_page_route] + before_action :try_to_redirect, :only => [:show] + before_action :try_to_stream_file, :only => [:show] + before_action :load_page, :only => [:show, :show_page_route] + before_action :check_access_to_page, :except => [:edit, :preview] + before_action :select_cache_directory self.responder = Cms::ContentResponder diff --git a/app/controllers/cms/dynamic_views_controller.rb b/app/controllers/cms/dynamic_views_controller.rb index 90aab2c69..f91fa648f 100644 --- a/app/controllers/cms/dynamic_views_controller.rb +++ b/app/controllers/cms/dynamic_views_controller.rb @@ -4,7 +4,7 @@ class DynamicViewsController < Cms::BaseController include Cms::AdminTab check_permissions :administrate - before_filter :load_view, :only => [:show, :edit, :update, :destroy] + before_action :load_view, :only => [:show, :edit, :update, :destroy] helper_method :dynamic_view_type diff --git a/app/controllers/cms/form_fields_controller.rb b/app/controllers/cms/form_fields_controller.rb index f61e68789..3c88f41ba 100644 --- a/app/controllers/cms/form_fields_controller.rb +++ b/app/controllers/cms/form_fields_controller.rb @@ -40,7 +40,7 @@ def update include_edit_path_in_json(field) render json: field else - render text: "Fail", status: 500 + render plain: "Fail", status: 500 end end diff --git a/app/controllers/cms/forms_controller.rb b/app/controllers/cms/forms_controller.rb index 797be0139..6aaf3af9c 100644 --- a/app/controllers/cms/forms_controller.rb +++ b/app/controllers/cms/forms_controller.rb @@ -1,7 +1,7 @@ class Cms::FormsController < Cms::ContentBlockController - before_filter :associate_form_fields, only: [:create, :update] - before_filter :strip_new_entry_params, only: [:create, :update] + before_action :associate_form_fields, only: [:create, :update] + before_action :strip_new_entry_params, only: [:create, :update] helper do # For new forms, if the user doesn't complete and save them, we need to delete them from the database. diff --git a/app/controllers/cms/inline_content_controller.rb b/app/controllers/cms/inline_content_controller.rb index 3527bd880..467c39cbf 100644 --- a/app/controllers/cms/inline_content_controller.rb +++ b/app/controllers/cms/inline_content_controller.rb @@ -4,7 +4,7 @@ class InlineContentController < Cms::BaseController def update content_block = Content.find_draft(params[:content_name], params[:id]) - content_block.update_attributes(filtered_content(content_block)) + content_block.update(filtered_content(content_block)) @page = Page.find_draft(params[:page_id].to_i) if (!@page.live?) page_status = "draft" diff --git a/app/controllers/cms/links_controller.rb b/app/controllers/cms/links_controller.rb index ea668b59b..2693698e5 100644 --- a/app/controllers/cms/links_controller.rb +++ b/app/controllers/cms/links_controller.rb @@ -1,9 +1,9 @@ module Cms class LinksController < Cms::BaseController - before_filter :load_section, :only => [:new, :create, :move_to] - before_filter :load_link, :only => [:destroy, :update] - before_filter :load_draft_link, :only => [:edit] + before_action :load_section, :only => [:new, :create, :move_to] + before_action :load_link, :only => [:destroy, :update] + before_action :load_draft_link, :only => [:edit] include Cms::PublishWorkflow @@ -35,7 +35,7 @@ def create end def update - if @link.update_attributes(link_params) + if @link.update(link_params) flash[:notice] = "Link '#{@link.name}' was updated" redirect_to @link.section else diff --git a/app/controllers/cms/page_route_options_controller.rb b/app/controllers/cms/page_route_options_controller.rb index 6ff5f9c7e..ccd5f7f55 100644 --- a/app/controllers/cms/page_route_options_controller.rb +++ b/app/controllers/cms/page_route_options_controller.rb @@ -1,8 +1,8 @@ module Cms class PageRouteOptionsController < Cms::BaseController - before_filter :load_page_route - before_filter :load_model, :only => [:edit, :update, :destroy] + before_action :load_page_route + before_action :load_model, :only => [:edit, :update, :destroy] def new @model = resource.new @@ -19,7 +19,7 @@ def create end def update - if @model.update_attributes(params[object_name]) + if @model.update(params[object_name]) flash[:notice] = "#{object_name.titleize} updated" redirect_to cms_page_route_url(@page_route) else diff --git a/app/controllers/cms/page_routes_controller.rb b/app/controllers/cms/page_routes_controller.rb index f394bfdfb..9001387db 100644 --- a/app/controllers/cms/page_routes_controller.rb +++ b/app/controllers/cms/page_routes_controller.rb @@ -1,7 +1,7 @@ module Cms class PageRoutesController < Cms::BaseController - before_filter :load_page_route, :only => [:edit, :update, :destroy] + before_action :load_page_route, :only => [:edit, :update, :destroy] def index @page_routes = PageRoute.paginate(:page => params[:page]).order("name") diff --git a/app/controllers/cms/pages_controller.rb b/app/controllers/cms/pages_controller.rb index 03ba4f7b5..2349ab8fc 100644 --- a/app/controllers/cms/pages_controller.rb +++ b/app/controllers/cms/pages_controller.rb @@ -3,10 +3,10 @@ class PagesController < Cms::BaseController helper Cms::RenderingHelper - before_filter :load_section, :only => [:new, :create] - before_filter :load_page, :only => [:versions, :version, :revert_to, :destroy] - before_filter :load_draft_page, :only => [:edit, :update] - before_filter :hide_toolbar, :only => [:new, :create] + before_action :load_section, :only => [:new, :create] + before_action :load_page, :only => [:versions, :version, :revert_to, :destroy] + before_action :load_draft_page, :only => [:edit, :update] + before_action :hide_toolbar, :only => [:new, :create] before_action :strip_visibility_params, :only => [:create, :update] include Cms::PublishWorkflow diff --git a/app/controllers/cms/portlet_controller.rb b/app/controllers/cms/portlet_controller.rb index 150ddc56b..16a2d6246 100644 --- a/app/controllers/cms/portlet_controller.rb +++ b/app/controllers/cms/portlet_controller.rb @@ -1,7 +1,10 @@ module Cms class PortletController < Cms::ApplicationController - skip_before_filter :redirect_to_cms_site + # There is deliberately no skip of :redirect_to_cms_site here. That callback is + # registered only on Cms::BaseController (base_controller.rb:3), which is a + # sibling of this class, not an ancestor -- so there has never been anything to + # skip. 4.2's skip_callback silently deleted nil; 5.0 raises ArgumentError. def execute_handler @portlet = Portlet.find(params[:id]) diff --git a/app/controllers/cms/section_nodes_controller.rb b/app/controllers/cms/section_nodes_controller.rb index 3c7e1e522..124cd35a1 100644 --- a/app/controllers/cms/section_nodes_controller.rb +++ b/app/controllers/cms/section_nodes_controller.rb @@ -70,9 +70,16 @@ def repair_sitemap # Retrieves all siblings that will need updating on success. This includes all siblings # from the node's previous location as well as siblings from the node's new/target location. Also # includes the node itself. + # TODO(Phase 4): the dedupe below is on the wrong side of the parenthesis. `.distinct` + # binds to the *second* relation only, so it becomes SELECT DISTINCT within that half + # and does nothing about duplicates *between* the two halves -- which is the only kind + # this method can plausibly produce, since a node can be a sibling in both the previous + # and the target parent. The code reads like a union dedupe and is not one. Hoisting it + # outside the parens would fix that, and is a behaviour change rather than a rename, so + # it belongs with the characterization test Phase 4 already owns for move_to_position. def nodes_to_update_on_success(previous_parent, target_parent) (previous_parent.children.not_of_type(Cms::Section::HIDDEN_NODE_TYPES) + - target_parent.children.not_of_type(Cms::Section::HIDDEN_NODE_TYPES).uniq).map { |n| [n.id, n.position, n.depth] } + target_parent.children.not_of_type(Cms::Section::HIDDEN_NODE_TYPES).distinct).map { |n| [n.id, n.position, n.depth] } end end end diff --git a/app/controllers/cms/sections_controller.rb b/app/controllers/cms/sections_controller.rb index f36f34cf7..44f2b0881 100644 --- a/app/controllers/cms/sections_controller.rb +++ b/app/controllers/cms/sections_controller.rb @@ -1,8 +1,8 @@ module Cms class SectionsController < Cms::BaseController - before_filter :load_parent, :only => [:new, :create] - before_filter :load_section, :only => [:edit, :update, :destroy, :move] + before_action :load_parent, :only => [:new, :create] + before_action :load_section, :only => [:edit, :update, :destroy, :move] helper_method :public_groups helper_method :cms_groups diff --git a/app/controllers/cms/sessions_controller.rb b/app/controllers/cms/sessions_controller.rb index e2d1e95b5..3171b302f 100644 --- a/app/controllers/cms/sessions_controller.rb +++ b/app/controllers/cms/sessions_controller.rb @@ -2,7 +2,7 @@ module Cms # Handles the login/logout function of the site. class SessionsController < Devise::SessionsController include Cms::AdminController - before_filter :redirect_to_cms_site, :only => [:new] + before_action :redirect_to_cms_site, :only => [:new] layout 'cms/application' diff --git a/app/controllers/cms/tasks_controller.rb b/app/controllers/cms/tasks_controller.rb index ae41a922f..e3068d594 100644 --- a/app/controllers/cms/tasks_controller.rb +++ b/app/controllers/cms/tasks_controller.rb @@ -1,7 +1,7 @@ module Cms class TasksController < Cms::BaseController - before_filter :load_page, :only => [:new, :create] + before_action :load_page, :only => [:new, :create] def new @task = @page.tasks.build(:assigned_by => current_user) diff --git a/app/controllers/cms/users_controller.rb b/app/controllers/cms/users_controller.rb index 80adee0f3..eb5463304 100644 --- a/app/controllers/cms/users_controller.rb +++ b/app/controllers/cms/users_controller.rb @@ -3,8 +3,8 @@ class UsersController < Cms::ResourceController include Cms::AdminTab check_permissions :administrate, :except => [:change_password, :update_password] - before_filter :only_self_or_administrator, :only => [:change_password, :update_password] - after_filter :update_flash, :only => [:update] + before_action :only_self_or_administrator, :only => [:change_password, :update_password] + after_action :update_flash, :only => [:update] def index @@ -32,7 +32,13 @@ def index per_page = params[:per_page] || 10 page_num = params[:page] ? params[:page].to_i : 1 - @users = PersistentUser.where(conditions).paginate(page: page_num, per_page: per_page).includes(:user_group_memberships).references(:user_group_memberships).order("first_name, last_name, email") + # All three filters above are optional, so `query` is empty whenever show_expired is + # set with no keyword and no group -- which makes `conditions` == [""]. Rails 4.2 + # dropped an empty condition string and emitted no WHERE clause at all; 5.0 emits a + # literal empty one, producing `WHERE ORDER BY ...` and a PG::SyntaxError. + # Skipping the call reproduces 4.2's SQL exactly on both versions. + scope = query.empty? ? PersistentUser.all : PersistentUser.where(conditions) + @users = scope.paginate(page: page_num, per_page: per_page).includes(:user_group_memberships).references(:user_group_memberships).order("first_name, last_name, email") end def new diff --git a/app/controllers/tests/pretend_controller.rb b/app/controllers/tests/pretend_controller.rb index 4839efad9..4a61576f9 100644 --- a/app/controllers/tests/pretend_controller.rb +++ b/app/controllers/tests/pretend_controller.rb @@ -11,11 +11,15 @@ class Tests::PretendController < ApplicationController RESTRICTED_H1 = "Restricted" def restricted - render :text =>"

#{RESTRICTED_H1}

You can see this restricted page." + # html:, not plain: -- these two actions are cucumber-covered + # (acts_as_content_page.feature:25 and :49) and emit markup, so plain: would change + # the Content-Type of a passing feature from text/html to text/plain. .html_safe is + # load-bearing: render html: escapes its argument otherwise. + render :html => "

#{RESTRICTED_H1}

You can see this restricted page.".html_safe end def open - render :text =>"

Open Page

You can see this public page." + render :html => "

Open Page

You can see this public page.".html_safe end def error diff --git a/app/models/cms/attachment.rb b/app/models/cms/attachment.rb index 2ba38e286..2c2d12d7b 100644 --- a/app/models/cms/attachment.rb +++ b/app/models/cms/attachment.rb @@ -16,7 +16,7 @@ class Attachment < ActiveRecord::Base before_save :set_section, :sanitized_file_path_and_name before_create :setup_attachment - belongs_to :attachable, :polymorphic => true + belongs_to :attachable, :polymorphic => true, :required => false extend DefaultAccessible diff --git a/app/models/cms/category.rb b/app/models/cms/category.rb index 929a6ecea..b5efd2a38 100644 --- a/app/models/cms/category.rb +++ b/app/models/cms/category.rb @@ -1,7 +1,7 @@ module Cms class Category < ActiveRecord::Base belongs_to :category_type, :class_name => 'Cms::CategoryType' - belongs_to :parent, :class_name => 'Cms::Category' + belongs_to :parent, :class_name => 'Cms::Category', :required => false has_many :children, :class_name => 'Cms::Category', :foreign_key => "parent_id" is_searchable has_content_type :module => :categorization diff --git a/app/models/cms/email_message.rb b/app/models/cms/email_message.rb index 5b909af7b..4c1195b79 100644 --- a/app/models/cms/email_message.rb +++ b/app/models/cms/email_message.rb @@ -55,8 +55,8 @@ def deliver_now def deliver! return false if delivered? self.sender = self.class.mailbot_address if self.sender.blank? - Cms::EmailMessageMailer.email_message(self).deliver - update_attributes(:delivered_at => Time.now) + Cms::EmailMessageMailer.email_message(self).deliver_now + update(:delivered_at => Time.now) end end diff --git a/app/models/cms/form_entry.rb b/app/models/cms/form_entry.rb index c66ba19e7..8869f0d02 100644 --- a/app/models/cms/form_entry.rb +++ b/app/models/cms/form_entry.rb @@ -2,7 +2,7 @@ module Cms class FormEntry < ActiveRecord::Base store :data_columns - belongs_to :form, class_name: 'Cms::Form' + belongs_to :form, class_name: 'Cms::Form', required: false after_initialize :add_field_accessors diff --git a/app/models/cms/form_field.rb b/app/models/cms/form_field.rb index 393f28d11..2b2dc7683 100644 --- a/app/models/cms/form_field.rb +++ b/app/models/cms/form_field.rb @@ -2,7 +2,7 @@ module Cms class FormField < ActiveRecord::Base extend DefaultAccessible - belongs_to :form + belongs_to :form, required: false acts_as_list scope: :form attr_accessor :edit_path, :delete_path diff --git a/app/models/cms/group.rb b/app/models/cms/group.rb index f60a40e2b..da375e73b 100644 --- a/app/models/cms/group.rb +++ b/app/models/cms/group.rb @@ -15,7 +15,7 @@ class Group < ActiveRecord::Base has_many :group_sections, :class_name => 'Cms::GroupSection' has_many :sections, :through => :group_sections, :class_name => 'Cms::Section' - belongs_to :group_type, :class_name => 'Cms::GroupType' + belongs_to :group_type, :class_name => 'Cms::GroupType', :required => false extend Cms::DefaultAccessible diff --git a/app/models/cms/group_permission.rb b/app/models/cms/group_permission.rb index 1116375dc..0c684f465 100644 --- a/app/models/cms/group_permission.rb +++ b/app/models/cms/group_permission.rb @@ -3,8 +3,8 @@ class GroupPermission < ActiveRecord::Base extend DefaultAccessible - belongs_to :group, :class_name => 'Cms::Group' - belongs_to :permission, :class_name => 'Cms::Permission' + belongs_to :group, :class_name => 'Cms::Group', :required => false + belongs_to :permission, :class_name => 'Cms::Permission', :required => false validates_uniqueness_of :permission_id, :scope => :group_id diff --git a/app/models/cms/group_section.rb b/app/models/cms/group_section.rb index 2e346903f..06a13c628 100644 --- a/app/models/cms/group_section.rb +++ b/app/models/cms/group_section.rb @@ -3,7 +3,7 @@ class GroupSection < ActiveRecord::Base extend DefaultAccessible - belongs_to :group, :class_name => 'Cms::Group' - belongs_to :section, :class_name => 'Cms::Section' + belongs_to :group, :class_name => 'Cms::Group', :required => false + belongs_to :section, :class_name => 'Cms::Section', :required => false end end \ No newline at end of file diff --git a/app/models/cms/group_type_permission.rb b/app/models/cms/group_type_permission.rb index 38ec2b3a8..e55b7eea4 100644 --- a/app/models/cms/group_type_permission.rb +++ b/app/models/cms/group_type_permission.rb @@ -1,7 +1,7 @@ module Cms class GroupTypePermission < ActiveRecord::Base - belongs_to :group_type, :class_name => 'Cms::GroupType' - belongs_to :permission, :class_name => 'Cms::Permission' + belongs_to :group_type, :class_name => 'Cms::GroupType', :required => false + belongs_to :permission, :class_name => 'Cms::Permission', :required => false extend DefaultAccessible end diff --git a/app/models/cms/guest_user.rb b/app/models/cms/guest_user.rb index 1c7553bf5..2b837dc1a 100644 --- a/app/models/cms/guest_user.rb +++ b/app/models/cms/guest_user.rb @@ -40,7 +40,25 @@ def groups [group] end - #You shouldn't be able to save a guest user + # You shouldn't be able to save a guest user. + # + # NOTE: this guard is incomplete, and deliberately left that way for now. + # `update_attributes` is an *alias*, not the method -- persistence.rb reads + # `def update(attributes)` … `alias update_attributes update` on both 4.2 (:247/:256) + # and 5.0 (:270/:279). Overriding the alias name in a subclass leaves `update` bound + # to the original implementation, so `guest.update(...)` reaches + # ActiveRecord::Persistence#update and walks straight past this guard. The write still + # fails, but at `save` below rather than here, and only by luck. + # + # Closing it means renaming the definition to `update` and aliasing the old name -- + # a behaviour change, in a phase whose contract is that there are none. Phase 3 chose + # to leave the hole in place and record it instead. The characterization test is + # test/unit/models/user_test.rb, GuestUserTest, marked skipped. + # + # `save(perform_validation=true)` is a second, separate signature-override bug of the + # kind Phase 2 fixed on create_or_update: 5.0's save is `save(*args)`, so a keyword + # hash lands harmlessly in the positional slot. It is not breaking today and will not + # survive later hops. def update_attribute(name, value) false end diff --git a/app/models/cms/page.rb b/app/models/cms/page.rb index ad1498764..d4495ab75 100644 --- a/app/models/cms/page.rb +++ b/app/models/cms/page.rb @@ -217,7 +217,7 @@ def add_content(connectable, container=:main) # (connectable.class.publishable? ? connectable.published? : true) should_publish = false # binding.pry - update_attributes( + update( :version_comment => "#{connectable} was added to the '#{container}' container", :publish_on_save => should_publish ) @@ -240,7 +240,7 @@ def move_connector(connector, direction) raise "Connector is nil" unless connector raise "Direction is nil" unless direction orientation = direction[/_/] ? "#{direction.sub('_', ' the ')} of" : "#{direction} within" - update_attributes(:version_comment => "#{connector.connectable} was moved #{orientation} the '#{connector.container}' container", :publish_on_save => false) + update(:version_comment => "#{connector.connectable} was moved #{orientation} the '#{connector.container}' container", :publish_on_save => false) connectors.for_page_version(draft.version).like(connector).first.send("move_#{direction}") end end @@ -255,7 +255,7 @@ def move_connector(connector, direction) def remove_connector(connector) transaction do raise "Connector is nil" unless connector - update_attributes(version_comment: "#{connector.connectable} was removed from the '#{connector.container}' container", publish_on_save: false) + update(version_comment: "#{connector.connectable} was removed from the '#{connector.container}' container", publish_on_save: false) #The logic of this is to go ahead and let the container get copied forward, then delete the new connector if new_connector = connectors.for_page_version(draft.version).like(connector).first diff --git a/app/models/cms/page_component.rb b/app/models/cms/page_component.rb index 77e59ebaf..9c74f640a 100644 --- a/app/models/cms/page_component.rb +++ b/app/models/cms/page_component.rb @@ -7,7 +7,7 @@ class PageComponent attr_accessor :page_id, :page_title, :blocks def initialize(page_id, params) - params = HashWithIndifferentAccess.new(params) + params = ActiveSupport::HashWithIndifferentAccess.new(params) self.page_title = params[:page_title] self.blocks = params[:blocks] ? params[:blocks] : [] self.page_id = page_id @@ -25,7 +25,7 @@ def save content_ids.each do |block_id| block = content_block_class.constantize.find(block_id) assignment_hash = convert_mercury_params_to_assignment_hash(block_id, block_type) - block.update_attributes(assignment_hash) + block.update(assignment_hash) end end @page.save diff --git a/app/models/cms/page_route_option.rb b/app/models/cms/page_route_option.rb index f05fd714b..c5e21196b 100644 --- a/app/models/cms/page_route_option.rb +++ b/app/models/cms/page_route_option.rb @@ -1,6 +1,6 @@ module Cms class PageRouteOption < ActiveRecord::Base - belongs_to :page_route, :class_name => 'Cms::PageRoute' + belongs_to :page_route, :class_name => 'Cms::PageRoute', :required => false extend DefaultAccessible end diff --git a/app/models/cms/portlet.rb b/app/models/cms/portlet.rb index 03057599c..7684871a3 100644 --- a/app/models/cms/portlet.rb +++ b/app/models/cms/portlet.rb @@ -225,7 +225,7 @@ def store_errors_in_flash(errors) end def store_hash_in_flash(key, hash) - flash[key] = hash.inject(HashWithIndifferentAccess.new) do |p, (k, v)| + flash[key] = hash.inject(ActiveSupport::HashWithIndifferentAccess.new) do |p, (k, v)| unless StringIO === v || Tempfile === v p[k.to_sym] = v end diff --git a/app/models/cms/section_node.rb b/app/models/cms/section_node.rb index 12596ec67..21bb28a11 100644 --- a/app/models/cms/section_node.rb +++ b/app/models/cms/section_node.rb @@ -21,7 +21,7 @@ def section=(new_section) end # The item this node links to - belongs_to :node, :polymorphic => :true, :inverse_of => :section_node + belongs_to :node, :polymorphic => :true, :inverse_of => :section_node, :required => false acts_as_list # For acts_as_list. Specifies that position should be unique within a section. diff --git a/app/models/cms/tagging.rb b/app/models/cms/tagging.rb index 7003ed636..0bf209990 100644 --- a/app/models/cms/tagging.rb +++ b/app/models/cms/tagging.rb @@ -1,7 +1,7 @@ module Cms class Tagging < ActiveRecord::Base - belongs_to :tag, :class_name => 'Cms::Tag' - belongs_to :taggable, :polymorphic => true, :class_name => 'Cms::Taggable', :foreign_type => 'taggable_type' + belongs_to :tag, :class_name => 'Cms::Tag', :required => false + belongs_to :taggable, :polymorphic => true, :class_name => 'Cms::Taggable', :foreign_type => 'taggable_type', :required => false extend DefaultAccessible #attr_accessible :tag, :taggable diff --git a/app/models/cms/task.rb b/app/models/cms/task.rb index 796ddecb2..087a1da0e 100644 --- a/app/models/cms/task.rb +++ b/app/models/cms/task.rb @@ -31,7 +31,7 @@ def self.other_than(t) validate :assigned_to_is_able_to_edit_or_publish_content def mark_as_complete! - update_attributes(:completed_at => Time.now) + update(:completed_at => Time.now) end def completed? diff --git a/app/models/cms/user_group_membership.rb b/app/models/cms/user_group_membership.rb index a297e22e7..602625600 100644 --- a/app/models/cms/user_group_membership.rb +++ b/app/models/cms/user_group_membership.rb @@ -3,7 +3,7 @@ class UserGroupMembership < ActiveRecord::Base extend Cms::DefaultAccessible - belongs_to :group, :class_name => 'Cms::Group' - belongs_to :user, :class_name => 'Cms::PersistentUser' + belongs_to :group, :class_name => 'Cms::Group', :required => false + belongs_to :user, :class_name => 'Cms::PersistentUser', :required => false end end \ No newline at end of file diff --git a/app/portlets/deprecated_placeholder.rb b/app/portlets/deprecated_placeholder.rb index 88a713832..dccd349ba 100644 --- a/app/portlets/deprecated_placeholder.rb +++ b/app/portlets/deprecated_placeholder.rb @@ -1,3 +1,8 @@ +# Do not delete. This is a tombstone, not dead code: db/migrate/20130327184912_browsercms400.rb:76 +# runs `UPDATE cms_portlets SET type = 'DeprecatedPlaceholder' WHERE type = 'ResetPasswordPortlet'`, +# so every database that has ever run browsercms400 holds rows whose type column names this +# class. Removing it turns each of them into ActiveRecord::SubclassNotFound on load. +# # A portlet type that can be used to deprecate and remove old portlets. # During migrations, change existing portlet types with this and remove the old classes. # diff --git a/app/portlets/list_portlet.rb b/app/portlets/list_portlet.rb index 9e0609922..d16dcdeaa 100644 --- a/app/portlets/list_portlet.rb +++ b/app/portlets/list_portlet.rb @@ -19,7 +19,7 @@ def render # This is far less flexible than prepending additional view paths, but it suffices for now. def view_as_full_path - if File.exists?(expected_view_path()) + if File.exist?(expected_view_path()) "portlets/list/#{self.name.parameterize('_')}/_#{self.view_as}" else "portlets/list/_#{self.view_as}" diff --git a/browsercms.gemspec b/browsercms.gemspec index db34a54da..f0162e191 100644 --- a/browsercms.gemspec +++ b/browsercms.gemspec @@ -1,5 +1,17 @@ require File.dirname(__FILE__) + "/lib/cms/version.rb" +# Dual-boot support for the Rails upgrade (docs/rails-upgrade/). +# +# The Gemfile's `next?` helper is not in scope here: Bundler evaluates this file +# in a Gem::Specification context that has never heard of it. So key off +# BUNDLE_GEMFILE, which Bundler sets to .../Gemfile.next for the next bundle. +# +# This deliberately defaults to the 4.2 branch, so `gem build browsercms.gemspec` +# with no bundler environment produces exactly what it produced before the +# upgrade started. Once Rails 5.0 is actually supported (Phase 5), replace this +# with a plain version range and delete the conditional. +NEXT_BOOT = ENV["BUNDLE_GEMFILE"].to_s.end_with?("Gemfile.next") + Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY s.name = "browsercms" @@ -30,21 +42,45 @@ Gem::Specification.new do |s| s.executables = ["bcms", "browsercms"] - s.add_dependency("rails", "~> 4.2.0") + s.add_dependency("rails", NEXT_BOOT ? "~> 5.0.0" : "~> 4.2.0") s.add_dependency("devise", "~> 4.0") s.add_dependency("sass-rails") s.add_dependency("bootstrap-sass") s.add_dependency("compass-rails") s.add_dependency("ancestry", "~> 3.0.0") - s.add_dependency("ckeditor_rails", "~> 4.3.0") + # 4.3.4 dispatches its Railtie on `case ::Rails.version` and has no Rails 5 branch, so + # under 5.0 the gem defines no Rails::Engine at all, its lib/assets never joins the + # asset load path, and `//= require ckeditor-jquery` (bcms/ckeditor.js:5) cannot + # resolve -- which takes down every page rendering the CMS layout. Measured: 131 of 131 + # cucumber failures and 28 of 30 functional errors on Gemfile.next. + # + # 4.5.10 is the first release whose `when` clause reads /^[45]/ (verified against 4.4.8, + # 4.5.1, 4.5.2 and 4.5.3, all of which still read /^4/), and it is the last before 4.16 + # replaces the default `moono` skin with `moono-lisa`. This gem's version IS CKEditor's + # version, so this is an editor upgrade wearing a dependency bump's clothes -- and with + # zero @javascript scenarios, no test here can see a WYSIWYG regression. The smallest + # jump that loads is therefore the right one; widening both bundles to a modern editor + # belongs with the 5.0 bump, not here. See docs/rails-upgrade/phase-3-implementation-plan.md D6. + s.add_dependency("ckeditor_rails", NEXT_BOOT ? "~> 4.5.10" : "~> 4.3.0") s.add_dependency("underscore-rails", "~> 1.4") - s.add_dependency("jquery-rails", "~> 3.1") + # jquery-rails 3.x caps railties < 5.0. + s.add_dependency("jquery-rails", NEXT_BOOT ? "~> 4.0" : "~> 3.1") s.add_dependency("jquery-ui-rails", "~> 4.1") s.add_dependency("paperclip", "~> 5.0") s.add_dependency("panoramic") s.add_dependency("will_paginate", "3.3.1") s.add_dependency("actionpack-page_caching", "~>1.0") - s.add_dependency("simple_form", "~> 3.1.0") + # simple_form 3.1 caps actionpack/activemodel ~> 4.0. The custom inputs under + # app/inputs/ ride on its API, so expect real work here in Phase 2. + s.add_dependency("simple_form", NEXT_BOOT ? "~> 3.5" : "~> 3.1.0") + # Currently reaching us transitively through devise. Six sites depend on it directly -- + # respond_with at content_controller.rb:79 and page_components_controller.rb:14/:16, and + # class-level respond_to at content_controller.rb:3, inline_content_controller.rb:3 and + # page_components_controller.rb:4 -- and they should not depend on another gem's + # dependency graph. Deliberately unconstrained: the two locks resolve 2.4.1 (4.2) and + # 3.0.1 (5.0), responders 3.0 requires railties >= 5.0, so any constraint tight enough + # to be useful would exclude one bundle. + s.add_dependency("responders") s.add_dependency("bigdecimal") # Required only for bcms-upgrade s.add_dependency "term-ansicolor" diff --git a/docs/rails-upgrade/README.md b/docs/rails-upgrade/README.md new file mode 100644 index 000000000..820c68ae0 --- /dev/null +++ b/docs/rails-upgrade/README.md @@ -0,0 +1,84 @@ +# BrowserCMS Rails Upgrade — Phase Plan + +**Target:** Rails 4.2.11.3 → Rails 8.x, sequentially, one minor version at a time. +**Scope of these files:** the pre-bump work and the **first hop only (4.2 → 5.0)**. Hops 5.1 through 8.0 are handled by repeating [Phase 6](phase-6-subsequent-hops.md). +**Repo:** `browsercms` (the engine). The consuming `cms` app has its own blockers; they are noted where they gate this work but are not planned here. + +--- + +## How to read a phase file + +Every file has the same five sections, in this order: + +| Section | What it answers | +|---|---| +| **Goal** | One sentence. If you read nothing else, read this. | +| **Why this phase exists** | The specific risk it retires. | +| **Work items** | The concrete changes, with counts and `file:line` references. | +| **Exit criteria** | A checklist where **every item is objectively verifiable** — most have a command next to them. This is how you judge whether the goal was reached. | +| **Explicitly not in this phase** | Deferred work, so a reviewer can tell a gap from an oversight. | + +**A phase is complete when every exit criterion passes — not when the work items are done.** Work items are the plan; exit criteria are the contract. If a work item turns out to be unnecessary, that's fine. If an exit criterion can't be met, the phase isn't finished. + +--- + +## The phases + +| # | Phase | Goal in brief | Blocking? | Status | +|---|---|---|---|---| +| **0** | [Baseline and CI](phase-0-baseline-and-ci.md) | Know the true pass rate and get a green button that runs on every push | 🔴 **Yes** — nothing else can start | ✅ **Done** — [plan](phase-0-implementation-plan.md) · [results](phase-0-baseline.md) | +| **1** | [Gem compatibility and dual-boot](phase-1-gem-compatibility-and-dual-boot.md) | Find out which gems actually block Rails 5, and be able to boot both versions | 🔴 **Yes** — its output scopes Phase 2 | ✅ **Done** — [plan](phase-1-implementation-plan.md) · [results](phase-1-gem-report.md) | +| **2** | [Harness migration](phase-2-harness-migration.md) | Make the test suite capable of running on Rails 5, while still on 4.2 | 🔴 **Yes** — the suite cannot boot on Rails 5 today | ⚠️ **Done, 11 of 12 criteria** — [plan](phase-2-implementation-plan.md) · [results](phase-2-harness-report.md) | +| **3** | [Backwards-compatible code fixes](phase-3-backwards-compatible-fixes.md) | Land ~96 mechanical changes that work on 4.2 *and* 5.0+, shrinking the bump diff | 🟡 Strongly recommended — but it owns the five defects keeping CI red | 📋 **Planned** — [plan](phase-3-implementation-plan.md) | +| **4** | [Characterization tests](phase-4-characterization-tests.md) | Pin the behaviour that Rails 5 changes *silently*, before it can drift | 🔴 **Yes** for the four 5.0-specific items | — | +| **5** | [The 5.0 bump](phase-5-the-5.0-bump.md) | Rails 5.0 green, deployed, with `load_defaults` handled deliberately | — | — | +| **6** | [Subsequent hops](phase-6-subsequent-hops.md) | A repeatable checklist for 5.1 → 5.2 → 6.0 → … → 8.0 | — | — | + +**Phase 0 is done with two caveats**, both about the default branch rather than the work: its exit criteria 1 and 2 ask for a green CI run on the *default* branch, and the work currently sits on `feature/cms-420-migrate-tests`. CI triggers are now `master`, `develop` and pull requests, so those two close when this merges into `develop` — not before. + +**Phase 2 is done, with criterion 3 unmet and knowingly so.** The harness migration itself is complete: the 4.2 suite is green at 78.35% (the number moved because simplecov moved, not because coverage did — see the [report](phase-2-harness-report.md)), and on Rails 5 the unit suite went from 323 errors to 3 while cucumber went from "does not load" to 154 scenarios collected. What remains red on Rails 5 is five *application* defects that no harness work can reach, and they belong to Phase 3. The `next-rails` CI job was made gating anyway, deliberately, so **CI is red on every PR until Phase 3 lands** — the job's comment names all five. + +Phase 2's original Poltergeist migration had nothing to migrate (Phase 0 established that no Capybara driver is ever selected), and Phase 1 showed most of the gems it planned to move carry no Rails 5 cap. Both were dropped. The report also records six places the plan was wrong, including two that broke the 4.2 suite before being caught. + +**Ordering note:** Phase 1 comes before Phase 2 deliberately. The gem compatibility check determines how much of the harness migration is actually forced, so running it first prevents Phase 2 from being scoped on guesswork. + +Phases 3 and 4 can run in parallel — Phase 3 is mechanical and Phase 4 requires thought, so they compete for different attention rather than the same hands. + +--- + +## Supporting documentation + +These phase files are a distillation. The reasoning, evidence, and per-file coverage data live here: + +| Document | What it holds | +|---|---| +[`RAILS_UPGRADE_TEST_PRIORITY.md`](../../RAILS_UPGRADE_TEST_PRIORITY.md) | **The primary source.** Breakage risk ranked by *silence* rather than likelihood. §0 carries the skill reconciliation verdicts, §3 the Tier B silent-change items (B1–B10), §4 the Tier C loud ones, §5 the verified-clean list, §7 the coverage-adequacy assessment. | +| [`TEST_COVERAGE_PLAN.md`](../../TEST_COVERAGE_PLAN.md) | Real measured coverage (72.64%), per-file, ordered by coverage-gained-per-unit-effort. The secondary lens for sequencing *within* a phase. | +| [`TEST_COVERAGE_ANALYSIS.md`](../../TEST_COVERAGE_ANALYSIS.md) | The wider gap analysis across both repos, including the `cms` integration surface and the content-block lifecycle gap. Its estimates were superseded by `TEST_COVERAGE_PLAN.md`; its *structural* findings still stand. | + +### The `rails-upgrade` skill + +The methodology behind this plan (FastRuby.io, *The Complete Guide to Upgrade Rails*). Not in this repo — it lives in the `ombulabs-ai` checkout at `rails-upgrade/3.3.0/rails-upgrade/`. Files referenced by these phases: + +- `SKILL.md` — the mandated step order, and the rule that **version skipping is not allowed** +- `version-guides/upgrade-4.2-to-5.0.md` — the hop these phases build toward +- `workflows/test-suite-verification-workflow.md` — Phase 0 +- `workflows/gem-compatibility-workflow.md`, `workflows/boot-smoke-test-workflow.md` — Phase 1 +- `workflows/ci-sync-workflow.md` — Phase 0 and every hop's PR +- `references/testing-checklist.md` — the exit-criteria source for Phase 5 +- `detection-scripts/patterns/rails-*.yml` — the per-version detection patterns, re-run at every hop in Phase 6 + +--- + +## Two things that were unknown — now measured + +Both were called out here as the questions the downstream estimates depended on. Phase 0 answered them. Full numbers in [`phase-0-baseline.md`](phase-0-baseline.md). + +1. **Does the suite pass?** **Yes.** 994 Minitest tests, 0 failures, 0 errors, 19 skips — every skip carrying a stated reason. Coverage 75.82%, enforced by `rake coverage:check`. +2. **Are the 53 Cucumber features green?** **Mostly, and the exceptions are concentrated.** 154/154 in the default profile. Across all 53 files it is 161/193 — and **27 of the 29 failures are the `@cli` set**, which shares a single root cause: `rails new` failing inside aruba. The remaining two are tagged `@known-bug` and `@missing-feature`. + +The third answer nobody asked for is the most useful one: **Poltergeist was never in play.** There are no `@javascript` tags anywhere and both Capybara driver assignments are commented out, so the suite is green on a runner with no browser installed. The "abandoned since 2018" driver risk that shaped Phase 2's scope does not exist. + +So the plan does **not** change shape the way this section feared — with one exception. The `@cli` features are the only coverage `lib/generators` has, and they are 79% red going into a sequence of hops that rewrite generator APIs. That is the gap to close, and it is tracked as O1 in the baseline rather than buried here. + +Effort estimates from the source documents were **not** revisited during distillation and are deliberately omitted from these files. Sequence and exit criteria are the useful parts; days-per-phase should be estimated by whoever picks up the work, now that Phase 0 has reported real numbers. diff --git a/docs/rails-upgrade/phase-0-baseline-and-ci.md b/docs/rails-upgrade/phase-0-baseline-and-ci.md new file mode 100644 index 000000000..8902050e9 --- /dev/null +++ b/docs/rails-upgrade/phase-0-baseline-and-ci.md @@ -0,0 +1,109 @@ +# Phase 0 — Baseline and CI + +> ## Goal +> **Establish that the test suite passes, record what it covers, and put that suite behind a CI job that runs on every push.** +> +> Nothing else in this plan can start until this is true. The skill's Step 1 is explicit: *if any tests fail, STOP; do not proceed until all tests pass.* + +**Blocking:** 🔴 Yes — gates every other phase. +**Rails version at the end of this phase:** 4.2.11.3 (unchanged). + +> ## ✅ Done +> +> Executed on `feature/cms-420-migrate-tests`, commits `acb51911`…`08ad6574`. +> **Plan:** [`phase-0-implementation-plan.md`](phase-0-implementation-plan.md) · **Results:** [`phase-0-baseline.md`](phase-0-baseline.md) +> +> Suite green: **994 tests, 0 failures, 0 errors**, 19 skips. Cucumber **154/154** on the default profile, **161/193** across all 53 files. Coverage **75.82%**, gated. CI exists and runs. +> +> **8 of 10 exit criteria pass.** Criteria 1 and 2 want a green run on the *default* branch; the work is on a feature branch and CI triggers on `master`/`develop`/PRs, so both close on merge into `develop` and not before. +> +> Several of this document's assumptions turned out to be wrong — `rake app:test` is a silent no-op, `assumptions_test.rb` does not fail when wired in, and Poltergeist is never used. The corrections are tabulated at the top of the implementation plan. + +--- + +## Why this phase exists + +Three reasons, in descending order of how much trouble they cause if skipped: + +1. **There is no CI.** The only config is a dead `.travis.yml`; there is no `.github/` directory. Travis OSS is effectively gone, so **browsercms currently has no automated test run at all.** You cannot execute a nine-hop upgrade without a green button — every hop's verification would be someone running `rake` locally and remembering the result. + +2. **Nobody knows whether the suite passes.** The most recent commits are `[CMS-420] get tests working` and `[CMS-420] tests are running`. "Running" is not "passing." Every estimate in the supporting documents assumes a green baseline; if it isn't green, the first thing the upgrade would do is mix pre-existing failures with upgrade-induced ones, which is the single most expensive debugging position to be in. + +3. **The only end-to-end coverage that exists may already be broken.** 53 Cucumber features, 4,860 lines, running on Poltergeist/PhantomJS — abandoned since 2018. They cover the content-block lifecycle that nothing else covers. If a meaningful share are already red, later phases grow substantially, and it is much cheaper to learn that now than in the middle of a bump. + +## Supporting documentation + +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §6](../../RAILS_UPGRADE_TEST_PRIORITY.md) — "Step 1 — Establish that the suite passes, and get CI. BLOCKING." +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §7](../../RAILS_UPGRADE_TEST_PRIORITY.md) — the coverage-adequacy assessment this phase measures against +- [`TEST_COVERAGE_PLAN.md` §1](../../TEST_COVERAGE_PLAN.md) — the 72.64% figure and its per-file breakdown; §1.1 explains why the headline number is misleading in both directions +- [`TEST_COVERAGE_PLAN.md` §0d](../../TEST_COVERAGE_PLAN.md) — the ten test files currently outside the `rake test` chain +- [`TEST_COVERAGE_ANALYSIS.md` Phase 0](../../TEST_COVERAGE_ANALYSIS.md) — the instrumentation argument, and item 3b on the Cucumber pass rate +- Skill: `workflows/test-suite-verification-workflow.md`, `workflows/ci-sync-workflow.md` + +## Work items + +### 0.1 — Run everything and write down what happens + +- [ ] Run the full default task (`bundle exec rake`, which chains `ci:test` → `db:drop`, `db:create:all`, `db:install`, `test`). Record total / passing / failing / pending per suite: unit, spec, functional, features. +- [ ] Run the `@cli` Cucumber features, which the default task **excludes** via `--tags ~@cli`. These are the generator and command features — nine feature files whose coverage is invisible to SimpleCov because aruba shells out to a child process. +- [ ] Fix or explicitly quarantine every failure. A quarantined test needs a comment saying why and a linked issue; an unexplained skip is a hole in the baseline. + +### 0.2 — Wire up the orphaned tests + +Ten test files sit outside the `rake test` chain entirely, because the `Rakefile` globs only `test/unit/**/*_test.rb`, `spec/**/*_spec.rb`, and `test/functional/**/*_test.rb`. They split into two kinds: + +- **2 are genuinely unreachable** — `test/assumptions_test.rb` and `test/helpers/cms/content_types_helper_test.rb`. No task can run them. +- **8 are reachable but never invoked** — everything under `test/dummy/test/**`. The `Rakefile:15` sets `APP_RAKEFILE` to the dummy app and loads `engine.rake`, so `rake app:test` would run them, but nothing in the `rake test` chain calls it. These cover the engine-host integration path (`acts_as_content_page`, custom portlets, design helpers) — exactly the surface a mountable-engine upgrade threatens. + +- [ ] Bring them into the chain, or delete them if they are dead. Either is fine; leaving them unrun is not. See [`TEST_COVERAGE_PLAN.md` §0d](../../TEST_COVERAGE_PLAN.md) for the full list. +- [ ] **Expect this to hurt before it helps.** `test/helpers/cms/content_types_helper_test.rb` is a single `flunk "Need real tests"` and will fail the moment it is wired in. That is the point — a red test is information, an unrun test is not. Either write it or delete it; don't re-hide it. +- [ ] De-duplicate `test/unit/lib/cms_domain_support_test.rb` and `test/unit/lib/cms/domain_support_test.rb` — overlapping tests of the same file. + +### 0.3 — Make coverage honest + +- [ ] Filter generator *templates* out of coverage in `.simplecov`: `lib/generators/**/templates/`, `lib/templates/`. `demo.seeds.rb` alone is 249 counted lines — 13.5% of all "missed" lines — and it is a seed script that would execute if loaded. +- [ ] Decide what to do about `lib/generators` (5 files, 198 counted lines, all at 0%). They **are** tested, by the `@cli` features, but out of process where SimpleCov cannot see them. Exclude them for an honest denominator and schedule real in-process generator tests later — but record the decision either way. +- [ ] Set `minimum_coverage` to the real measured baseline, so a later phase cannot silently delete tests. + +### 0.4 — Stand up CI + +- [ ] Port `.travis.yml` to GitHub Actions: Ruby 2.7.8, Postgres, running the same task the default `rake` runs. +- [ ] Include the `@cli` features, or document why they are excluded. +- [ ] Publish the coverage number as a build artifact so the delta is visible per-PR rather than requiring a local run. +- [ ] Delete `.travis.yml` once the Actions workflow is green, so there is exactly one source of truth about how tests run. + +### 0.5 — Turn the warnings back on + +- [ ] Delete `$VERBOSE = nil` from `test/test_helper.rb`. It suppresses Ruby and Rails deprecation warnings — which are the upgrade roadmap. Fix or explicitly silence the resulting noise; do not restore the blanket suppression. +- [ ] Confirm deprecation output is visible in CI logs (the skill suggests `RUBYOPT="-W:deprecated"`). + +--- + +## Exit criteria + +Judge the phase against these. Every one is checkable. + +| # | Criterion | How to verify | +|---|---|---| +| 1 | A GitHub Actions workflow exists and runs the suite on push and PR | `ls .github/workflows/` — at least one file; the Actions tab shows a run for the latest commit | +| 2 | The most recent CI run on the default branch is **green** | Actions tab shows a passing run, not a skipped or cancelled one | +| 3 | `.travis.yml` is gone | `test ! -f .travis.yml` | +| 4 | The recorded baseline is written down in the repo, per suite | A committed file (or this doc, updated) states total/passing/failing/pending for unit, spec, functional, and features | +| 5 | Zero failing tests; every skip or quarantine has a stated reason | CI green, plus `grep -rn "skip\|pending" test/ spec/` reviewed — each has a comment | +| 6 | **The Cucumber pass rate is known and recorded**, including the `@cli` features | The number is in the committed baseline. This is the phase's most important output. | +| 7 | Coverage is reported by CI and the number is committed as the baseline | Coverage artifact present on the CI run; `minimum_coverage` set to that number in `.simplecov` | +| 8 | Generator templates are excluded from coverage | `grep -n "add_filter" .simplecov` shows the `templates/` filters | +| 9 | No test file is outside the run | The `Rakefile` patterns plus any additions account for every `*_test.rb` / `*_spec.rb` in the repo; a `find` diff against the run list is empty | +| 10 | `$VERBOSE = nil` is gone and deprecation warnings appear in CI output | `grep -n 'VERBOSE' test/test_helper.rb` returns nothing; warnings visible in the CI log | + +**Done means:** a reviewer can open the Actions tab, see green, and read a committed baseline that says exactly how many tests exist, how many Cucumber features pass, and what the real coverage percentage is. + +--- + +## Explicitly not in this phase + +- **No Rails version change.** Still 4.2.11.3. +- **No gem upgrades.** `mocha`, `factory_girl`, `capybara` and friends stay pinned — that is Phase 2, scoped by Phase 1's findings. This phase must run the suite *as it is today*, or the baseline measures something other than the current state. +- **No new tests.** Not one. Writing tests before the harness is modernised means writing them twice ([Phase 2](phase-2-harness-migration.md)). +- **No migration off Poltergeist.** This phase only *measures* the Cucumber pass rate. Migrating the driver is Phase 2 work, and the measurement determines how much of it is worth doing. +- **No coverage improvement.** Making the number honest is in scope; making it higher is not. diff --git a/docs/rails-upgrade/phase-0-baseline.md b/docs/rails-upgrade/phase-0-baseline.md new file mode 100644 index 000000000..b278bcafc --- /dev/null +++ b/docs/rails-upgrade/phase-0-baseline.md @@ -0,0 +1,208 @@ +# Phase 0 — Recorded Baseline + +> Exit criteria 4, 6 and 7 of [`phase-0-baseline-and-ci.md`](phase-0-baseline-and-ci.md). +> Measurements, not intentions. The reasoning behind them is in [`phase-0-implementation-plan.md`](phase-0-implementation-plan.md). + +**Date:** 2026-07-28 · **Branch:** `feature/cms-420-migrate-tests` · **Commits:** `acb51911`…`08ad6574` +**Ruby:** 2.7.8 · **Rails:** 4.2.11.3 · **Postgres:** 16 (local), 15 (CI) +**Command:** `RAILS_ENV=test bundle exec rake`, plus `rake features:all` and `rake features:cli` + +`RAILS_ENV=test` is not optional — see [Environment findings](#environment-findings). + +--- + +## Headline + +| | | +|---|---| +| Minitest | **994 tests, 0 failures, 0 errors, 19 skips** | +| Cucumber, non-`@cli` (default `rake` subset) | **154 / 154 scenarios pass** | +| Cucumber, non-`@cli`, all tags | **154 / 159 pass** — 2 fail (`@known-bug`), 3 pending (`@missing-feature`) | +| Cucumber, `@cli` | **7 / 34 scenarios pass (20.6%)** | +| Coverage | **75.82%** (4,898 / 6,460 relevant lines) | + +**The `@cli` number is this phase's most important output.** The generator and +command-line features — the only coverage `lib/generators` has — are 79% red. + +## Minitest suites + +| Suite | Tests | Assertions | Failures | Errors | Skips | Time | +|---|---|---|---|---|---|---| +| Unit (`test/unit`) | 754 | 1,749 | 0 | 0 | 3 | 47s | +| Spec (`spec`) | 145 | 260 | 0 | 0 | 7 | 20s | +| Functional (`test/functional`) | 88 | 203 | 0 | 0 | 9 | 10s | +| Orphans (`test:orphans`, new) | 7 | 9 | 0 | 0 | 0 | 0.1s | +| **Total** | **994** | **2,221** | **0** | **0** | **19** | | + +The orphan suite runs 9 files but only 7 tests: `catalogs_controller_test.rb` +and `content_page_helper_test.rb` are empty generator scaffolds with no test +methods. They are in the chain and green; they assert nothing. + +## Cucumber + +| Profile | Scenarios | Passed | Failed | Pending | Steps | +|---|---|---|---|---|---| +| `features` — default subset (`~@cli ~@known-bug ~@missing-feature`) | 154 | 154 | 0 | 0 | 837 | +| non-`@cli`, **all** tags | 159 | 154 | 2 | 3 | 864 | +| `features:cli` — the 9 `@cli` files | 34 | 7 | 27 | 0 | 161 | + +**Pass rate across all 53 feature files: 161 / 193 scenarios = 83.4%.** +Excluding `@cli`, it is 154 / 159 = 96.9%. + +### The 5 tag-excluded non-`@cli` scenarios + +Six scenarios carry `@known-bug` or `@missing-feature`; one of them +(`generate_module.feature:10`) is also `@cli` and is counted in that set +instead. The remaining five are what separates 154 from 159: + +| Scenario | Tag | Outcome | +|---|---|---| +| `features/portlets/portlets.feature:94` — Portlet errors should not blow up the page | `@known-bug` | **fails** | +| `features/content_pages.feature:25` — View Older Versions | `@missing-feature` | **fails** | +| `features/cucumber.feature:11` — Upgrade Cucumber | `@known-bug` | pending | +| `features/page_templates.feature:20` — Multiple pages of templates | `@known-bug` | pending | +| `features/page_templates.feature:29` — Edit a template | `@known-bug` | pending | + +### Reconciliation + +A bare `cucumber features` run — every file, every tag — reports +**193 scenarios: 161 passed, 29 failed, 3 pending**. That decomposes exactly: + +``` +154 pass default profile + 7 pass @cli = 161 passed + 27 fail @cli + 2 fail tagged = 29 failed + 3 pending (all @known-bug) = 3 pending +154 + 5 + 34 = 193 scenarios +``` + +Nothing in that run is unaccounted for, and nothing in it is a regression. + +### The 27 `@cli` failures — one dominant root cause +`rails new petstore --skip-bundle` does not exit cleanly inside aruba. The +cached project it should produce is never created, and **15 of the 27 failures +are downstream `Errno::ENOENT` on that missing directory**. Fixing the root +command plausibly recovers most of the suite; it was not attempted in Phase 0, +which is a measurement phase. Tracked as [O1](#open-items). + +Before this could be measured at all, a `Cucumber::Ambiguous` abort had to be +removed — see [Changes made](#changes-made). + +## Coverage + +| Configuration | % | Covered | Relevant | +|---|---|---|---| +| Raw, no filters — reproduces `TEST_COVERAGE_PLAN.md` exactly | 72.64 | 4,898 | 6,743 | +| Generator templates filtered (`.simplecov`) | **75.82** | 4,898 | 6,460 | + +**Enforced floor: 75.82%**, checked by `rake coverage:check`, which `ci:test` +runs after the suite. Override with `COVERAGE_MINIMUM`. + +`lib/generators` (5 files, 198 counted lines, 0%) is **kept in the denominator** +— see [D2](#decisions). + +## Environment findings + +| Question | Answer | +|---|---| +| Does `bundle exec rake` work without `RAILS_ENV=test`? | **No.** `db:install` → `db:migrate` boots the dummy app in `development`, and `test/dummy/config/database.yml` defines only `test`. Aborts with `ActiveRecord::AdapterNotSpecified`. Travis's `script: bundle exec rake` could not have passed. | +| Is PhantomJS installed? | No — and the suite is green anyway. | +| Does any scenario select the Poltergeist driver? | **No.** Zero `@javascript` tags in `features/`; both driver assignments at `features/support/env.rb:16-17` are commented out. `require 'capybara/poltergeist'` loads the gem and nothing ever uses it. **CI needs no PhantomJS.** | +| Suites in `coverage/.resultset.json` | Unit Tests, RSpec, Functional Tests, Orphan Tests, Cucumber Features — five distinct names, none overwriting another. | +| Does `rake app:test` run the dummy app's tests? | **No.** It exits 0 having run nothing. | + +## Quarantine register + +19 skips. Every one already carried a reason string before Phase 0; none has a +linked issue. No test was newly quarantined by this phase. + +| Test | Reason as written | +|---|---| +| `test/unit/models/file_block_test.rb:48` | RuntimeError: unsupported: TrueClass | +| `test/unit/behaviors/attaching_test.rb:451,458` | `changed?` is not updating with rails 4 | +| `test/functional/cms/file_blocks_controller_test.rb:13` | deeper dive needed on why these indexes are not rendering | +| `test/functional/cms/html_blocks_controller_test.rb:39` | deeper dive needed on why these indexes are not rendering | +| `test/functional/cms/pages_controller_test.rb:135` | Work out how page creation has changed | +| `test/functional/cms/home_controller_test.rb:59` | Page routing is not working correctly | +| `test/functional/cms/content_controller_test.rb:22,63,70` | Page routing / routes not working as expected | +| `test/functional/cms/content_controller_test.rb:41` | Archived pages are not visible, but maybe should be for admins | +| `test/functional/cms/sections_controller_test.rb:158` | Admin related operations are failing | +| `spec/cms/form_spec.rb:14,33` · `spec/inputs/name_input_spec.rb:13,19,25` | Form addressability removed 6 years ago (`app/models/cms/form.rb:5`) | +| `spec/cms/form_spec.rb:22` | Parent not getting created (in rails 4?) | +| `spec/concerns/addressable_spec.rb:71` | Parent relationships broken in bcms4 | + +Two clusters are worth noting: **five skips blame page routing** in the +functional controller tests, and **six blame removed/broken addressability**. +Neither is a Phase 0 problem, but both are concentrated enough to be one bug +each rather than eleven. + +## Changes made + +| Change | Why | +|---|---| +| `.simplecov`: `merge_timeout 3600` | Default 600s silently drops the earliest suites from a full run's merged report. Observed producing a 0.0% report. | +| `.simplecov`: `command_name` per suite; `Rakefile` sets `COVERAGE_SUITE` | SimpleCov guesses suite names, and two suites that guess alike overwrite each other. | +| `.simplecov`: template filters as **blocks** | 0.12 raises `ArgumentError` on a `Regexp` filter, and `defaults.rb` rescues it — a regex filter abandons the rest of `.simplecov` with one stderr line. Both the plan's and `TEST_COVERAGE_PLAN.md` §0a's suggested snippets were wrong here. | +| `Rakefile`: new `test:orphans` task, added to the `:test` chain | 10 test files were outside every pattern. `rake app:test` is a silent no-op, so it could not be used. | +| `test/dummy/test/unit/portlets/{find_category,uses_helper}_portlet_test.rb` | Both required `../../test_helper`, which resolves to a `test/dummy/test/test_helper.rb` that does not exist. | +| `test/dummy/test/controllers/design_controller_test.rb` | `get :show` passed no `:page` param, so the action rendered nil and fell through to a `design/show` template that has never existed. | +| Deleted `test/helpers/cms/content_types_helper_test.rb` | A single `flunk "Need real tests"` that referenced `ContentTypesHelper` — not a real constant (the helper is `Cms::ContentTypesHelper`). It could not even load. | +| Merged `test/unit/lib/cms_domain_support_test.rb` into `.../cms/domain_support_test.rb` | Not duplicates: one tested `cms_site?`/`cms_domain_prefix`, the other `using_cms_subdomains?`. All real cases kept; one empty stub dropped. | +| Deleted the redundant `the file "..." should not contain:` step | Collided with aruba 0.14's own, aborting the entire `@cli` run with `Cucumber::Ambiguous` before any result was reported. | +| Removed `$VERBOSE = nil` from `test/test_helper.rb` | It silenced the Ruby-level deprecation warnings that are the upgrade roadmap. | +| `File.exists?` → `File.exist?`, 7 call sites | Deprecated. One of them was stubbed by `attaching_test.rb:247`, which had to move with it. | +| `Rakefile`: `ci:test` now runs `coverage:check` | Enforces the floor once, after the chain. | +| `.github/workflows/ci.yml` | There was no CI. | +| `test/dummy/db/schema.rb` regenerated | See [O2](#open-items). | + +## Deprecation inventory + +Visible in the CI log now that warnings are on. This is the Phase 3 work list, +recorded here because Phase 0 is where it became visible. + +| Count | Warning | Where | +|---|---|---| +| 44 | `#timestamps` called without `null:` — changes in Rails 5 | migrations | +| 29 | `#add_timestamps` called without `null:` | migrations | +| 24 | passing an AR instance to `find` | `app/models/cms/category.rb:33` | +| 8 | `#deliver` removed in Rails 5 | mailers | +| many | `use_route` in functional tests — **removed in Rails 5, no replacement** | `test/support/engine_controller_hacks.rb:33` | +| 2 | `config.serve_static_assets` renamed | `test/dummy/config/environments/test.rb:11` | +| 1 | `Devise::TestHelpers` → `Devise::Test::ControllerHelpers` | `test/test_helper.rb:199` | +| 1 | `active_support.test_order` default changes to `:random` | unset | +| 97 | Ruby: `Object#=~` called on `Cms::ContentType` | see below | +| 69 / 52 / 45 | Ruby: `Proc.new` block capture, `Fixnum`, `Bignum` | gem internals (activerecord, simplecov, simple_form) | + +### One of those is a latent defect, not just noise + +`Cms::ContentBlockController#content_type` (`app/controllers/cms/content_block_controller.rb:152`) +overrides `ActionController::Metal#content_type`, returning a `Cms::ContentType` +record instead of the response MIME string. Rails' CSRF protection then runs +`content_type =~ %r(\Atext/javascript)` at +`request_forgery_protection.rb:242`, which on a non-String is `Object#=~` and +**always returns nil** — so `non_xhr_javascript_response?` is permanently false +for every content-block controller, and that cross-origin JavaScript check +never fires. Renaming a public helper method is out of Phase 0's scope; tracked +as [O3](#open-items). + +## Decisions + +| ID | Decision | Outcome | +|---|---|---| +| **D1** | How to handle the `RAILS_ENV` requirement | **Set `RAILS_ENV: test` in CI**, leave `database.yml` alone. Phase 0 must measure the suite as it is. Adding a `development:` section is the better long-term fix — [O4](#open-items). | +| **D2** | Exclude `lib/generators` from coverage? | **No — keep it in the denominator.** The standing recommendation was to exclude it because the `@cli` features cover it out of process. Phase 0 measured those features: 7 of 34 pass. The 0% is a measurement gap sitting on a real testing gap, and excluding it would misreport the second one. Revisit once [O1](#open-items) is fixed. | +| **D3** | `test/assumptions_test.rb` | **Kept.** It was predicted to fail once wired in, because `db:install` seeds a Home page and a `/system` section. It passes: `app:test:prepare` purges and reloads the schema before any test runs, so the seed data is gone by then. The prediction was wrong; the test is valid. | +| **D4** | The `flunk` stub test | **Deleted.** It never asserted anything and referenced a constant that does not exist. | +| **D5** | The two empty scaffold test files | **Kept**, now inside the chain. Recorded here so nine files are not mistaken for nine tests. | + +## Open items + +Not Phase 0 work. Recorded so a reviewer can tell a gap from an oversight. + +| ID | Item | +|---|---| +| **O1** | `@cli` features are 7/34. One root cause — `rails new` failing inside aruba — accounts for 15 of the 27 failures. Fix it and re-measure before revisiting [D2](#decisions). | +| **O2** | `test/dummy/db/schema.rb` as committed contained 27 tables that no migration creates — ephemeral fixtures (`default_attachables`, `publishable_blocks`, `things`, …) that tests build at runtime and a `db:schema:dump` then captured. The committed file is now what a clean migrate produces, but any partial test task can re-dirty it. Consider gitignoring it: CI builds the database from migrations. | +| **O3** | The `content_type` override disabling CSRF's cross-origin JavaScript check (above). | +| **O4** | `bundle exec rake` is broken without `RAILS_ENV=test` for every developer, not just CI. | +| **O5** | Five skips blame page routing; six blame removed addressability. Likely two bugs, not eleven. | +| **O6** | The 19 skips have reasons but no linked issues. | diff --git a/docs/rails-upgrade/phase-0-implementation-plan.md b/docs/rails-upgrade/phase-0-implementation-plan.md new file mode 100644 index 000000000..823bb5c81 --- /dev/null +++ b/docs/rails-upgrade/phase-0-implementation-plan.md @@ -0,0 +1,557 @@ +# Phase 0 — Implementation Plan + +**Implements:** [`phase-0-baseline-and-ci.md`](phase-0-baseline-and-ci.md) +**Rails version throughout:** 4.2.11.3 (unchanged) +**Branch:** continue on `feature/cms-420-migrate-tests`, or cut `feature/cms-420-phase-0` from it. + +This is the *how*. The phase file states the goal and the contract; this file states the order of operations, the exact edits, the failures to expect, and the decisions that need a human. Where the two disagree, the divergence is called out explicitly in [§1](#1-pre-flight-findings) — the phase file was written from static reading, and several of its assumptions do not survive contact with the repo. + +> ### Status: ✅ done — commits `acb51911`…`08ad6574` +> Measured results are in [`phase-0-baseline.md`](phase-0-baseline.md); that file, not this one, is the record. 8 of 10 exit criteria pass; 1 and 2 want a green run on the default branch and close on merge into `develop`. Where execution contradicted the plan: +> +> | Plan said | Reality | +> |---|---| +> | F4: one file requires a missing `test_helper` | **Two** — `find_category_portlet_test.rb` and `uses_helper_portlet_test.rb`. | +> | F5: `assumptions_test.rb` will fail once wired in | **It passes.** `app:test:prepare` purges and reloads the schema before any test runs, so `db:install`'s seed data is gone by then. | +> | Stage G.1: filter with `add_filter %r{…}` | **SimpleCov 0.12 rejects Regexp filters entirely** — `parse_filter` raises `ArgumentError`, and `defaults.rb` rescues it around `load .simplecov`, so a regex filter silently abandons the rest of the config file. Use block filters. The plan's snippet and `TEST_COVERAGE_PLAN.md` §0a were both wrong, in different ways. | +> | Stage E: flip `t.warning = true` | **Not done.** Removing `$VERBOSE = nil` restores Ruby's default warning level, which is what surfaces the deprecations. `-w` additionally enables the uninitialized-ivar and method-redefined classes, almost all of it from gem internals. `RUBYOPT=-W:deprecated` in CI covers criterion 10 without the noise. | +> | Stage E: `File.exists?` fixes are mechanical | One of the seven call sites was **stubbed by a test** (`attaching_test.rb:247`), which broke two tests until the stub moved with it. | +> | Stage B: measure the `@cli` pass rate | Could not be measured at all until a `Cucumber::Ambiguous` step collision was removed — it aborted the run before the first result. | +> +> Also unplanned: running the suite rewrites `test/dummy/db/schema.rb`, because the committed version contained 27 ephemeral fixture tables no migration creates. See O2 in the baseline. + +--- + +## 1. Pre-flight findings + +Verified against the working tree at `b00c2c04` on 2026-07-28, before any work started. Each of these changes the plan. + +| # | Finding | Evidence | Consequence | +|---|---|---|---| +| **F1** | **`bundle exec rake` — the documented default command, and Travis's `script` — fails immediately.** `ci:test` → `db:install` → `db:migrate` boots the dummy app in `development`, and `test/dummy/config/database.yml` defines **only** `test`. | `bundle exec rake db:migrate` → `ActiveRecord::AdapterNotSpecified: 'development' database is not configured. Available: ["test"]`. With `RAILS_ENV=test` it boots fine. | Every command in this phase is `RAILS_ENV=test bundle exec rake …`. Fixing it properly is a decision — see [D1](#d1-railsenv). It also means `.travis.yml` could not have been green; treat the Travis config as evidence of *intent*, not of a working build. | +| **F2** | **`rake app:test` is a silent no-op.** It exits 0 having run nothing. Rails 4.2's `test` task passes `Rake.application.top_level_tasks` (`["app:test"]`) to `Rails::TestTask.test_creator`, which matches no known sub-task. | `bundle exec rake app:test` → exit 0, zero output. | Phase file §0.2's "`rake app:test` would run them" is **false**. Do not wire `app:test` into the chain; a task that silently passes is worse than one that fails. Use a new `Rake::TestTask` instead ([Stage D](#stage-d--wire-in-the-orphaned-tests-02)). | +| **F3** | **`rake app:test:run` does run — but globs from the engine root, not the dummy app**, and dies on boot. Its pattern `test/**/*_test.rb` is relative to the cwd, so it sweeps up `test/unit`, `test/functional`, `test/assumptions_test.rb` and the dummy tests together. | `bundle exec rake app:test:run` → aborts inside `test/assumptions_test.rb:1` → `test_helper.rb:4` → `AdapterNotSpecified`. | Confirms F2's replacement approach, and confirms `test/assumptions_test.rb` is loadable (its `require "test_helper"` resolves) once `RAILS_ENV` is right. | +| **F4** | **There is no `test/dummy/test/test_helper.rb`.** `test/dummy/test/unit/portlets/find_category_portlet_test.rb:1` requires `../../test_helper`, which resolves to that missing path. The other seven use a bare `require "test_helper"`, which resolves to the engine's `test/test_helper.rb` **only if** `test/` is on the load path. | `ls test/dummy/test/*.rb` → no matches. | Wiring the 8 dummy tests needs `t.libs << 'test'` (engine `test/`) **and** a fix for the one relative require — either edit that line, or add a one-line `test/dummy/test/test_helper.rb` shim. Prefer editing the one file. | +| **F5** | **`test/assumptions_test.rb` will fail the moment it is wired in.** It asserts `Section.count == 0`, but `db:install` (inside `ci:test`) seeds a Home page and a `/system` section. | The test's own body, plus the comment block at `test/test_helper.rb:32-40` describing exactly that seeded state. | Expected red, same category as `content_types_helper_test.rb`. Decision [D3](#d3-assumptions_testrb). | +| **F6** | **The two domain-support tests are not duplicates.** `test/unit/lib/cms_domain_support_test.rb` covers `cms_site?` and `cms_domain_prefix` on `Cms::ApplicationController`; `test/unit/lib/cms/domain_support_test.rb` covers `using_cms_subdomains?` through the module. Ten distinct cases, one empty stub. | Both files read in full. | Phase file §0.2's "de-duplicate — overlapping tests of the same file" overstates it. **Merge, don't delete**: move the three real cases from the former into the latter, drop the empty `"prepare_for_rendererable"` stub, delete the now-empty file. | +| **F7** | **Poltergeist/PhantomJS may be a paper tiger.** There are **zero `@javascript` tags** in `features/`, and both driver assignments in `features/support/env.rb:16-17` are commented out. PhantomJS is not installed on this machine. | `grep -rn '@javascript' features/` → 0. `which phantomjs` → not found. `features/support/env.rb:16-17`. | `require 'capybara/poltergeist'` loads the gem but nothing ever selects the driver, so **CI needs no PhantomJS**. Confirm during [Stage B](#stage-b--the-baseline-run-01) and record it — this materially shrinks [Phase 2](phase-2-harness-migration.md), whose driver-migration scope is sized on the assumption that the features depend on it. | +| **F8** | **`rake features` excludes more than `@cli`.** It also drops `~@known-bug` (5 scenarios) and `~@missing-feature` (1). | `Rakefile:51`; `grep -rho '@known-bug\|@missing-feature' features/`. | Exit criterion 6 ("the Cucumber pass rate is known") needs three runs, not two: `features`, `features:cli`, and `features:all`. Otherwise the recorded denominator excludes 16 scenarios by construction. | +| **F9** | **SimpleCov's 600-second `merge_timeout` will silently corrupt the coverage number** on any full run. The four suites are separate processes that merge through `coverage/.resultset.json`; 0.12 discards stored results older than `merge_timeout` (default 600s). | Demonstrated accidentally today: a late single-suite run regenerated the report and the four baseline suites dropped out — `coverage/.last_run.json` read `0.0`. Restored to 72.64% by re-merging with a raised timeout. `simplecov-0.12.0/lib/simplecov/configuration.rb:206`. | **Must be fixed before the baseline run**, or the recorded number is fiction. This is why [Stage A](#stage-a--make-the-measurement-trustworthy-prerequisite-to-01) precedes work item 0.1. | +| **F10** | **`minimum_coverage` cannot be set naively.** SimpleCov enforces it in *every* process's `at_exit` and calls `Kernel.exit` — so the units suite would fail the build for not, by itself, meeting the full-suite threshold. | `simplecov-0.12.0/lib/simplecov/defaults.rb:52-95`. | Gate once, after the chain, from `coverage/.last_run.json` (written unconditionally, line 90). See [Stage G](#stage-g--make-coverage-honest-03). | +| **F11** | **SimpleCov names suites by guessing**, and the guesser is fragile — today's stray run was filed as `"Unknown Test Framework"`. Two suites that guess the same name overwrite each other in the resultset. | `coverage/.resultset.json` keys; `SimpleCov::CommandGuesser`. | The new orphan-tests `Rake::TestTask` added in Stage D is at real risk of being guessed as `"Unit Tests"` and **silently clobbering** the units coverage. Set `command_name` explicitly per suite and verify the resultset has five distinct keys. | +| **F12** | **`$VERBOSE = nil` suppresses Ruby warnings only — Rails deprecations are already visible.** `RAILS_ENV=test bundle exec rake db:migrate` prints `config.serve_static_assets` → `serve_static_files` (from `test/dummy/config/environments/test.rb:11`, a Rails 5.0 removal) and the `after_rollback`/`after_commit` warning today, with `$VERBOSE = nil` in force. | Observed output. | Phase file §0.5's framing ("suppresses Ruby *and Rails* deprecation warnings") is half right. Removing it surfaces the **Ruby** layer: `File.exists?` (5 call sites), uninitialized ivars, method redefinitions. Both layers matter; they need different switches (`-W:deprecated` vs. nothing). | + +Two smaller notes carried into the steps below: `test/dummy/config/database.yml` **is** tracked (so CI needs no `project:setup`), and `rake` itself lives in the `:development` group (`Gemfile:20`), so Travis's `--without development` should not be carried over. + +--- + +## 2. Execution order + +The phase file numbers its work items 0.1 → 0.5. That is the right order to *read* them and the wrong order to *do* them, for two reasons: the measurement harness must be trustworthy before the baseline run is worth recording (F9), and CI should be stood up while the suite is still red, because the runner surfaces failures a Mac never will. + +| Stage | Work item | What it produces | Size | +|---|---|---|---| +| **A** | 0.3 (partial) | `merge_timeout` fixed, per-suite `command_name` — the number can be trusted | S | +| **B** | 0.1 | The baseline run: four suites + all three cucumber profiles, logged | M | +| **C** | 0.4 (partial) | `.github/workflows/ci.yml` landed, allowed to be red | M | +| **D** | 0.2 | Ten orphan tests inside the chain; domain-support tests merged | M | +| **E** | 0.5 | `$VERBOSE = nil` gone, warning noise triaged | M–L | +| **F** | 0.1 (triage) | Zero failing tests; every quarantine annotated | **L / unknown** | +| **G** | 0.3 (rest) | Honest denominator, `minimum_coverage` enforced once | S | +| **H** | 0.4 (rest) | CI green, coverage artifact, `.travis.yml` deleted | S | + +**Stage F is the only unbounded one.** Its size is exactly what Stage B measures, which is why B comes early and why no estimate is offered here. If B reports a large share of the Cucumber suite red, stop and re-scope before continuing — that is the finding the README says the whole plan's shape depends on. + +Stages D and E can run in parallel with C. Everything else is sequential. + +--- + +## Stage A — Make the measurement trustworthy (prerequisite to 0.1) + +**Why first:** F9. A full `rake` run takes longer than ten minutes, so without this the coverage figure recorded in Stage B is whatever subset of suites happened to finish inside the window. + +### A.1 — Fix `.simplecov` + +Replace the file with: + +```ruby +# The suite runs as four separate processes (units, spec, functionals, features) +# that merge through coverage/.resultset.json. SimpleCov 0.12 discards any stored +# result older than merge_timeout, which defaults to 600s -- so on a full run the +# earliest suites silently drop out and the reported percentage is a fraction of +# the real one. Observed 2026-07-28: a late run produced a report reading 0.0%. +SimpleCov.start 'rails' do + merge_timeout 3600 + + # Each suite must name itself, or CommandGuesser guesses -- and two suites that + # guess the same name overwrite each other in the resultset. Set by the Rakefile. + command_name ENV['COVERAGE_SUITE'] if ENV['COVERAGE_SUITE'] +end +``` + +Coverage *filters* are deliberately not added yet — they change the denominator, and Stage B should first reproduce the 72.64% already recorded in `TEST_COVERAGE_PLAN.md`. Reproducing a known number is how you find out the measurement works. Filters land in Stage G. + +> `merge_timeout(3600)` runs `seconds.is_a?(Fixnum)`. On Ruby 2.7 that is a deprecated constant (harmless, but it will show up once Stage E turns warnings on). On Ruby 3.2+ `Fixnum` is **gone** and this line raises `NameError` — simplecov 0.12 does not survive the Ruby bump. Not Phase 0's problem; note it for [Phase 2](phase-2-harness-migration.md). + +### A.2 — Name each suite in the `Rakefile` + +`Rake::TestTask` shells out to a subprocess that inherits the environment, so setting `ENV` in a prerequisite task is sufficient: + +```ruby +# Rakefile, above the TestTask definitions +def coverage_suite(name) + suite_task = "coverage:suite:#{name.downcase.tr(' ', '_')}" + task(suite_task) { ENV['COVERAGE_SUITE'] = name } + suite_task +end + +Rake::TestTask.new('units' => coverage_suite('Unit Tests')) do |t| + # ...unchanged +end +``` + +Apply to `units` (`Unit Tests`), `spec` (`RSpec`), `test:functionals` (`Functional Tests`), and the cucumber tasks (`Cucumber Features`) — matching the names already in `coverage/.resultset.json`, so the historical result stays comparable. + +- [ ] `.simplecov` updated +- [ ] Each suite names itself +- [ ] **Verify:** after Stage B, `ruby -rjson -e 'puts JSON.parse(File.read("coverage/.resultset.json")).keys'` lists exactly the expected suites, no `Unknown Test Framework` + +--- + +## Stage B — The baseline run (0.1) + +**Do not fix anything during this stage.** The output is a measurement; changing the code mid-measurement invalidates it. Write failures down and move on. + +### B.1 — Run everything, log everything + +```bash +mkdir -p tmp/baseline +RAILS_ENV=test bundle exec rake 2>&1 | tee tmp/baseline/default.log # units, spec, functionals, features +RAILS_ENV=test bundle exec rake features:all 2>&1 | tee tmp/baseline/features-all.log +RAILS_ENV=test bundle exec rake features:cli 2>&1 | tee tmp/baseline/features-cli.log +``` + +Three cucumber runs, not one — F8. `features` is the default-task subset; `features:all` is the honest 53-file denominator; `features:cli` is the nine generator/command files that SimpleCov cannot see. + +Expect `features:cli` to be slow and probably red: aruba shells out to `bcms new`, which runs a full `bundle install` inside a generated app, against an `@aruba_timeout_seconds = 15` set at `features/support/env.rb:29`. Record the failure mode rather than fixing it here. + +If the default task dies before finishing, run the suites individually (`rake units`, `rake spec`, `rake test:functionals`, `rake features`) so one broken suite does not hide the other three. + +### B.2 — Answer the Poltergeist question (F7) + +While the features run, confirm the driver is genuinely unused: + +```bash +grep -rn '@javascript\|javascript_driver\|Capybara.default_driver\|:poltergeist' features/ test/ spec/ +``` + +If nothing selects Poltergeist and the features pass without PhantomJS installed, **write that down in the baseline** in as many words. It is the most valuable side-finding available in this phase. + +### B.3 — Record the baseline + +Create `docs/rails-upgrade/phase-0-baseline.md`. This file is exit criteria 4, 6 and 7; it is a deliverable, not a scratchpad. + +```markdown +# Phase 0 — Recorded Baseline + +**Commit:** **Date:** **Ruby:** 2.7.8 **Rails:** 4.2.11.3 +**Command:** `RAILS_ENV=test bundle exec rake` (+ `features:all`, `features:cli`) + +## Minitest suites +| Suite | Files | Runs | Assertions | Failures | Errors | Skips | +|---|---|---|---|---|---|---| +| Unit (`test/unit`) | | | | | | | +| Spec (`spec`) | | | | | | | +| Functional (`test/functional`) | | | | | | | +| Orphans (added in Stage D) | | | | | | | + +## Cucumber +| Profile | Features | Scenarios | Passed | Failed | Undefined | Skipped | +|---|---|---|---|---|---|---| +| `features` (default: `~@cli ~@known-bug ~@missing-feature`) | | | | | | | +| `features:all` (all 53 files) | | | | | | | +| `features:cli` (`@cli`, 9 files) | | | | | | | + +**Cucumber pass rate (all 53 files): __ %** + +## Coverage +| | % | Covered | Relevant | +|---|---|---|---| +| Raw (no filters) | | | | +| Filtered (Stage G) | | | | + +## Environment findings +- PhantomJS installed: no / yes. Driver actually selected by any scenario: no / yes. +- `RAILS_ENV=test` required: yes (see F1). + +## Quarantine register +| Test | Why | Issue | +|---|---|---| +``` + +- [ ] Three runs completed and logged +- [ ] `phase-0-baseline.md` committed with every cell filled — "unknown" is an acceptable value, an empty cell is not +- [ ] Coverage reproduces ≈72.64% (if it does not, Stage A is wrong — stop and fix it) + +--- + +## Stage C — Stand up CI, red (0.4, part 1) + +Land the workflow now, before the suite is green. A GitHub-hosted Ubuntu runner with a fresh Postgres will find environment assumptions that a developer Mac with a warm database never will, and you want those failures in Stage F's queue, not discovered in Stage H. + +Remote is `git@github.com:ETBD/browsercms.git`, so Actions is the right target. + +### C.1 — `.github/workflows/ci.yml` + +```yaml +name: CI + +on: + push: + branches: [master, develop] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-22.04 + timeout-minutes: 45 + + services: + postgres: + image: postgres:15 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + + env: + # test/dummy/config/database.yml defines only `test`; anything that boots + # the app in `development` aborts with AdapterNotSpecified. See F1. + RAILS_ENV: test + # database.yml specifies no host, so libpq falls back to PGHOST -- which + # points it at the service container instead of a non-existent unix socket. + PGHOST: localhost + PGPORT: '5432' + PGUSER: postgres + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7.8' + bundler: '1.17.3' + bundler-cache: true + + - name: Full suite + run: bundle exec rake + + - name: Cucumber — all features + run: bundle exec rake features:all + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: coverage/ + retention-days: 30 + + cli-features: + runs-on: ubuntu-22.04 + timeout-minutes: 45 + continue-on-error: true # remove or delete this job once Stage F decides — see D2 + services: + postgres: + image: postgres:15 + env: {POSTGRES_HOST_AUTH_METHOD: trust} + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + env: + RAILS_ENV: test + PGHOST: localhost + PGPORT: '5432' + PGUSER: postgres + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7.8' + bundler: '1.17.3' + bundler-cache: true + - run: bundle exec rake features:cli +``` + +Notes on the choices, since each is a place this will otherwise be quietly wrong: + +- **`ubuntu-22.04`, pinned — not `ubuntu-latest`.** `ruby/setup-ruby` has no prebuilt 2.7.8 for 24.04. If 22.04 is retired before this lands, switch the job to `container: ruby:2.7.8-bullseye` rather than chasing runner images; the Postgres service works the same way, with `PGHOST: postgres` instead of `localhost`. +- **No PhantomJS install step.** Justified by F7 — and if B.2 disproves it, this is where a `phantomjs` setup step goes. +- **Travis's `--without development` is dropped.** `rake` is declared in the `:development` group (`Gemfile:20`) and the entire chain is rake-driven; installing every group is cheaper than debugging that. +- **`bundle exec rake`, no `RAILS_ENV` prefix**, because the job-level `env:` block sets it. If [D1](#d1-railsenv) resolves toward fixing `database.yml` instead, drop the env var here. +- **`features:all` is a separate step** so the default-subset run and the full run are separately visible in the log. +- **`@cli` in its own `continue-on-error` job**, so the phase can converge while the aruba question is settled independently. Exit criterion 2 is about the main `test` job. + +- [ ] Workflow committed and pushed +- [ ] A run appears in the Actions tab (criterion 1) — red is expected and fine at this stage +- [ ] Compare the CI failure list against Stage B's local list; anything CI-only goes in the Stage F queue tagged as environmental + +--- + +## Stage D — Wire in the orphaned tests (0.2) + +Ten files, three separate problems (F2, F4, F6). + +### D.1 — A fourth `Rake::TestTask` for the reachable orphans + +Do **not** use `app:test` (F2). Add to the `Rakefile`: + +```ruby +# test/assumptions_test.rb, test/helpers/**, and the eight dummy-app tests under +# test/dummy/test/** are matched by none of the three globs above. rake app:test +# looks like the answer and is not -- it exits 0 having run nothing. +Rake::TestTask.new('test:orphans' => coverage_suite('Orphan Tests')) do |t| + t.libs << 'lib' + t.libs << 'test' # so `require "test_helper"` finds the engine's helper + t.test_files = FileList[ + 'test/*_test.rb', + 'test/helpers/**/*_test.rb', + 'test/dummy/test/**/*_test.rb' + ] + t.verbose = false + t.warning = false # flipped to true in Stage E +end +``` + +Add `test:orphans` to the `:test` task's list (`Rakefile:86`), after `test:functionals`. + +### D.2 — Fix the one broken require (F4) + +`test/dummy/test/unit/portlets/find_category_portlet_test.rb:1` points at a `test/dummy/test/test_helper.rb` that does not exist. Change it to a bare `require "test_helper"` to match its seven siblings. (A shim file in `test/dummy/test/` would also work, and is worse — it invents a second helper for a dummy app that never had one.) + +### D.3 — Merge the domain-support tests (F6) + +Not duplicates. Move the three real cases from `test/unit/lib/cms_domain_support_test.rb` (`cms_site?` with a `cms` subdomain, with a `www` subdomain, and `cms_domain_prefix`) into `test/unit/lib/cms/domain_support_test.rb`, drop the empty `"prepare_for_rendererable"` stub, and delete the emptied file. Ten cases in, ten cases out — verify the count. + +### D.4 — Prove the net is closed (exit criterion 9) + +```bash +comm -13 \ + <(RAILS_ENV=test bundle exec rake units spec test:functionals test:orphans TESTOPTS=--verbose 2>/dev/null \ + | grep -o 'test/[^ ]*_test\.rb\|spec/[^ ]*_spec\.rb' | sort -u) \ + <(find test spec -name '*_test.rb' -o -name '*_spec.rb' | sort) +``` + +Empty output means no file is outside the run. Simpler and equally valid: temporarily add `puts t.file_list.to_a` to each `TestTask` block and diff against `find`. + +### Expected damage + +| File | What happens | Handle in | +|---|---|---| +| `test/helpers/cms/content_types_helper_test.rb` | Fails — the body is `flunk "Need real tests"` | Stage F / [D4](#d4-flunk-and-stub-tests) | +| `test/assumptions_test.rb` | Fails — asserts an empty DB, `db:install` seeded one (F5) | Stage F / [D3](#d3-assumptions_testrb) | +| The 8 dummy tests | Unknown; never run in living memory | Stage F | +| Coverage | Should **rise** — dummy-app tests exercise `acts_as_content_page`, portlets, design helpers | Re-baseline in Stage G | + +- [ ] `test:orphans` defined and in the `:test` chain +- [ ] Broken require fixed +- [ ] Domain-support tests merged, empty file deleted +- [ ] Criterion 9's `find` diff is empty +- [ ] `.resultset.json` has five distinct suite keys, and `Unit Tests` did not shrink (F11) + +--- + +## Stage E — Turn the warnings back on (0.5) + +Two independent switches, which the phase file treats as one (F12). + +### E.1 — Ruby warnings + +Delete `$VERBOSE = nil` from `test/test_helper.rb:29` and its comment. Then flip `t.warning = false` → `true` in every `Rake::TestTask` — leaving the task-level flag off would re-suppress much of what deleting the line was meant to reveal. + +Known Ruby-level noise to expect, so it is not mistaken for something new: + +- `File.exists?` (deprecated since Ruby 2.1, warns under `-W:deprecated`) — `lib/tasks/core_tasks.rake:51`, `lib/cms/caching.rb:42`, `lib/cms/attachments/attachment_serving.rb:44`, `test/custom_assertions.rb:19`, `lib/generators/cms/content_block/content_block_generator.rb:26` +- `Fixnum` from `merge_timeout` in `.simplecov` (A.1) +- The HTML-parsing warnings from functional tests that the original comment blamed — these are the ones to actually triage + +Fix what is cheap (the five `File.exists?` → `File.exist?` are mechanical and safe on 4.2). Silence what is not, **narrowly and with a reason** — a targeted `Warning[:deprecated] = false` around one require, never a blanket `$VERBOSE = nil`. + +### E.2 — Rails deprecations in CI + +These already print (F12) and are the upgrade roadmap. Add to the workflow's `env:` block: + +```yaml +RUBYOPT: "-W:deprecated" +``` + +`test/dummy/config/environments/test.rb:11` uses `config.serve_static_assets`, removed in Rails 5.0 — that one is already visible today and belongs in [Phase 3](phase-3-backwards-compatible-fixes.md), not here. Phase 0's job is to make the list *visible*, not to work it. + +- [ ] `$VERBOSE = nil` gone (criterion 10: `grep -n 'VERBOSE' test/test_helper.rb` is empty) +- [ ] `t.warning = true` on all test tasks +- [ ] `RUBYOPT: "-W:deprecated"` in CI +- [ ] Deprecation output visible in the CI log; no blanket suppression reintroduced + +--- + +## Stage F — Drive to zero failures (0.1, third bullet) + +The unbounded stage. Its input is the union of Stage B's local failures, Stage C's CI-only failures, and the new red from Stages D and E. + +**The rule, per the phase file: fix it or quarantine it — and a quarantine carries a comment saying why plus a linked issue.** Nothing gets silently skipped. + +Suggested triage order, cheapest signal first: + +1. **Environmental** (CI-only, missing binaries, DB state) — usually a workflow fix, unblocks the whole board. +2. **Ordering/isolation.** The suite runs two cleaning strategies side by side — transactional rollback for `ActiveSupport::TestCase`, DatabaseCleaner truncation for `Minitest::Spec` — and the comment at `test/test_helper.rb:32-40` documents a real coin-flip this already caused. Suspect any failure that moves when the seed changes. Reproduce with `TESTOPTS="--seed=N"`. +3. **Genuinely broken tests** — fix. +4. **Genuinely broken product code** — this is a finding. Log it; do not fix it inside Phase 0 unless it is trivial. +5. **Cucumber.** Triage by feature file, and treat `@known-bug`-tagged scenarios as pre-quarantined *only if* the tag has an explanation. Five scenarios carry it today. + +For each quarantine, add to the test: + +```ruby +# QUARANTINED : -- +skip "see " +``` + +and a row in the baseline's quarantine register. There are already 20 `skip`s across `test/` and `spec/`; criterion 5 requires each to have a stated reason, so they need reviewing too — that is part of this stage, not an extra. + +- [ ] Every failure fixed or quarantined-with-reason +- [ ] The 20 existing skips reviewed and annotated +- [ ] `RAILS_ENV=test bundle exec rake` green locally +- [ ] The main CI job green +- [ ] Baseline document updated with the final numbers and the full quarantine register + +--- + +## Stage G — Make coverage honest (0.3) + +Only now, with a green suite and a stable number. + +### G.1 — Filters + +Append to `.simplecov`: + +```ruby + # Generator templates are copied into a user's app, not executed here. + # demo.seeds.rb alone is 249 counted lines -- 13.5% of all missed lines -- and + # loading it would run it. + add_filter %r{/lib/generators/.*/templates/} + add_filter %r{/lib/templates/} +``` + +> **Do not copy the snippet from `TEST_COVERAGE_PLAN.md` §0a verbatim.** It anchors the regex with `^/lib/…`, and SimpleCov 0.12 matches filters against the **absolute** path (`simplecov-0.12.0/lib/simplecov/filter.rb:31`), so the anchored version silently matches nothing — you get a plausible-looking config and an unchanged number. Unanchored, as above. + +**Verify:** `grep -c 'demo.seeds.rb' coverage/index.html` → 0, and the percentage moves to roughly 75.8%. + +### G.2 — Decide on `lib/generators` ([D2](#d2-libgenerators-coverage)) + +Five files, 198 counted lines, all 0%, all genuinely exercised by the `@cli` features — out of process, where simplecov 0.12 cannot follow (no `at_fork` before 0.17). Excluding them gives an honest denominator (~78.5%); leaving them in reports a measurement gap as a coverage gap. **Either way, write the decision and its reasoning into the baseline document** — that is what criterion 7 is really asking for. + +If excluding: + +```ruby + add_filter %r{/lib/generators/.*_generator\.rb\z} + add_filter %r{/lib/generators/browser_cms\.rb\z} +``` + +and record the landmine: `content_block_generator.rb:26` calls `File.exists?`, which no in-process test touches. + +### G.3 — Enforce the floor, once + +Not `minimum_coverage` in `.simplecov` — F10. Add to `lib/tasks/core_tasks.rake`: + +```ruby +namespace :coverage do + desc 'Fail if merged coverage fell below the recorded Phase 0 baseline' + task :check do + require 'json' + threshold = Float(ENV.fetch('COVERAGE_MINIMUM', '')) + path = 'coverage/.last_run.json' + abort "#{path} missing -- did the suite run?" unless File.exist?(path) + actual = JSON.parse(File.read(path)).fetch('result').fetch('covered_percent') + if actual < threshold + abort format('Coverage %.2f%% is below the %.2f%% baseline.', actual, threshold) + end + puts format('Coverage %.2f%% (baseline %.2f%%)', actual, threshold) + end +end +``` + +Then, in the `Rakefile`: + +```ruby +Rake::Task['ci:test'].enhance { Rake::Task['coverage:check'].invoke } +``` + +`coverage/.last_run.json` is written unconditionally by simplecov's `at_exit` (`defaults.rb:90`), and — given Stage A's `merge_timeout` — the last suite to finish writes the fully merged figure. The gate runs after the chain, so it cannot fire on a partial merge, and a failing suite short-circuits it (which is correct: a red suite's coverage number is meaningless). + +- [ ] Template filters land and demonstrably change the number +- [ ] `lib/generators` decision made **and recorded** +- [ ] `coverage:check` wired into `ci:test`, threshold = measured baseline +- [ ] Baseline document records raw and filtered percentages side by side + +--- + +## Stage H — Close out CI (0.4, rest) + +- [ ] Confirm the coverage artifact is downloadable from the run (criterion 7). `coverage/` is gitignored; `upload-artifact` reads the workspace, so this works — just verify it rather than assuming. +- [ ] Resolve the `@cli` job: either it is green and `continue-on-error` comes off, or it stays excluded **and the workflow carries a comment saying why**, mirrored in the baseline (criterion 6 requires the number either way — a documented "9 files, N scenarios, does not run in CI because …" satisfies it; silence does not). +- [ ] `git rm .travis.yml` (criterion 3). Last, so there is a working replacement before the old intent is deleted. +- [ ] Merge to `develop`; confirm the default branch's most recent run is green (criterion 2). +- [ ] Add a CI badge to `README.markdown` — not an exit criterion, but it is the cheapest way to keep criterion 2 true. + +--- + +## 3. Decisions that need a human + +### D1: `RAILS_ENV` +`bundle exec rake` is broken without it (F1). Options: **(a)** set `RAILS_ENV=test` in CI and document it as the invocation — smallest change, but the repo's documented command stays broken for developers; **(b)** add a `development:` section to `test/dummy/config/database.yml` pointing at `browsercms_development` — fixes the command for everyone, adds a database CI must create; **(c)** default `RAILS_ENV` to `test` in the `Rakefile` when it is unset — fixes the command everywhere, and is a surprising side effect in a file that also builds gems. +**Recommendation: (a) now, (b) as a follow-up issue.** Phase 0 must measure the suite as it is; changing the database configuration changes what is being measured. + +### D2: `lib/generators` coverage +Exclude (honest denominator, ~78.5%, generator regressions stay invisible to the number) versus keep (visible 0% that no reasonable amount of work will move, because the coverage cannot be collected). `TEST_COVERAGE_PLAN.md` §0c recommends excluding and scheduling in-process `Rails::Generators::TestCase` tests later. **Concur** — with the `File.exists?` landmine recorded explicitly so the exclusion is not mistaken for "this code is fine." + +### D3: `test/assumptions_test.rb` +It asserts a precondition (empty database) that `db:install` deliberately violates (F5). Either the assumption is stale — delete the file — or it is right and the seeding is the problem, which is a much larger conversation about the two-cleaning-strategies mess documented at `test/test_helper.rb:32-40`. **Recommendation: delete, and open an issue for the fixture-strategy question**, which properly belongs to [Phase 2](phase-2-harness-migration.md). + +### D4: `flunk` and stub tests +`test/helpers/cms/content_types_helper_test.rb` is a single `flunk "Need real tests"`. The phase file says write it or delete it, and "no new tests" is also a Phase 0 rule. **Recommendation: delete it, and open an issue** — deleting a file that has never asserted anything removes no coverage, and writing it here would be the one exception to the no-new-tests rule for the least valuable possible test. + +--- + +## 4. Contingencies + +| If | Then | +|---|---| +| A large share of Cucumber is red | **Stop and re-scope before Stage F.** This is the finding the README flags as reshaping the whole plan. Report the number, get a decision on repair-versus-retire per feature file, and do not absorb an open-ended repair job inside Phase 0. | +| `ubuntu-22.04` is unavailable | Switch to `container: ruby:2.7.8-bullseye`, `PGHOST: postgres`. Do not upgrade Ruby to satisfy the runner — that is a different project. | +| `features:cli` cannot be made to work in CI | Exclude it, document why in the workflow and the baseline, and still record its local pass rate. Criterion 6 wants the number, not the automation. | +| Coverage does not reproduce ≈72.64% in Stage B | Stage A is wrong, or the four suites are not all reporting. Check `.resultset.json` keys before believing any number (F9, F11). | +| Stage E's warning noise is overwhelming | Land `$VERBOSE`'s removal with `t.warning = false` still set on the tasks, get CI green, then flip the task flags in a follow-up commit. Do not restore `$VERBOSE = nil`. | + +--- + +## 5. Exit criteria traceability + +| # | Criterion | Stage | Verification | +|---|---|---|---| +| 1 | Actions workflow exists, runs on push and PR | C | `ls .github/workflows/`; Actions tab shows a run | +| 2 | Most recent run on default branch is green | H | Actions tab | +| 3 | `.travis.yml` gone | H | `test ! -f .travis.yml` | +| 4 | Baseline written down, per suite | B, F | `docs/rails-upgrade/phase-0-baseline.md` | +| 5 | Zero failures; every skip explained | F | CI green; all 20+ skips annotated | +| 6 | Cucumber pass rate known, `@cli` included | B, H | Three profiles recorded in the baseline (F8) | +| 7 | Coverage reported by CI, baseline committed | G, H | Artifact on the run; `coverage:check` threshold matches the baseline | +| 8 | Generator templates excluded | G | `grep -n add_filter .simplecov`; `demo.seeds.rb` absent from the report | +| 9 | No test file outside the run | D | The D.4 `comm` diff is empty | +| 10 | `$VERBOSE = nil` gone, deprecations visible | E | `grep -n VERBOSE test/test_helper.rb` empty; warnings in the CI log | + +**A note on criterion 5 versus Stage F:** "zero failing tests" is satisfied by quarantining, and quarantining is not cheating — it is the difference between a known hole and an unknown one. But a phase that quarantines half the Cucumber suite has not met its goal, whatever the checklist says. If that is where this lands, say so in the baseline plainly and take it back to whoever owns the schedule. diff --git a/docs/rails-upgrade/phase-1-gem-compatibility-and-dual-boot.md b/docs/rails-upgrade/phase-1-gem-compatibility-and-dual-boot.md new file mode 100644 index 000000000..591075736 --- /dev/null +++ b/docs/rails-upgrade/phase-1-gem-compatibility-and-dual-boot.md @@ -0,0 +1,116 @@ +# Phase 1 — Gem Compatibility and Dual-Boot + +> ## Goal +> **Produce a definitive, data-backed list of which gems block Rails 5.0 — and be able to boot the app under both 4.2 and 5.0 from the same working copy.** +> +> This phase replaces guesswork about gem EOL with resolver output, and it must come *before* the harness migration because its findings determine how much of that migration is actually forced. + +**Blocking:** 🔴 Yes — scopes [Phase 2](phase-2-harness-migration.md), and no bump can happen without dual-boot. +**Rails version at the end of this phase:** 4.2.11.3 still boots and is still the default. `Gemfile.next` resolves to 5.0. + +> ## ✅ Done +> +> **Plan:** [`phase-1-implementation-plan.md`](phase-1-implementation-plan.md) · **Results:** [`phase-1-gem-report.md`](phase-1-gem-report.md) +> +> `Gemfile.next` resolves to **Rails 5.0.7.2** and boots. Default bundle still 4.2.11.3 and green (994 tests, 0 failures, coverage 75.82%). `HTML::FullSanitizer` breakage confirmed. **No hard blockers** — `panoramic` was expected to be one and is not. +> +> **The most important finding is not about gems.** `test/dummy/config/boot.rb` reassigned `BUNDLE_GEMFILE` unconditionally, so every spawned test process silently ran on 4.2. Dual-boot did not apply to the test suite at all, and the failure mode was a *false green* — a `Gemfile.next` CI job on top of it would have reported success through Phase 4 while testing the wrong Rails. +> +> **§1.2's gem table below overstates the work.** `mocha` is one line, not 109 call sites; `compass-rails`/`sass-rails` cap `sass`, not Rails; `devise` is already at 4.9.4; `cucumber-rails` clears 5.0 and blocks 5.1. Corrections are in the report; the table is left as written so the difference between reasoning and measuring stays visible. + +--- + +## Why this phase exists + +**Because hand-reasoning about gem compatibility has already produced a wrong answer, and the tooling produces a right one.** + +The supporting analysis correctly identified that Paperclip is EOL, `factory_girl` was renamed, and `mocha 1.2.0` predates the `mocha/setup` removal. What it *couldn't* see by reading Gemfiles was this: + +`lib/cms/content_filter.rb:12` calls `HTML::FullSanitizer`. That constant does not exist in Rails 4.2. It comes from `rails-deprecated_sanitizer (1.0.4)`, which is in the bundle only because `rails-dom-testing (1.0.9)` depends on it — and `rails-dom-testing 1.x` is capped at `activesupport < 5.0`. **The moment Rails 5 resolves, `rails-dom-testing` jumps to 2.x, `rails-deprecated_sanitizer` leaves the bundle, and that line raises `NameError`.** The file is 100% covered, so coverage says it's safe. A grep for Rails APIs says it's safe. Only actually resolving the bundle and booting reveals it. + +That is one instance of a class of failure — a gem that resolves cleanly but calls or requires something that no longer exists. The skill has two dedicated steps for exactly this (4.5 and 4.6), and this phase is those steps. + +The dual-boot half exists because every subsequent phase needs to answer "does this change work on both versions?" without swapping Gemfiles by hand. + +## Supporting documentation + +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §6](../../RAILS_UPGRADE_TEST_PRIORITY.md) — "Step 2 — Set up dual-boot" and "Step 4.5 / 4.6 — Gem compatibility and boot smoke test" +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §0.2, ➕A3](../../RAILS_UPGRADE_TEST_PRIORITY.md) — the `HTML::FullSanitizer` finding in full +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §3, B4](../../RAILS_UPGRADE_TEST_PRIORITY.md) — why Paperclip is a Step 4.5 question rather than a judgement call +- [`TEST_COVERAGE_ANALYSIS.md` §5.4](../../TEST_COVERAGE_ANALYSIS.md) — dependency gates, including the `cms`-side Rails LTS and Gem Fury sources +- Skill: `workflows/gem-compatibility-workflow.md` (the primary check and when to escalate), `workflows/boot-smoke-test-workflow.md`, `references/gem-compatibility.md` (the fork/vendor/replace playbook — load only if blockers appear) +- Skill: the `dual-boot` skill, for `next_rails --init` and the `NextRails.next?` pattern + +## Work items + +### 1.1 — Set up dual-boot + +- [ ] Add `next_rails` and run `next_rails --init` to generate `Gemfile.next`. Check first that no `Gemfile.next` exists, to avoid a duplicate `next?` method definition. +- [ ] Configure the `Gemfile` / `browsercms.gemspec` with `if next?` conditionals so both versions resolve. +- [ ] Install dependencies for both: default and `BUNDLE_GEMFILE=Gemfile.next`. +- [ ] Add a second CI job that runs the suite against `Gemfile.next`, **allowed to fail** for now. Its output is the running scoreboard for the rest of the upgrade. + +**One rule for all later phases:** when a fix genuinely cannot work on both versions, branch on `NextRails.next?`. Never `respond_to?` or other feature detection. Most fixes in [Phase 3](phase-3-backwards-compatible-fixes.md) need no branch at all. + +### 1.2 — Run the compatibility check (Step 4.5) + +- [ ] Run `bundle_report compatibility` per the skill's workflow against the target Rails version. Escalate to the railsbump API only under the conditions that workflow specifies. +- [ ] Sort every gem into three buckets: **required bumps**, **blockers**, **already compatible**. Commit the result — later phases reference it. +- [ ] At minimum, the following need a verdict. Every one is currently pinned to a version that predates Rails 5: + +| Gem | Pinned | Why it's on the list | +|---|---|---| +| `paperclip` | 5.0 (gemspec) | EOL 2018; the whole attachment subsystem depends on it | +| `mocha` | 1.2.0 | `mocha/setup` removed in 2.0; **109** call sites | +| `factory_girl` / `factory_girl_rails` | 4.7.0 | Renamed `factory_bot` in 2017; **42** references | +| `cucumber` / `cucumber-rails` | 2.4.0 / 1.4.5 | 53 features depend on it | +| `capybara` | 2.10.1 | Selector semantics changed in 3.0 | +| `poltergeist` | 1.11.0 | PhantomJS, abandoned 2018 | +| `database_cleaner` | 1.5.3 | Split into `database_cleaner-active_record` 2.x | +| `aruba` | 0.14.14 | Hard-pinned; drives the `@cli` features | +| `simplecov` | 0.12.0 | 2016; no branch coverage, so every figure so far is line-only | +| `rails-dom-testing` | 1.0.9 | **Capped at `activesupport < 5.0`.** The `HTML::FullSanitizer` chain. | +| `compass-rails`, `sass-rails` | — | Compass EOL 2018 | +| `jquery-rails` | 3.1 | Far behind | +| `devise` | ~> 4.0 | Skill's guide wants 4.2+ for Rails 5; also the only thing supplying `responders` | + +- [ ] If any gem lands in **blockers**, load `references/gem-compatibility.md` and decide fork / vendor / replace per gem. Write the decision down; do not leave it implicit. + +### 1.3 — Boot smoke test (Step 4.6) + +- [ ] Run a Rails-loading command against `Gemfile.next`: `BUNDLE_GEMFILE=Gemfile.next bundle exec rake -T`, or `bin/rails runner "puts Rails.version"`, or `rspec --dry-run` — anything that triggers `Bundler.require` and the framework boot. +- [ ] For each `LoadError` / `NoMethodError` / `NameError`: identify the offending gem, check RubyGems for a version with target-Rails compatibility, and add the bump to the required-bumps bucket. +- [ ] **Confirm `HTML::FullSanitizer` in `lib/cms/content_filter.rb:12` surfaces here.** If the smoke test passes without flagging it, the smoke test isn't loading enough of the app — fix the test, not the expectation. +- [ ] Re-run until Rails boots under `Gemfile.next`. Booting is the bar; the suite passing is [Phase 5](phase-5-the-5.0-bump.md). + +### 1.4 — Record what the check changed + +- [ ] Note any place where the compatibility data contradicts the supporting documents, and update them. The analysis docs were written from working knowledge; this phase produces measurements, and measurements win. + +--- + +## Exit criteria + +| # | Criterion | How to verify | +|---|---|---| +| 1 | `Gemfile.next` exists and resolves | `test -f Gemfile.next && BUNDLE_GEMFILE=Gemfile.next bundle check` | +| 2 | `Gemfile.next` resolves to a Rails 5.0.x | `BUNDLE_GEMFILE=Gemfile.next bundle list \| grep " rails "` | +| 3 | **The default bundle still resolves to 4.2.11.3 and the suite is still green** | `bundle list \| grep " rails "`; CI green on the default job | +| 4 | Rails **boots** under `Gemfile.next` | `BUNDLE_GEMFILE=Gemfile.next bundle exec rake -T` exits 0 | +| 5 | A committed three-bucket gem report exists: required bumps / blockers / already compatible | The file is in the repo and every gem in the table above appears in exactly one bucket | +| 6 | Every gem in the **blockers** bucket has a written fork/vendor/replace decision | Each blocker has a named owner-decision in the report, not a question mark | +| 7 | The `HTML::FullSanitizer` breakage is confirmed by the smoke test and recorded | The smoke-test output naming it is committed alongside the gem report | +| 8 | A CI job runs against `Gemfile.next` | `.github/workflows/` contains a job with `BUNDLE_GEMFILE=Gemfile.next`; it may be failing, but it must run and report | +| 9 | No `respond_to?`-style version branching was introduced | `grep -rn "respond_to?(:.*Rails\|Rails::VERSION" app/ lib/` returns nothing new | + +**Done means:** you can state, from a committed artifact rather than from memory, exactly which gems must move before Rails 5 and which of those have no compatible version — and Rails 5.0 boots, even though the suite doesn't pass yet. + +--- + +## Explicitly not in this phase + +- **No Rails bump on the default Gemfile.** 4.2.11.3 stays the default through Phase 4. +- **Not making the suite pass under `Gemfile.next`.** Booting is the bar. The suite failing there is expected and is precisely what [Phase 2](phase-2-harness-migration.md) fixes. +- **No Paperclip replacement.** Deciding whether it's ActiveStorage, Shrine, or `kt-paperclip` is in scope; *doing* it is not. Paperclip's 5.x line works on Rails 5, so the migration belongs near the 7.2 → 8.0 hop where Propshaft and ActiveStorage land together. +- **No Devise upgrade.** Its verdict is recorded here; the work is Phase 2 (test helpers) and Phase 5 (config). +- **No code fixes.** Even the `HTML::FullSanitizer` line — this phase *finds* it, [Phase 3](phase-3-backwards-compatible-fixes.md) fixes it. diff --git a/docs/rails-upgrade/phase-1-gem-report.md b/docs/rails-upgrade/phase-1-gem-report.md new file mode 100644 index 000000000..01181a2ae --- /dev/null +++ b/docs/rails-upgrade/phase-1-gem-report.md @@ -0,0 +1,315 @@ +# Phase 1 — Gem Report and Boot Smoke Test + +> Exit criteria 5, 6 and 7 of [`phase-1-gem-compatibility-and-dual-boot.md`](phase-1-gem-compatibility-and-dual-boot.md). +> Measurements. Reasoning is in [`phase-1-implementation-plan.md`](phase-1-implementation-plan.md). + +**Date:** 2026-07-29 · **Branch:** `feature/cms-420-migrate-tests` +**Ruby:** 2.7.8 · **Default bundle:** Rails 4.2.11.3 · **`Gemfile.next`:** Rails **5.0.7.2** +**Tooling:** `next_rails` 1.6.0 (`bundle_report`), plus [`script/rails_blockers.rb`](../../script/rails_blockers.rb) + +--- + +## Headline + +| | | +|---|---| +| `Gemfile.next` resolves | ✅ Rails **5.0.7.2**, zero remaining resolution blockers | +| Rails **boots** under `Gemfile.next` | ✅ `rake -T` exits 0; the dummy app loads and prints `5.0.7.2` | +| Default bundle still 4.2 and green | ✅ 994 tests, 0 failures, 0 errors; cucumber 154/154; coverage gate 75.82% | +| `HTML::FullSanitizer` breakage | ✅ Confirmed — see [below](#the-htmlfullsanitizer-confirmation) | +| Hard blockers with no path | **none** — `panoramic` was expected to be one and is not | +| Suite under Rails 5.0 | ❌ Not expected to pass, and does not. **754 unit tests → 2 failures, 323 errors** — but see the shape of that number. | + +**The most consequential finding is not in the gem list.** Dual-boot did not +apply to the test suite at all until `test/dummy/config/boot.rb` was fixed, and +the failure mode was a **false green**. Details in +[The false-green trap](#the-false-green-trap). + +--- + +## The three buckets + +### Blockers — no compatible version exists + +**None.** + +`panoramic` was the expected blocker and survived on a technicality worth +recording, because it shows the two tools disagreeing and the *less* famous one +being right: + +| Tool | Verdict on `panoramic` | +|---|---| +| `bundle_report compatibility` | `panoramic 0.0.7` — **"new version not found"**, i.e. an unfixable blocker | +| Bundler's resolver | Resolved it **down** to `0.0.6`, which declares `rails (>= 3.0.7)` with **no upper bound** | + +`bundle_report` only searches *forward* for a newer compatible release. The cap +was introduced *in* 0.0.7 — the immediately preceding version is open-ended, so +the resolver found a backwards solution that the report is structurally unable +to see. Both tools were needed. + +**Caveat, recorded deliberately:** this is a downgrade of a gem last released in +2013, and 0.0.7 presumably added `rails (~> 4)` for a reason. Nothing in the +suite has yet exercised `panoramic` under Rails 5 — the runs below abort long +before template resolution. `panoramic` is load-bearing: `Cms::DynamicView` +calls its `store_templates` ([`app/models/cms/dynamic_view.rb:3`](../../app/models/cms/dynamic_view.rb#L3)), +and both `Cms::PageTemplate` and `Cms::PagePartial` subclass it. **Treat "not a +blocker" as provisional until a page renders on Rails 5.** + +### Required bumps — a compatible version exists + +Handled in `Gemfile.next` via `next?` / `NEXT_BOOT` conditionals; the 4.2 bundle is untouched. + +| Gem | 4.2 | 5.0 | Notes | +|---|---|---|---| +| `rails` | 4.2.11.3 | 5.0.7.2 | [`browsercms.gemspec`](../../browsercms.gemspec), [`Gemfile`](../../Gemfile) (`railties`) | +| `jquery-rails` | 3.1.5 | 4.6.1 | capped `railties < 5.0`. Asset-only; jQuery API drift is the risk, not Rails | +| `simple_form` | 3.1.1 | 3.5.1 | capped `actionpack ~> 4.0`. Custom inputs in `app/inputs/` ride its API — Phase 2 | +| `rails-dom-testing` | 1.0.9 | 2.3.0 | transitive; drops `rails-deprecated_sanitizer` and with it `HTML::FullSanitizer` | +| `minitest` | 5.19.0 | **5.10.3** (pinned down) | Rails 5.0's own test reporter is incompatible with newer minitest — [below](#the-minitest-reporter-wall) | +| `panoramic` | 0.0.7 | **0.0.6** (resolved down) | see above | + +### Removed + +| Gem | Why | +|---|---| +| `minitest-rails` 2.2.1 | Capped `railties ~> 4.1` — a genuine Rails 5 blocker, and **entirely unused**: every reference in `test/minitest_helper.rb` was already commented out. Deleted rather than bumped. `minitest_helper.rb` itself stays; 19 test files require it. | + +### Already compatible — with notes + +None of these blocks Rails 5.0 resolution. "Compatible" is not "fine": + +| Gem | Locked | Note | +|---|---|---| +| `cucumber-rails` | 1.4.5 | Caps `railties < 5.1`. Clears this hop; **first blocker of hop 2 (5.0 → 5.1)**. | +| `simplecov` | 0.12.0 | Fine on Rails 5. Calls `Fixnum` (`configuration.rb:207`, `source_file.rb:29-30`) — **removed in Ruby 3.2**, so a hard Ruby blocker later. Line coverage only. | +| `poltergeist` | 1.11.0 | Compatible, and **unused**. Zero `@javascript` tags; both driver assignments commented out at [`features/support/env.rb:16-17`](../../features/support/env.rb#L16-L17). Recommend removal. | +| `compass-rails` / `sass-rails` | 4.0.0 / 5.0.6 | Cap **`sass < 3.5`**, not Rails. The 7.2 → 8.0 Propshaft problem, not a 5.0 problem. | +| `devise` | 4.9.4 | Already well past the 4.2+ the skill wants. Work is the `Devise::TestHelpers` deprecation — Phase 2. | +| `paperclip` | 5.3.0 | `activemodel >= 4.2.0`, no upper bound. Compatible through this hop, as planned. | +| `mocha` | 1.2.0 | No Rails cap. `mocha/setup` appears in **one** file ([`test/test_helper.rb:13`](../../test/test_helper.rb#L13)) — a one-line change if bumped, not the 109 sites previously assumed. | +| `factory_girl` / `_rails` | 4.7.0 | No Rails cap. Renaming to `factory_bot` is elective. | +| `capybara` | 2.10.1 | No Rails cap. | +| `database_cleaner` | 1.5.3 | No Rails cap. | +| `aruba` | 0.14.14 | No Rails cap. Hard-pinned; drives the `@cli` features, which are 7/34 (Phase 0 O1). | +| `will_paginate`, `ancestry`, `ckeditor_rails`, `jquery-ui-rails`, `underscore-rails`, `bootstrap-sass`, `actionpack-page_caching`, `responders` | — | No caps. | + +### Tool output, for the record + +`bundle_report compatibility --rails-version=5.0.7`, run before `minitest-rails` was deleted: + +``` +=> Incompatible with Rails 5.0.7 (with new versions that are compatible): +jquery-rails 3.1.5 - upgrade to 4.0.1 +rails-dom-testing 1.0.9 - upgrade to 2.2.0 +simple_form 3.1.1 - upgrade to 3.5.1 + +=> Incompatible with Rails 5.0.7 (with no new versions): +browsercms 5.2.0 - new version not found <- the engine itself; expected +panoramic 0.0.7 - new version not found <- disproved by the resolver + +5 gems incompatible with Rails 5.0.7 +``` + +`bundle_report ruby_check --ruby-version=2.7.8 --rails-version=5.0.7`: + +``` +The required ruby version is >= 2.2.2 for matched rails version 5.0.7 +``` + +The offline scan agrees with the report on every gem. It found one more +(`minitest-rails`) only because it ran first; and it does **not** find +`minitest`, whose incompatibility is behavioural rather than declared. + +--- + +## The false-green trap + +**This is the finding to carry forward.** + +The first Rails 5 probe reported the *entire* suite passing — units, spec, +functionals, orphans and all 154 cucumber scenarios, numbers identical to the +4.2 baseline. That was wrong. Every one of those runs was on **Rails 4.2**. + +[`test/dummy/config/boot.rb`](../../test/dummy/config/boot.rb) assigned +`ENV['BUNDLE_GEMFILE']` to the engine's root `Gemfile` unconditionally. Because +`Rake::TestTask` and cucumber each spawn a **fresh** Ruby process, and in a fresh +process `boot.rb` runs *before* Bundler is set up, the hardcoded path won — +silently overriding `BUNDLE_GEMFILE=Gemfile.next` on the command line. + +It looked like it worked from `bundle exec ruby -e 'require ".../environment"'`, +because there Bundler is already loaded by the time `boot.rb` runs and its +assignment is a no-op. So the *boot* smoke test was honest and the *suite* was +not. + +Fixed by honouring an explicitly-provided value: + +```ruby +default_gemfile = File.expand_path('../../../../Gemfile', __FILE__) +gemfile = ENV['BUNDLE_GEMFILE'] ? File.expand_path(ENV['BUNDLE_GEMFILE']) : default_gemfile +``` + +**Why it matters beyond this phase:** a `Gemfile.next` CI job on top of the +unfixed `boot.rb` would have reported green through Phases 2, 3 and 4 while +testing 4.2 the entire time. Phase 6 warns that the cucumber rate is "the metric +most likely to erode silently"; this was worse — a metric that would have read +*success* while measuring the wrong thing. + +**Verification, for anyone re-running this:** the two Rails versions emit +different deprecations at boot. Grep the log rather than trusting the command +line — `public_file_server` means Rails 5, `serve_static_assets` means 4.2. The +CI job below asserts the version explicitly for the same reason. + +--- + +## The boot smoke test + +### Booting — criterion 4 + +``` +$ BUNDLE_GEMFILE=Gemfile.next RAILS_ENV=test bundle exec rake -T # exit 0 +$ BUNDLE_GEMFILE=Gemfile.next RAILS_ENV=test bundle exec ruby -e \ + 'require "./test/dummy/config/environment"; puts "BOOTED Rails #{Rails.version}"' +BOOTED Rails 5.0.7.2 +``` + +`RAILS_ENV=test` is mandatory — without it the dummy app boots in `development`, +which `test/dummy/config/database.yml` does not define, and the resulting +`AdapterNotSpecified` has nothing to do with Rails 5 (Phase 0, F1). + +### The `HTML::FullSanitizer` confirmation — criterion 7 + +Predicted by the phase document from static reading, and confirmed: + +``` +$ BUNDLE_GEMFILE=Gemfile.next RAILS_ENV=test bundle exec ruby -e \ + 'require "./test/dummy/config/environment"; Cms::ContentFilter.new.filter({title: "x"})' + +lib/cms/content_filter.rb:12:in `block in filter': + uninitialized constant Cms::ContentFilter::HTML (NameError) +``` + +Chain: `rails-dom-testing 1.x` → depends on `rails-deprecated_sanitizer` (which +supplies `HTML::FullSanitizer`) and caps `activesupport < 5.0`. Rails 5 forces +`rails-dom-testing` to 2.x, `rails-deprecated_sanitizer` leaves the bundle, and +the constant is gone. Confirmed absent: `grep -c rails-deprecated_sanitizer +Gemfile.next.lock` → `0`. + +**A correction to the phase doc's framing.** It says the file is 100% covered and +therefore that coverage cannot see this. The first half is true — +`content_filter.rb` is 8/8 lines in the units suite and **line 12 is hit twice**. +The second half needs restating: coverage did not hide this because the line was +unexercised, it hid it because *coverage cannot express version-dependent +resolution*. The test does call the line, and it does fail on Rails 5 — it is +one of the 2 failures in the run below. The lesson is unchanged but sharper: +**a covered line is not a portable line.** + +Phase 3 has two exits, and they are not equivalent: + +- **Fix the call site** to `Rails::Html::FullSanitizer` (or `ActionView::Base.full_sanitizer`) — where it should end up. +- **Declare `rails-deprecated_sanitizer` explicitly.** It requires only `activesupport >= 4.2.0.alpha`, with no upper bound, so the constant survives on Rails 5 unchanged. A one-line unblock that keeps a transition-shim gem alive indefinitely. + +### The minitest reporter wall + +The first attempt to run a suite on Rails 5 died before reporting anything: + +``` +railties-5.0.7.2/lib/rails/test_unit/reporter.rb:70:in `method': + undefined method `test_accessible_to_guests?' for class `Minitest::Result' (NameError) +``` + +Rails 5.0's `Rails::TestUnitReporter` calls `result.method`, which predates +`Minitest::Result` (introduced in minitest 5.11). The next bundle had resolved +minitest to 5.26.1, so the reporter raised while formatting the **first** failure +and took the whole run down — turning any number of real failures into zero +usable output. + +Pinned to `~> 5.10.3` for the next bundle only. This is harness work and +properly belongs to Phase 2; it was done here because without it Phase 1 cannot +report a Rails 5 number at all. + +### The Rails 5.0 unit suite, measured + +With the boot fix and the minitest pin, against a database whose schema was built +by **4.2** migrations: + +``` +754 tests, 657 assertions, 2 failures, 323 errors, 0 skips +``` + +Not a pass, and not expected to be — Phase 1's bar is booting. But the *shape* +is the useful part, and it is far better than 323 suggests: + +| Errors | Cause | +|---|---| +| **320** | `ArgumentError: wrong number of arguments (given 1, expected 0)`, all from **one line**: [`lib/cms/behaviors/versioning.rb:230`](../../lib/cms/behaviors/versioning.rb#L230) | +| 2 | `NameError: uninitialized constant Cms::ContentFilter::HTML` — the finding above | +| 1 | `Before process_action callback :redirect_to_cms_site has not been defined` | + +**99% of the failures are a single method signature.** Rails 4.2 declares +`def create_or_update` (`persistence.rb:502`); Rails 5.0 declares +`def create_or_update(*args, &block)` (`persistence.rb:546`). browsercms +overrides it with the old zero-arity signature, so every save on Rails 5 raises. + +The fix is backwards-compatible — `def create_or_update(*args, &block)` passing +through — so it belongs in [Phase 3](phase-3-backwards-compatible-fixes.md) and +works on 4.2 unchanged. It is plausibly the single highest-leverage change in the +whole upgrade. + +**Caveats on this number.** It is one suite, not five. It ran against a +4.2-built schema — the full `db:drop`/`create`/`migrate`/`seed` chain has *not* +been exercised on Rails 5, deliberately, because a Rails 5 `db:migrate` would +rewrite `test/dummy/db/schema.rb` into Rails 5 format and break the 4.2 suite +that Phase 0 gated. Sequencing that is Phase 5's problem and it is not trivial. + +--- + +## Ruby 2.7 vs Rails 5.0 — D3 answered + +The plan flagged that Rails 5.0 predates Ruby 2.7 and does not officially support +it, as a risk that could re-plan the whole hop sequence. **It is not fatal.** + +- Rails 5.0.7 declares `required_ruby_version >= 2.2.2`, no upper bound. +- The dummy app boots cleanly on 5.0.7.2 under Ruby 2.7.8. +- 754 unit tests execute. None of the errors is a Ruby-vs-Rails incompatibility — + they are one browsercms method override, one missing constant, one callback. +- One accommodation already exists and is worth knowing about: + [`test/dummy/config/boot.rb`](../../test/dummy/config/boot.rb) loads + `cms/extensions/big_decimal` before `rails/all`, because Rails 4.2's + `duplicable.rb` calls the removed `BigDecimal.new` at load time. That patch was + needed for **4.2** on modern Ruby; Rails 5 may not need it. Check whether it + can be dropped rather than carrying it forward by inertia. + +**Verdict: proceed on Ruby 2.7.8.** Revisit if Phase 5 hits framework-internal +failures with no application code in the backtrace. + +--- + +## What changed in the repo + +| Change | Why | +|---|---| +| [`script/rails_blockers.rb`](../../script/rails_blockers.rb) | Offline blocker scan, parameterised by target. `TARGET=5.1.0` already shows `cucumber-rails` as hop 2's addition. Rerunnable at all seven remaining hops. | +| `Gemfile` — `next?` helper, `next_rails` in `:development` | Dual-boot. `Gemfile.next` is a **symlink** to `Gemfile`, so `next?` is the only thing distinguishing the two bundles. | +| `Gemfile` — `railties` and `minitest` conditionals; `minitest-rails` deleted | Two of the six blockers, plus the reporter wall. | +| `browsercms.gemspec` — `NEXT_BOOT` conditional on `rails`, `jquery-rails`, `simple_form` | The gemspec cannot see the Gemfile's `next?` (Bundler evaluates it in a `Gem::Specification` context), so it keys off `BUNDLE_GEMFILE`. **Defaults to the 4.2 branch**, so `gem build` with no bundler environment is byte-identical to before. | +| [`test/dummy/config/boot.rb`](../../test/dummy/config/boot.rb) | The false-green fix. The most important change in the phase. | +| `Gemfile.next`, `Gemfile.next.lock` | Committed so CI can cache on the lockfile. | +| `.github/workflows/ci.yml` — `next-rails` job | Non-gating scoreboard for Phases 2–5. Asserts the Rails version explicitly. | + +`Gemfile.lock`'s only changes are `minitest-rails` out and `next_rails` in. No +incidental bumps — verified by diff, because an accidental bump here would +invalidate the Phase 0 baseline everything else is measured against. + +--- + +## Open items + +| ID | Item | +|---|---| +| **P1-1** | `panoramic` 0.0.6 is unproven under Rails 5. Nothing has rendered a template there. Verify before treating "no blockers" as settled; plan to vendor regardless, since a gem last released in 2013 will block hops 2–8 too. | +| **P1-2** | `create_or_update` arity ([`versioning.rb:230`](../../lib/cms/behaviors/versioning.rb#L230)) — 320 of 323 unit errors. Backwards-compatible; Phase 3, and it should be first. | +| **P1-3** | `HTML::FullSanitizer` ([`content_filter.rb:12`](../../lib/cms/content_filter.rb#L12)) — Phase 3, with the two exits above. | +| **P1-4** | `minitest` pinned to 5.10.3 on the next bundle to work around Rails 5.0's reporter. Revisit at 5.1; it is a pin, not a fix. | +| **P1-5** | The Rails 5 `db:migrate`/`schema.rb` collision. A Rails 5 migration run rewrites the schema dump in a format 4.2 cannot load, so the two bundles cannot share the committed file. Phase 5 needs a plan — most likely gitignoring the dummy schema (Phase 0 O2 proposed this for unrelated reasons). | +| **P1-6** | `cms/extensions/big_decimal` may be unnecessary on Rails 5; check rather than inherit. | +| **P1-7** | `poltergeist` and `minitest-rails`-style dead weight: `poltergeist` is unused and removable. Phase 2. | diff --git a/docs/rails-upgrade/phase-1-implementation-plan.md b/docs/rails-upgrade/phase-1-implementation-plan.md new file mode 100644 index 000000000..f7a2f1386 --- /dev/null +++ b/docs/rails-upgrade/phase-1-implementation-plan.md @@ -0,0 +1,463 @@ +# Phase 1 — Implementation Plan + +**Implements:** [`phase-1-gem-compatibility-and-dual-boot.md`](phase-1-gem-compatibility-and-dual-boot.md) +**Entry condition:** Phase 0 complete — suite green, CI running, baseline committed ([`phase-0-baseline.md`](phase-0-baseline.md)). +**Rails at the end of this phase:** `Gemfile` still 4.2.11.3 and still green. `Gemfile.next` resolves to 5.0.x and **boots**. The suite does not pass there, and is not expected to. + +Same shape as the [Phase 0 plan](phase-0-implementation-plan.md): findings first, then an ordered work stream, then the decisions that need a human. + +> ### Status: ✅ done +> Measured results are in [`phase-1-gem-report.md`](phase-1-gem-report.md); that file, not this one, is the record. All 9 exit criteria pass. Where execution contradicted the plan: +> +> | Plan said | Reality | +> |---|---| +> | `panoramic` is "likely the only true blocker"; test a fork first ([D2](#d2-panoramic)) | **Not a blocker.** Bundler resolved it *down* to 0.0.6, which declares `rails (>= 3.0.7)` with no upper bound — the cap was introduced in 0.0.7. No fork needed. `bundle_report` could not see this: it only searches *forward* for newer compatible versions. | +> | The blocker list is six gems | Six for resolution, but a **seventh** blocks actually running anything: `minitest`. Rails 5.0's own test reporter predates `Minitest::Result`, so on minitest ≥ 5.11 it raises while formatting the first failure and takes the run down. Declared caps cannot express this; only running it finds it. | +> | Ruby 2.7 vs Rails 5.0 is a live risk that could re-plan the hop sequence ([§1.5](#15-the-risk-the-phase-doc-does-not-mention)) | **Not fatal.** Boots and executes 754 tests; no failure has a framework-only backtrace. Proceed on 2.7.8. | +> | `HTML::FullSanitizer` is invisible to coverage because the file is 100% covered | Sharper than that: line 12 **is** hit twice by the units suite, and it **does** fail on Rails 5. Coverage did not miss an unexercised line — it cannot express version-dependent resolution. *A covered line is not a portable line.* | +> +> **The plan missed the finding that mattered most.** `test/dummy/config/boot.rb` reassigned `BUNDLE_GEMFILE` unconditionally, so every `Rake::TestTask` and cucumber subprocess silently ran on 4.2. The first Rails 5 probe reported the entire suite passing with numbers identical to the 4.2 baseline — because it *was* the 4.2 baseline. Stage D's verification step (`rake -T` plus an app boot) was not enough; only diffing the boot deprecations between the two versions exposed it. The CI job now asserts `Rails.version` explicitly for that reason. + +> ### The useful thing happened before this plan was written +> +> `Gemfile.lock` already contains the answer to most of work item 1.2. Every gem +> declares its Rails-component requirements in the lock, so **which gems exclude +> Rails 5.0.0 is computable locally, offline, in about a second** — no +> `bundle_report`, no network, no resolver. [§1](#1-pre-flight-findings) is that +> computation. +> +> This does not make 1.2 redundant. The lock shows *resolution* blockers; it is +> blind to gems that resolve cleanly and then raise at runtime — which is the +> entire point of the `HTML::FullSanitizer` example, and the class of failure +> this phase exists to catch. But it means you start Phase 1 with the blocker +> list already in hand, and `bundle_report` becomes a check on it rather than a +> discovery exercise. + +--- + +## 1. Pre-flight findings + +Computed against `Gemfile.lock` at `b00c2c04` + the Phase 0 commits. The script is in [A.0](#a0--reproduce-the-blocker-scan). + +### 1.1 The complete Rails 5.0 resolution-blocker list + +Six gems, excluding the Rails components themselves. **This is the phase's central artifact and it is already done.** + +| # | Gem | Requirement excluding 5.0 | Pinned at | Resolution path | +|---|---|---|---|---| +| **B1** | `browsercms` 5.2.0 | `rails (~> 4.2.0)` | [`browsercms.gemspec:33`](../../browsercms.gemspec#L33) | **Conditionalize.** The engine caps itself. Nothing else can be tested until this moves — see [D1](#d1-how-to-conditionalize-the-gemspec). | +| **B2** | `panoramic` 0.0.7 | `rails (~> 4)` | [`browsercms.gemspec:44`](../../browsercms.gemspec#L44) | **Likely the only true blocker.** Last released 2013, no Rails 5 version exists. Runtime dependency, not a test gem — it supplies database-backed view resolution, which is how `Cms::PageTemplate` / `Cms::PagePartial` render at all. Needs a fork/vendor/replace decision ([D2](#d2-panoramic)). | +| **B3** | `jquery-rails` 3.1.5 | `railties (>= 3.0, < 5.0)` | [`browsercms.gemspec:41`](../../browsercms.gemspec#L41) (`~> 3.1`) | Bump to 4.x. Asset-only; the risk is jQuery API drift in CMS JavaScript, not Rails. | +| **B4** | `simple_form` 3.1.1 | `actionpack (~> 4.0)`, `activemodel (~> 4.0)` | [`browsercms.gemspec:47`](../../browsercms.gemspec#L47) (`~> 3.1.0`) | Bump to 3.5+. Custom inputs under `app/inputs/` ride on its API — expect real work, but in Phase 2, not here. | +| **B5** | `minitest-rails` 2.2.1 | `railties (~> 4.1)` | [`Gemfile:25`](../../Gemfile#L25) | **Delete it.** Every reference in `test/minitest_helper.rb` is commented out (lines 12, 22, 38, 46, 51). The gem is unused. Cheapest blocker in the list — note that `minitest_helper.rb` itself is required by 19 test files and must stay. | +| **B6** | `rails-dom-testing` 1.0.9 | `activesupport (>= 4.2.0, < 5.0)` | transitive | Resolves to 2.x on its own. **This is the `HTML::FullSanitizer` chain, confirmed** — see [1.2](#12-the-htmlfullsanitizer-chain-is-real). | + +Also pinned and needing conditionals to let `Gemfile.next` resolve, though not blockers themselves: [`Gemfile:10`](../../Gemfile#L10) `gem 'railties', '~> 4.2'`. + +### 1.2 The `HTML::FullSanitizer` chain is real + +Confirmed exactly as [`phase-1`](phase-1-gem-compatibility-and-dual-boot.md) describes. [`lib/cms/content_filter.rb:12`](../../lib/cms/content_filter.rb#L12) calls `HTML::FullSanitizer.new.sanitize`, and the lock shows the chain: + +``` +rails-dom-testing (1.0.9) + rails-deprecated_sanitizer (>= 1.0.1) <- supplies HTML::FullSanitizer + activesupport (>= 4.2.0, < 5.0) <- forces the 1.x line out on Rails 5 +``` + +One nuance worth having before Phase 3 writes the fix: **`rails-deprecated_sanitizer 1.0.4` declares `activesupport (>= 4.2.0.alpha)` with no upper bound.** So there are two exits, and they are not equivalent — + +- *Declare it explicitly* in the Gemfile and `HTML::FullSanitizer` keeps working on Rails 5 unchanged. A one-line unblock that adds a dependency whose entire purpose was easing the 4.2→5.0 transition. +- *Fix the call site* to `Rails::Html::FullSanitizer` (or `ActionView::Base.full_sanitizer`), which is where it should end up. + +Phase 1 only needs to know the second exists. Recording both here so Phase 3 does not rediscover it under time pressure. Note the file is 100% covered — coverage will not tell you about this. + +### 1.3 Where the phase doc's gem table overstates the work + +The doc's §1.2 table lists 13 gems needing a verdict. Measured against the lock, most are not resolution blockers at their **currently locked** versions: + +| Doc's claim | Measured | Consequence | +|---|---|---| +| `mocha` 1.2.0 — "`mocha/setup` removed in 2.0; **109** call sites" | `mocha/setup` appears in **exactly one file**: [`test/test_helper.rb:13`](../../test/test_helper.rb#L13). The ~113 figure is `expects`/`stubs`/`mock`/`any_instance` **API** calls, which survive a 2.x bump. | Mocha is a **one-line** change (`require 'mocha/setup'` → `'mocha/minitest'`), not a 109-site migration. And it does not block Rails 5 at all — 1.2.0 has no Rails cap. | +| `compass-rails`, `sass-rails` — "Compass EOL 2018" | They cap **`sass < 3.5`**, not Rails. Neither excludes Rails 5.0. | Not a 5.0 problem. It is the Sprockets/Propshaft problem [`phase-6:59`](phase-6-subsequent-hops.md) already assigns to the 7.2 → 8.0 hop. Record and defer. | +| `devise` — "skill wants 4.2+ for Rails 5" | `~> 4.0` already resolves to **4.9.4**. | Already compatible. The real work is the `Devise::TestHelpers` → `Devise::Test::ControllerHelpers` deprecation Phase 0 surfaced — Phase 2. | +| `paperclip` — EOL, "the whole attachment subsystem" | 5.3.0, requires `activemodel >= 4.2.0`, no upper bound. | Compatible through the 5.0 hop, as the doc's own "not in this phase" note says. Confirmed rather than assumed. | +| `cucumber` / `cucumber-rails` — "53 features depend on it" | `cucumber-rails 1.4.5` caps `railties (>= 3, < 5.1)`. **5.0 satisfies it.** | Not a 5.0 blocker — it is the **first blocker of hop 2 (5.0 → 5.1)**. Worth knowing now: it buys Phase 2 room to sequence the cucumber stack after the first bump rather than before it. | +| `capybara`, `poltergeist`, `aruba`, `database_cleaner`, `simplecov`, `factory_girl` | No Rails cap on any of them. | None blocks resolution. Each is still *harness* work — but that is Phase 2's budget, and Phase 1 should not report them as Rails 5 blockers. | + +**Net:** the doc's 13-gem verdict list is really **one probable blocker (`panoramic`), three routine bumps, one deletion, one self-cap, and one transitive drop-out.** That is a materially smaller Phase 1 than the doc implies — and a materially different Phase 2, because most of that table turns out to be harness modernisation with no Rails deadline attached. + +### 1.4 Two findings carried forward from Phase 0 + +| Finding | Why it matters here | +|---|---| +| **Poltergeist is never used.** Zero `@javascript` tags; both driver assignments commented out at [`features/support/env.rb:16-17`](../../features/support/env.rb#L16-L17); suite green in CI with no browser installed. | The doc lists `poltergeist` as needing a verdict. The verdict is nearly free: **remove it.** Nothing selects the driver. This shrinks Phase 2's driver migration from "port 53 features to a new driver" to "delete a gem and a require." | +| **`simplecov` 0.12.0 calls `Fixnum`** (`configuration.rb:207`, `source_file.rb:29-30`). | Deprecated on 2.7, **removed in Ruby 3.2** → `NameError`. Not a Rails 5 blocker, but it is a hard Ruby blocker further out. It belongs in the report's third bucket with a note, not omitted because it passes today. | + +### 1.5 The risk the phase doc does not mention + +**Rails 5.0 was released before Ruby 2.7 existed, and does not officially support it.** The repo is pinned to Ruby 2.7.8 ([`.ruby-version`](../../.ruby-version), [`Gemfile:3`](../../Gemfile#L3)). Rails 5.0.x on Ruby 2.7 is known to hit removed-method and keyword-argument problems that have nothing to do with browsercms. + +Phase 1's bar is only that Rails **boots**, so this may not bite until [Phase 5](phase-5-the-5.0-bump.md) tries to make the suite pass. But Phase 1 is where it first becomes observable, and if it is fatal the whole hop sequence needs re-planning. Treat it as a first-class output of [Stage D](#stage-d--boot-smoke-test-13) — see [D3](#d3-ruby-27-vs-rails-50) and [contingencies](#4-contingencies). + +--- + +## 2. Execution order + +| Stage | Work item | Produces | Size | +|---|---|---|---| +| **A** | — | Blocker scan reproduced and committed as a script; `next_rails` installed | S | +| **B** | 1.1 | `Gemfile.next` resolving to 5.0.x — the six caps conditionalized or removed | M | +| **C** | 1.2 | `bundle_report compatibility` run; three-bucket report committed | M | +| **D** | 1.3 | Rails boots under `Gemfile.next`; `HTML::FullSanitizer` confirmed; Ruby-2.7 question answered | **M–L / unknown** | +| **E** | 1.1 (CI) | Third CI job on `Gemfile.next`, non-gating | S | +| **F** | 1.4 | Supporting docs corrected where measurement contradicted them | S | + +Two deviations from the doc's numbering, both for the same reason — the pre-flight scan moved information earlier: + +- **The compatibility check (C) comes after dual-boot (B), not before.** The doc has this order already; what changes is *why* B is achievable first. You do not need `bundle_report` to know which caps to conditionalize — [§1.1](#11-the-complete-rails-50-resolution-blocker-list) lists them. +- **Stage D is the unbounded one**, and it is where `panoramic` and the Ruby-2.7 question resolve. Everything before it is mechanical. + +--- + +## Stage A — Reproduce the scan, install the tooling + +### A.0 — Reproduce the blocker scan + +Commit this as `script/rails_blockers.rb` so it is rerunnable at every hop rather than a one-off in a chat log. Change `TARGET` per hop — Phase 6 will want it six more times. + +```ruby +#!/usr/bin/env ruby +# Which locked gems declare a Rails-component requirement that excludes TARGET? +# Answers the resolution half of the gem-compatibility question offline, with no +# resolver and no network. Blind to gems that resolve and then raise at runtime +# -- that is what the boot smoke test is for. +require "rubygems" + +TARGET = Gem::Version.new(ENV.fetch("TARGET", "5.0.0")) +COMPONENTS = %w[rails railties activesupport actionpack activerecord activemodel actionview].freeze +CORE = COMPONENTS + %w[actionmailer activejob] + +current, blockers = nil, {} +File.readlines("Gemfile.lock").each do |line| + if line =~ /^ ([a-zA-Z0-9_\-]+) \(([^)]+)\)$/ + current = [$1, $2] + elsif line =~ /^ (#{COMPONENTS.join('|')}) \((.+)\)$/ + name, constraint = $1, $2 + next if CORE.include?(current&.first) + req = begin + Gem::Requirement.new(constraint.split(",").map(&:strip)) + rescue StandardError + next + end + (blockers[current] ||= []) << "#{name} (#{constraint})" unless req.satisfied_by?(TARGET) + end +end + +puts "Gems whose Rails requirement excludes #{TARGET}:" +blockers.sort.each { |(gem, version), deps| puts format(" %-24s %-10s %s", gem, version, deps.join("; ")) } +puts " (none)" if blockers.empty? +exit(blockers.empty? ? 0 : 1) +``` + +`TARGET=5.1.0 ruby script/rails_blockers.rb` is how you find out that `cucumber-rails` is hop 2's problem before hop 2 starts. + +- [ ] Script committed; output matches [§1.1](#11-the-complete-rails-50-resolution-blocker-list) (6 gems) + +### A.1 — Install `next_rails` + +`next_rails` supplies both halves of this phase: `next_rails --init` for dual-boot and `bundle_report compatibility` for Stage C. Neither `next_rails`, `bootboot`, nor `appraisal` is currently installed. + +Add to the `:development` group in the `Gemfile`, and **not** to the gemspec — it is a maintainer tool, not a dependency of the engine. + +- [ ] `gem 'next_rails'` in the development group; `bundle install`; default bundle still resolves to 4.2.11.3 +- [ ] `bundle exec next_rails --version` and `bundle exec bundle_report --help` both work + +### A.2 — Generate `Gemfile.next`, then check what it actually is + +```bash +test -f Gemfile.next && echo "STOP: already exists" # the doc's warning: avoid a duplicate next? definition +bundle exec next_rails --init +ls -l Gemfile.next && cat Gemfile.next +``` + +**Verify rather than assume the mechanism.** Current `next_rails` makes `Gemfile.next` a *symlink* to `Gemfile` plus a separate `Gemfile.next.lock`, and injects a `next?` helper that keys off which filename Bundler loaded. [D1](#d1-how-to-conditionalize-the-gemspec) depends on this being true — if `--init` instead writes a standalone copy, the gemspec conditional below is unnecessary and the two files diverge by hand instead. + +- [ ] `Gemfile.next` exists; its nature (symlink vs. copy) recorded +- [ ] A `next?` helper is defined exactly once + +--- + +## Stage B — Make `Gemfile.next` resolve (1.1) + +Six caps, four kinds of fix. Do them in this order — B1 first, because until the engine stops capping itself nothing else is observable. + +### B.1 — The gemspec's four constraints + +`browsercms.gemspec` carries **four** of the six blockers: `rails ~> 4.2.0` (B1), `jquery-rails ~> 3.1` (B3), `simple_form ~> 3.1.0` (B4), and `panoramic` (B2, unconstrained but capped by its own 0.0.7). + +The gemspec cannot call the Gemfile's `next?` helper — Bundler evaluates it in a `Gem::Specification` context that has never heard of it. Use the environment, and **default to the 4.2 branch** so a plain `gem build` is byte-identical to today's release: + +```ruby +# browsercms.gemspec +# +# Dual-boot: the Gemfile's next? helper is not in scope here -- Bundler evaluates +# this file in a Gem::Specification context. Key off BUNDLE_GEMFILE instead, and +# default to the 4.2 branch so `gem build` with no bundler environment produces +# exactly what it produced before this phase. +next_boot = ENV["BUNDLE_GEMFILE"].to_s.end_with?("Gemfile.next") + +if next_boot + s.add_dependency("rails", "~> 5.0.0") + s.add_dependency("jquery-rails", "~> 4.0") + s.add_dependency("simple_form", "~> 3.5") +else + s.add_dependency("rails", "~> 4.2.0") + s.add_dependency("jquery-rails", "~> 3.1") + s.add_dependency("simple_form", "~> 3.1.0") +end +``` + +This is a decision, not the only option — see [D1](#d1-how-to-conditionalize-the-gemspec). + +### B.2 — The Gemfile's `railties` pin + +[`Gemfile:10`](../../Gemfile#L10) is `gem 'railties', '~> 4.2'`. Here `next?` *is* in scope: + +```ruby +gem 'railties', next? ? '~> 5.0.0' : '~> 4.2' +``` + +### B.3 — Delete `minitest-rails` (B5) + +Remove [`Gemfile:25`](../../Gemfile#L25). It is unused — every reference in `test/minitest_helper.rb` is commented out. Confirm before and after: + +```bash +grep -rn 'minitest/rails\|MiniTest::Rails' test/ spec/ features/ lib/ | grep -v '^\s*#' +RAILS_ENV=test bundle exec rake # still green on 4.2 +``` + +Keep `test/minitest_helper.rb` — 19 test files require it. + +### B.4 — `panoramic` (B2) + +No Rails 5 version exists, so there is nothing to conditionalize. `Gemfile.next` **will not resolve** until [D2](#d2-panoramic) is decided and acted on. This is the stage's real gate; expect to arrive here quickly and then stop. + +Interim move that keeps Stage C and D unblocked while D2 is being decided: point `Gemfile.next` at a git ref or path override for `panoramic` so resolution completes, and mark it loudly in the report as unresolved. Booting on a fork you have not committed to is still more informative than not booting. + +### B.5 — Resolve both bundles + +```bash +bundle install # 4.2 -- must not change +BUNDLE_GEMFILE=Gemfile.next bundle install # 5.0 +bundle list | grep " rails " # criterion 3 +BUNDLE_GEMFILE=Gemfile.next bundle list | grep " rails " # criterion 2 +TARGET=5.0.0 ruby script/rails_blockers.rb # expect: none, on the next lock +RAILS_ENV=test bundle exec rake # criterion 3: still green +``` + +- [ ] `Gemfile.next.lock` resolves to a Rails 5.0.x +- [ ] `Gemfile.lock` **unchanged apart from the `minitest-rails` removal** — inspect the diff; an incidental bump here silently changes what Phase 0 measured +- [ ] Default suite still green, coverage gate still at 75.82% + +--- + +## Stage C — The compatibility report (1.2) + +### C.1 — Run it + +```bash +bundle exec bundle_report compatibility --rails-version 5.0 | tee tmp/phase1/compatibility.txt +bundle exec bundle_report outdated | tee tmp/phase1/outdated.txt +``` + +Escalate to the railsbump API only under the conditions the skill's `gem-compatibility-workflow.md` specifies — not by default. + +### C.2 — Commit the three-bucket report + +Create `docs/rails-upgrade/phase-1-gem-report.md`. Criterion 5 requires every gem from the doc's table to appear in **exactly one** bucket; the pre-flight work means most rows are already known: + +| Bucket | Means | Expected members from [§1.1](#11-the-complete-rails-50-resolution-blocker-list) / [§1.3](#13-where-the-phase-docs-gem-table-overstates-the-work) | +|---|---|---| +| **Blockers** | No version exists that supports the target | `panoramic` (pending [D2](#d2-panoramic)) | +| **Required bumps** | A compatible version exists; we must move to it | `jquery-rails` → 4.x · `simple_form` → 3.5+ · `rails-dom-testing` → 2.x (automatic) · `minitest-rails` (resolved by deletion) | +| **Already compatible** | Locked version is fine for 5.0 | `devise` 4.9.4 · `paperclip` 5.3.0 · `mocha` · `factory_girl` · `capybara` · `aruba` · `database_cleaner` · `simplecov` · `cucumber`/`cucumber-rails` · `compass-rails` · `sass-rails` · `will_paginate` · `ancestry` · `ckeditor_rails` · `jquery-ui-rails` · `actionpack-page_caching` | + +"Already compatible" must not be read as "fine." Each of these carries a note: + +- `cucumber-rails` — compatible with 5.0, **blocks 5.1**. First blocker of hop 2. +- `simplecov` 0.12.0 — fine on Rails 5, **dies on Ruby 3.2** (`Fixnum`). Line coverage only. +- `poltergeist` — compatible and **unused**; recommend removal ([§1.4](#14-two-findings-carried-forward-from-phase-0)). +- `compass-rails` / `sass-rails` — caps `sass < 3.5`; the 7.2 → 8.0 problem. +- `mocha` / `factory_girl` / `capybara` / `database_cleaner` — no Rails deadline; Phase 2 harness work by choice, not by force. + +- [ ] Report committed; every gem in the phase doc's table appears exactly once +- [ ] Every blocker has a named fork/vendor/replace decision, not a question mark (criterion 6) +- [ ] Where `bundle_report` contradicts [§1.1](#11-the-complete-rails-50-resolution-blocker-list), **the report wins** — and the contradiction is written down, because it means the scan script has a bug worth fixing before Phase 6 relies on it six more times + +--- + +## Stage D — Boot smoke test (1.3) + +The unbounded stage. + +### D.1 — Boot it + +```bash +BUNDLE_GEMFILE=Gemfile.next RAILS_ENV=test bundle exec rake -T > tmp/phase1/boot.log 2>&1; echo "exit=$?" +BUNDLE_GEMFILE=Gemfile.next RAILS_ENV=test bundle exec ruby -e \ + 'require "./test/dummy/config/environment"; puts Rails.version' +``` + +`rake -T` is the doc's suggested bar, but note what Phase 0 established: `rake -T` loads the Rakefile and `engine.rake`, which does **not** boot the dummy app. The second command does. Run both — the second is what actually exercises `Bundler.require` plus the full initializer chain, and it is the one that will surface `HTML::FullSanitizer`-class failures. + +Also from Phase 0: **`RAILS_ENV=test` is mandatory.** Without it the dummy app boots in `development`, which `test/dummy/config/database.yml` does not define, and you get `AdapterNotSpecified` — a red herring that has nothing to do with Rails 5. + +### D.2 — Triage each failure + +For every `LoadError` / `NameError` / `NoMethodError`: name the gem, check RubyGems for a target-compatible version, add it to the required-bumps bucket, re-run. Iterate until Rails boots. + +### D.3 — Confirm `HTML::FullSanitizer` (criterion 7) + +The doc is emphatic and correct: if the smoke test passes without flagging it, **the smoke test is not loading enough of the app — fix the test, not the expectation.** `lib/cms/content_filter.rb` is only loaded when the filter runs, so a boot that merely initializes Rails may never touch line 12. Force it: + +```bash +BUNDLE_GEMFILE=Gemfile.next RAILS_ENV=test bundle exec ruby -e \ + 'require "./test/dummy/config/environment"; Cms::ContentFilter.new.filter({title: "x"})' +``` + +Expect `NameError: uninitialized constant HTML`. Commit that output next to the report. + +### D.4 — Answer the Ruby 2.7 question ([§1.5](#15-the-risk-the-phase-doc-does-not-mention)) + +While triaging D.2, classify each failure as **browsercms's problem** or **Rails-5.0-on-Ruby-2.7's problem**. The second kind looks like removed core methods or keyword-argument errors raised from inside `activesupport`/`actionpack` frames with no application code in the backtrace. + +This is a genuine Phase 1 output, because it determines whether the hop sequence is viable as planned. Record the verdict in the report either way — including "no such failures observed," which is the good outcome and equally worth committing. + +- [ ] Rails boots under `Gemfile.next` (criterion 4) +- [ ] `HTML::FullSanitizer` failure captured and committed (criterion 7) +- [ ] Every boot failure classified: app / gem / Ruby-vs-Rails +- [ ] No `respond_to?`-style version branching introduced (criterion 9) + +--- + +## Stage E — CI on `Gemfile.next` (1.1, last item) + +A third job in [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml), non-gating. Its output is the scoreboard for Phases 2–5. + +```yaml + next-rails: + name: Rails 5.0 (Gemfile.next, reporting only) + runs-on: ubuntu-22.04 + timeout-minutes: 45 + # Expected red for the whole of Phases 1-4. Booting is Phase 1's bar; the + # suite passing is Phase 5. Make it gating there, not before. + continue-on-error: true + + services: + postgres: + image: postgres:15 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + + env: + BUNDLE_GEMFILE: Gemfile.next + RAILS_ENV: test + PGHOST: localhost + PGPORT: '5432' + PGUSER: postgres + launch_on_failure: 'false' + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7.8' + bundler: '1.17.3' + bundler-cache: true + + # Phase 1's actual bar. Split from the suite so a boot failure is + # distinguishable from a test failure at a glance in the job list. + - name: Boot under Rails 5.0 + run: bundle exec ruby -e 'require "./test/dummy/config/environment"; puts Rails.version' + + - name: Suite (expected red until Phase 5) + if: always() + run: bundle exec rake +``` + +Notes on the choices: + +- **`BUNDLE_GEMFILE` at job level**, so `ruby/setup-ruby`'s `bundler-cache` keys on the right lockfile. `Gemfile.next.lock` must be committed for the cache to work. +- **Boot and suite are separate steps.** Phase 1 is judged on the first; keeping them apart means the job list tells you which one regressed without opening a log. +- **`bundler: '1.17.3'`** — if `Gemfile.next` needs Bundler 2 for a modern gem, that is a finding for the report, not something to paper over here. +- **`launch_on_failure: 'false'`** for the same reason as the other jobs: the failure hook shells out to a browser that does not exist on the runner. + +- [ ] Job runs and reports (criterion 8); red is expected and fine +- [ ] `Gemfile.next.lock` committed + +--- + +## Stage F — Correct the record (1.4) + +Measurement beats working knowledge, and this phase produces measurements that contradict the supporting documents. Update them rather than leaving future readers to rediscover: + +- [ ] [`phase-1-gem-compatibility-and-dual-boot.md`](phase-1-gem-compatibility-and-dual-boot.md) §1.2's table — the mocha "109 call sites" figure, the compass/sass verdict, devise's actual version, and cucumber-rails's `< 5.1` cap ([§1.3](#13-where-the-phase-docs-gem-table-overstates-the-work)) +- [ ] [`RAILS_UPGRADE_TEST_PRIORITY.md`](../../RAILS_UPGRADE_TEST_PRIORITY.md) §3 B4 — Paperclip's verdict, now measured rather than reasoned +- [ ] [`phase-2-harness-migration.md`](phase-2-harness-migration.md) — the largest re-scope. Its §2.4 assumes a Poltergeist driver migration; there is no driver to migrate. And most of its gem list turns out to have no Rails deadline, which changes what is forced versus chosen. +- [ ] [`phase-6-subsequent-hops.md`](phase-6-subsequent-hops.md) — record `cucumber-rails` as hop 2's known first blocker + +--- + +## 3. Decisions that need a human + +### D1: How to conditionalize the gemspec +**(a)** ENV-conditional inside the gemspec, defaulting to 4.2 — contained, reversible, `gem build` unchanged; but the gemspec's meaning now depends on the environment, which is a genuine wart in a file that defines a published artifact. +**(b)** Move the Rails-ish dependencies out of the gemspec into the `Gemfile` under `if next?` — cleaner conditionals; but the published gem would stop declaring a `rails` dependency, which is worse for consumers than the wart. +**(c)** Loosen the gemspec to `rails >= 4.2, < 6` and let each Gemfile pin — where an engine should end up; but it advertises Rails 5 support that will not exist until Phase 5. +**Recommendation: (a) now, (c) at Phase 5** when the claim becomes true. (b) trades a small internal wart for a real external regression. + +### D2: `panoramic` +The only hard blocker, an unmaintained 2013 runtime dependency, and load-bearing: it provides the database-backed view resolver behind `Cms::PageTemplate` and `Cms::PagePartial`. Options, in ascending cost: +- **Fork and bump the constraint.** `rails (~> 4)` may be pessimism rather than a real incompatibility — 0.0.7 is a small gem. Cheapest, and the first thing to test: vendor it, relax the constraint, boot, see what breaks. +- **Vendor it into the engine.** browsercms is the only consumer that matters here; a resolver is a few hundred lines, and owning it removes a dependency that will block every remaining hop. +- **Replace it** with a `ActionView::Resolver` subclass written against modern Rails. Correct destination, largest cost, and it is Phase 2/3 work regardless of what Phase 1 decides. + +**Recommendation: test the fork first** — it is an afternoon and it either unblocks the whole phase or proves the incompatibility is real, which is exactly the information D2 needs. Then plan to vendor, because a gem last released in 2013 will block hops 2 through 8 as well. + +### D3: Ruby 2.7 vs Rails 5.0 +Only actionable once [D.4](#d4--answer-the-ruby-27-question-15) reports. If Rails 5.0 proves unworkable on Ruby 2.7.8, the choices are: pin Ruby *down* for the intermediate hops and raise it at the end; or bump Ruby *first*, to a version 5.0 and 5.2 both tolerate; or accept a broken middle and use 5.0 purely as a resolution waypoint without a green suite. **Do not decide in advance** — but do surface it the moment the data exists, because the third option quietly voids Phase 5's exit criteria. + +--- + +## 4. Contingencies + +| If | Then | +|---|---| +| `panoramic` cannot be forked cheaply | Stop and get a decision on vendor-vs-replace before continuing. Do not carry a path-override fork into Phase 2 as if it were resolved — that converts a known blocker into an invisible one. | +| Rails 5.0 will not run on Ruby 2.7.8 | Raise it immediately; it re-plans the hop sequence, not just this phase. See [D3](#d3-ruby-27-vs-rails-50). | +| `bundle_report` contradicts the scan script | The report wins. Fix the script and record the bug — Phase 6 reruns it six times. | +| The default `Gemfile.lock` drifts during Stage B | Revert and redo. Criterion 3 is that 4.2 stays green and unchanged; an incidental bump invalidates the Phase 0 baseline everything else is measured against. | +| `Gemfile.next` needs Bundler 2 | Record it as a finding and pin per-job in CI rather than raising the default. Changing the default Bundler changes the 4.2 build too. | + +--- + +## 5. Exit criteria traceability + +| # | Criterion | Stage | Verification | +|---|---|---|---| +| 1 | `Gemfile.next` exists and resolves | B | `test -f Gemfile.next && BUNDLE_GEMFILE=Gemfile.next bundle check` | +| 2 | It resolves to Rails 5.0.x | B | `BUNDLE_GEMFILE=Gemfile.next bundle list \| grep " rails "` | +| 3 | Default still 4.2.11.3 and green | B | `bundle list \| grep " rails "`; `rake` green; coverage still 75.82% | +| 4 | Rails **boots** under `Gemfile.next` | D | `rake -T` exits 0 **and** the dummy app environment loads | +| 5 | Committed three-bucket report | C | `phase-1-gem-report.md`; every gem in the doc's table appears exactly once | +| 6 | Every blocker has a fork/vendor/replace decision | C, D2 | Named decisions, no question marks | +| 7 | `HTML::FullSanitizer` confirmed by the smoke test | D.3 | Committed output naming it | +| 8 | A CI job runs against `Gemfile.next` | E | Job present and reporting; may be red | +| 9 | No `respond_to?`-style version branching | B, D | `grep -rn "respond_to?(:.*Rails\|Rails::VERSION" app/ lib/` shows nothing new | + +**A note on criterion 4 versus the goal.** "Boots" is a low bar deliberately, and it is worth being honest that clearing it with a path-overridden `panoramic` fork is not the same as clearing it for real. If that is how the phase ends, say so plainly in the report — the difference is the whole of [D2](#d2-panoramic), and it will be much more expensive to discover in Phase 5. diff --git a/docs/rails-upgrade/phase-2-harness-migration.md b/docs/rails-upgrade/phase-2-harness-migration.md new file mode 100644 index 000000000..9db4204cb --- /dev/null +++ b/docs/rails-upgrade/phase-2-harness-migration.md @@ -0,0 +1,123 @@ +# Phase 2 — Harness Migration + +> ## Goal +> **Make the test suite capable of running on Rails 5, while still on Rails 4.2, without losing a single test.** +> +> The suite does not currently *fail* on Rails 5 — it does not boot. Until that's fixed, no test result from the upgrade means anything, and any test written before this phase gets written twice. + +**Blocking:** 🔴 Yes. +**Rails version at the end of this phase:** 4.2.11.3, with the suite green on **both** the default Gemfile and `Gemfile.next`. + +> ## ⚠️ Re-scope before starting — Phases 0 and 1 changed the premises +> +> **The goal statement above is now wrong on its central claim.** "The suite does not currently *fail* on Rails 5 — it does not boot" is out of date: [Phase 1](phase-1-gem-report.md) got it booting on **5.0.7.2**, and the unit suite runs to completion — 754 tests, 2 failures, **323 errors**. It fails; it does not fail to boot. That is a different and much better problem. +> +> Three findings shrink this phase substantially: +> +> | Assumption | Measured | +> |---|---| +> | §2.4 plans a Poltergeist → cuprite/headless-Chrome driver migration across 53 features | **There is no driver to migrate.** Zero `@javascript` tags; both Capybara driver assignments are commented out at [`features/support/env.rb:16-17`](../../features/support/env.rb#L16-L17); the suite is green in CI with no browser installed. The task is *delete `poltergeist`*, not port off it. | +> | The gem table implies `mocha`, `factory_girl`, `capybara`, `database_cleaner`, `aruba` must move for Rails 5 | **None of them caps Rails.** All are already compatible at their locked versions. Moving them is modernisation *by choice*, on your own schedule — not forced work with a Rails deadline. | +> | `cucumber`/`cucumber-rails` must move before Rails 5 | `cucumber-rails 1.4.5` caps `railties < 5.1`. It **clears the 5.0 hop** and is the first blocker of hop 2, so this work can be sequenced *after* the first bump. | +> +> And one item this phase now owns, discovered in Phase 1: **`minitest` is pinned to `~> 5.10.3` on the next bundle** as a workaround, not a fix. Rails 5.0's `Rails::TestUnitReporter` calls `result.method`, which predates `Minitest::Result` (minitest 5.11), so on a newer minitest it raises while formatting the *first* failure and takes the whole run down. Revisit at 5.1. See P1-4. +> +> Also worth reading before scoping: 320 of the 323 unit errors are **one method signature** ([`versioning.rb:230`](../../lib/cms/behaviors/versioning.rb#L230)), and the fix is backwards-compatible — so it belongs to [Phase 3](phase-3-backwards-compatible-fixes.md), and doing it first will change what this phase is looking at. + +--- + +## Why this phase exists + +The 9,807 lines of existing test code are the primary asset being protected by this entire upgrade. They are also written against a 2016-era harness that Rails 5 removes out from under them: + +| Blocker | Sites | Breaks at | +|---|---|---| +| `require 'mocha/setup'` (`test/test_helper.rb:13`), `require 'mocha/mini_test'` (`spec/minitest_helper.rb:8`) | **109** mocha call sites | Mocha 2.0 | +| `factory_girl` / `FactoryGirl` | **42** references, plus every `create(:x)` | Renamed `factory_bot` in 2017 | +| `require 'minitest/unit'` (`test/test_helper.rb:9`, `spec/minitest_helper.rb:7`) | 2 requires, suite-wide effect | A Minitest 4 shim | +| Positional controller-test args — `get :show, :id => 5` | **88** | Rails 5 requires `params: {}` | +| `assert_template` | **19** | Extracted to `rails-controller-testing` at 5.0 | +| `assigns(...)` | **11** | Same gem | +| `Devise::TestHelpers` (`test/test_helper.rb:199`) | 1 | Devise 4.2 → `Devise::Test::ControllerHelpers` | +| `config.serve_static_assets` | 2 — `test/dummy/config/environments/{test,production}.rb` | Renamed `config.public_file_server.enabled` at 5.0 | +| `cucumber` 2.4.0 / `capybara` 2.10.1 / `poltergeist` / `aruba` 0.14.14 | 53 features | Rails 5 needs current versions; PhantomJS is abandoned | + +**The `serve_static_assets` entry is the one most likely to be missed**, because it is configuration in the dummy app rather than test code — and the dummy app is what the entire suite boots against. + +There's a genuine upside to doing this as its own phase: **migrating the harness is a coverage-preserving refactor**, so the Phase 0 baseline becomes the regression check on the migration itself. If coverage drops, the port lost tests. + +## Supporting documentation + +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §1](../../RAILS_UPGRADE_TEST_PRIORITY.md) — "The finding that reorders everything," with the full dependency table +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §6, Phase 0](../../RAILS_UPGRADE_TEST_PRIORITY.md) — the harness-migration step and its acceptance criterion +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §0.2, ➕A6](../../RAILS_UPGRADE_TEST_PRIORITY.md) — the `serve_static_assets` finding +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §5](../../RAILS_UPGRADE_TEST_PRIORITY.md) — verified clean: no `use_transactional_fixtures`, no `ActionDispatch::Http::UploadedFile` in test code. Do not go looking for these. +- [`TEST_COVERAGE_ANALYSIS.md` §4](../../TEST_COVERAGE_ANALYSIS.md) — "The test harness itself blocks the upgrade" +- Skill: `version-guides/upgrade-4.2-to-5.0.md` §6 (`rails-controller-testing`), §7 (upload testing), `detection-scripts/patterns/rails-50-patterns.yml` +- **This phase's scope is set by [Phase 1](phase-1-gem-compatibility-and-dual-boot.md)'s gem report.** Read it before starting. + +## Work items + +### 2.1 — Gem renames and requires + +- [ ] `factory_girl` → `factory_bot` (both repos' worth of syntax): `FactoryGirl::Syntax::Methods` → `FactoryBot::Syntax::Methods`, and the block syntax change — `m.name 'Root'` → `m.name { 'Root' }`. +- [ ] `mocha/setup` → `mocha/minitest`; `mocha/mini_test` → `mocha/minitest`. Verify all 109 call sites still work — `.expects`, `.stubs`, and `mock()` all survive the rename, so this should be a require-line change plus a version bump. +- [ ] Drop `require 'minitest/unit'` from both helpers. +- [ ] Bump `simplecov` and **enable branch coverage**. Every coverage figure to date is line-only; branch coverage is what reveals untested conditionals in the monkeypatches. + +### 2.2 — Controller test API + +- [ ] Add `rails-controller-testing` to preserve the 19 `assert_template` and 11 `assigns` call sites. The skill confirms this is the correct fix rather than rewriting the assertions. +- [ ] Convert all **88** positional `get :action, params` calls to `get :action, params: {...}`. **Rails 4.2 accepts the keyword form**, so this is a safe pre-emptive change with no dual-boot conditional. +- [ ] `Devise::TestHelpers` → `Devise::Test::ControllerHelpers` at `test/test_helper.rb:199`. + +### 2.3 — Dummy app config + +- [ ] `config.serve_static_assets = true` → `config.public_file_server.enabled = true` in `test/dummy/config/environments/test.rb:11` and `production.rb:20`. +- [ ] Boot the dummy app under both Gemfiles to confirm no other config key has been renamed out from under it. + +### 2.4 — The Cucumber stack + +Scope here depends on Phase 0's measured pass rate. If most features are already red, fix the driver before spending time on individual scenarios. + +- [ ] Migrate the driver off Poltergeist/PhantomJS to `cuprite` or headless Chrome — **while still on Rails 4.2**, so failures are attributable to the driver rather than to Rails. +- [ ] Bring `cucumber`, `cucumber-rails`, `capybara`, and `database_cleaner` to versions from Phase 1's required-bumps bucket. Note `database_cleaner` splits into `database_cleaner-active_record`. +- [ ] Re-check the `@cli` / aruba features specifically; `aruba` is hard-pinned and drives 9 feature files out of process. + +### 2.5 — Housekeeping that affects signal + +- [ ] Confirm `$VERBOSE = nil` is still gone (Phase 0 removed it) and that the newly-current gems haven't reintroduced a suppression. +- [ ] Confirm the `MonitorMixin`/`recycle!` monkeypatch in `test/test_helper.rb` self-disables cleanly on Rails 5 — it is guarded, so it should be benign, but verify rather than assume. + +--- + +## Exit criteria + +| # | Criterion | How to verify | +|---|---|---| +| 1 | **Coverage still reads the Phase 0 baseline on Rails 4.2** | Compare the CI coverage number against Phase 0's committed baseline. A drop means the port lost tests — the phase is not done. | +| 2 | Suite green on the **default** Gemfile (4.2) | CI default job passing | +| 3 | Suite green on **`Gemfile.next`** (5.0) | `BUNDLE_GEMFILE=Gemfile.next bundle exec rake` passing in CI, **no longer allow-failure** | +| 4 | Zero `factory_girl` references remain | `grep -rn "factory_girl\|FactoryGirl" . --exclude-dir=vendor --exclude-dir=.git` returns nothing | +| 5 | Zero positional controller-test calls remain | `grep -rnE "^\s*(get\|post\|put\|patch\|delete)\s+:[a-z_]+\s*,\s*:?[a-z_\"']" test/ spec/` returns nothing | +| 6 | No `mocha/setup` or `mocha/mini_test` or `minitest/unit` requires | `grep -rn "mocha/setup\|mocha/mini_test\|minitest/unit" test/ spec/` returns nothing | +| 7 | `rails-controller-testing` is declared, and all 19 + 11 call sites still pass | Gem present; the tests using them are green on `Gemfile.next` | +| 8 | No `Devise::TestHelpers` reference | `grep -rn "Devise::TestHelpers" test/ spec/` returns nothing | +| 9 | No `serve_static_assets` reference | `grep -rn "serve_static_assets" test/ config/` returns nothing | +| 10 | Branch coverage is enabled and reported | The coverage report shows a branch percentage, not just line | +| 11 | The Cucumber pass rate is **equal to or better than** Phase 0's baseline | Compare against the committed number. Equal is acceptable; worse is a regression to fix. | +| 12 | No `NextRails.next?` branch was added to make the suite pass | `grep -rn "NextRails" test/ spec/` — ideally empty. Any occurrence needs a comment justifying why the test genuinely cannot be version-neutral. | + +**Done means:** the same suite, with the same coverage, passes on both Rails 4.2 and Rails 5.0 — and the Rails 5.0 CI job is no longer allowed to fail. + +> **The strongest signal that this phase succeeded:** criterion 1 and criterion 3 hold simultaneously. Green on 5.0 with reduced coverage means tests were deleted rather than ported. + +--- + +## Explicitly not in this phase + +- **No Rails bump on the default Gemfile.** Still 4.2.11.3. The suite passing under `Gemfile.next` is not the same as bumping — that's [Phase 5](phase-5-the-5.0-bump.md). +- **No new tests.** Not one. This is a port, and mixing new tests into it destroys criterion 1 as a signal — you could no longer tell a lost test from an added one. +- **No application code changes** beyond what the harness needs to boot. Application fixes are [Phase 3](phase-3-backwards-compatible-fixes.md). +- **No coverage improvement.** Preserving the number is the goal; raising it is not. +- **Not hunting `use_transactional_fixtures` or `ActionDispatch::Http::UploadedFile`.** Both verified absent ([§5](../../RAILS_UPGRADE_TEST_PRIORITY.md)). Uploads use a custom `Cms::MockFile` shim, which insulates the suite from the Rails 5 change — but ties it to Paperclip, making it a Phase 5-and-beyond attachment question, not a harness one. diff --git a/docs/rails-upgrade/phase-2-harness-report.md b/docs/rails-upgrade/phase-2-harness-report.md new file mode 100644 index 000000000..55356cb37 --- /dev/null +++ b/docs/rails-upgrade/phase-2-harness-report.md @@ -0,0 +1,240 @@ +# Phase 2 — Harness Migration Report + +**Implements:** [`phase-2-harness-migration.md`](phase-2-harness-migration.md) via [`phase-2-implementation-plan.md`](phase-2-implementation-plan.md) +**Measured on:** Ruby 2.7.8, PostgreSQL 15, macOS. Rails 4.2.11.3 (`Gemfile`) and 5.0.7.2 (`Gemfile.next`). + +This file is the record. Where it disagrees with the plan, this file is right and the +plan was written before the measurement existed. + +--- + +## 1. Headline + +| | Before (Phase 1) | After | +|---|---|---| +| **4.2 full suite** | exit 0 — 994 tests, 0F/0E; cucumber 154/154 | **exit 0 — 996 tests, 0F/0E; cucumber 154/154** | +| **4.2 line coverage** | 75.82% (simplecov 0.12) | **78.35% (simplecov 0.22)** — same 4901 covered lines, see [§4](#4-stage-f--the-coverage-number-moved-and-why-that-is-not-a-regression) | +| **4.2 branch coverage** | not measured | **70.79%** — reported, not gated | +| **5.0 unit suite** | 754 tests, 2F / **323E** | **756 tests, 2F / 3E** | +| **5.0 cucumber** | did not load | **154 scenarios collected**, 6 pass / 148 fail | +| **5.0 full suite** | red | **still red** — 5 application defects, see [§5](#5-what-still-blocks-rails-5-and-who-owns-it) | + +The harness migration is **done**. Every remaining Rails 5 failure is application +behaviour, not test plumbing. + +--- + +## 2. Exit criteria + +| # | Criterion | Verdict | +|---|---|---| +| 1 | Coverage still reads the baseline on 4.2 | ✅ but **the baseline moved to 78.35%** — instrument change, [§4](#4-stage-f--the-coverage-number-moved-and-why-that-is-not-a-regression) | +| 2 | Suite green on the default Gemfile | ✅ exit 0, checked after every stage | +| 3 | Suite green on `Gemfile.next`, no longer allow-failure | ❌ **not met.** `continue-on-error` was removed anyway — a deliberate call, [§5](#5-what-still-blocks-rails-5-and-who-owns-it) | +| 4 | Zero `factory_girl` references | ✅ in all code and both lockfiles. Planning docs still carry the name; they are historical records and rewriting them would falsify the record | +| 5 | Zero positional controller-test calls | ✅ 89 live + 2 in comments converted, 9 files | +| 6 | No `mocha/setup`, `mocha/mini_test`, `minitest/unit` requires | ✅ — but not the way the plan specified, [§3.2](#32-criterion-6-as-written-was-unimplementable) | +| 7 | `rails-controller-testing` declared; 19 + 11 sites pass | ✅ present in the 5.0 lock only, absent from the 4.2 lock. 19 `assert_template` / 11 `assigns` confirmed | +| 8 | No `Devise::TestHelpers` | ✅ | +| 9 | No `serve_static_assets` | ✅ deleted, not renamed | +| 10 | Branch coverage enabled and reported | ✅ 70.79%, printed by `coverage:check`, not gated | +| 11 | Cucumber pass rate ≥ Phase 0 baseline | ✅ 154/154 on 4.2 | +| 12 | No `NextRails` branch in test code | ✅ the two version-conditional branches are both in the `Gemfile` | + +**11 of 12.** Criterion 3 is the exception and was never reachable by harness work +alone — the plan said as much in [1.4](phase-2-implementation-plan.md), and [§5](#5-what-still-blocks-rails-5-and-who-owns-it) +gives the residue by name. + +--- + +## 3. Where the plan was wrong + +Six corrections. The first is the plan's own count; the rest are things no one had +measured. + +### 3.1 The static-attribute count + +`factories.rb` has **37** static attributes, not 35. With `attachable_factories.rb`'s +15 — which the plan got right — the conversion was **52 sites, not 50**. + +### 3.2 Criterion 6 as written was unimplementable + +The plan said to drop `require 'minitest/unit'` from three files. Doing so turned the +whole 4.2 suite into a load error, because that file is the **only** definition of the +legacy `MiniTest = Minitest` alias — and mocha 1.x's minitest adapter assigns +`::MiniTest::Assertion` (`mocha/integration/mini_test/adapter.rb:26`). + +Three separate dependents fell out of this: + +| Site | Was | Now | +|---|---|---| +| `test/support/mini_test_matchers.rb` | `module MiniTest::Assertions` | `Minitest::Assertions` | +| `test/minitest_helper.rb`, `spec/minitest_helper.rb` | `MiniTest::Reporters.use!` | `Minitest::Reporters` — the gem never defined the camelCase name; it only resolved through the alias | +| `test/test_helper.rb`, `spec/minitest_helper.rb` | — | `MiniTest = Minitest unless defined?(MiniTest)`, aliasing the one constant mocha needs instead of restoring a deprecated file | + +Criterion 6 passes. Mocha stays on 1.x per [D4](phase-2-implementation-plan.md#d4--mocha-1x-vs-2x); the alias +is deletable the day mocha goes to 2.x. + +### 3.3 Two tests had never run + +`test/unit/extensions/active_record/base_test.rb:13` declared +`class TestExtensions < MiniTest::Unit`. `Minitest::Unit` is **not a TestCase** — it is +the deprecated shim class, so minitest never collected it and its two tests have not +executed in years. The name the author wanted, `MiniTest::Unit::TestCase`, is +`Minitest::Test` on minitest 5. + +Renamed. Both tests now run and both pass, on 4.2 and on 5.0. This is why the test +count is 996 and not 994, and it accounts for the 75.82% → 75.87% move seen before +the simplecov bump. + +### 3.4 factory_bot 5 changed the association *strategy*, not just the syntax + +The plan's risk register anticipated "a factory's value silently changed". The actual +break was louder and bigger: **factory_bot 5.0 flipped `use_parent_strategy` to +`true`**. factory_girl 4.7 always ran `association` with `:create` regardless of the +parent's strategy; factory_bot 5 inherits it, so `build(:page)` started *building* an +unsaved `Section`, which `SectionNode#section=` hands to ancestry's `parent=`, which +raises `No child ancestry for new record`. + +**21 unit errors, on both bundles.** Fixed with `FactoryBot.use_parent_strategy = false` +at the top of `test/factories/factories.rb`. This phase is a port; changing association +strategy would be a behaviour change smuggled in under a rename. + +### 3.5 `EngineControllerHacks` — the plan never knew it existed + +`test/support/engine_controller_hacks.rb` overrides `get`/`post`/`put`/`delete` on +`ActionController::TestCase` with **positional** signatures and calls `process` with +five positional arguments. Stage C's shim therefore never fired: it saw three args, +correctly declined to translate, and `params:` arrived at the controller as a literal +key. **21 failures and 27 errors** on the first stage-C run. + +The module now takes kwargs and forwards them to `super`, leaving exactly one place — +the shim — that knows about the 4.2/5.0 calling-convention difference. + +### 3.6 Rails 5.0 removed `Kernel#silence_stream` + +`features/support/env.rb:85` called it, so on the 5.0 bundle cucumber raised +`NoMethodError` **at load** and the entire run died before collecting a scenario. This +is harness work and squarely in scope; it is also the single change that took 5.0 +cucumber from "does not load" to "154 scenarios collected". + +4.2 has it as `Kernel#silence_stream`; 5.0 keeps the implementation only as a private +method on `ActiveSupport::Testing::Stream`, a module that does not exist on 4.2. No +name resolves on both, so the six-line implementation is now defined locally as +`without_output_from` and neither version matters. + +--- + +## 4. Stage F — the coverage number moved, and why that is not a regression + +Measured across the simplecov 0.12 → 0.22 bump on **identical code**: + +| | simplecov 0.12 | simplecov 0.22 | +|---|---|---| +| Covered lines | **4901** | **4901** | +| Relevant lines | 6460 | **6255** | +| Line coverage | 75.87% | **78.35%** | + +The numerator is **identical**. Only the denominator moved — 0.18+ narrowed what counts +as a relevant line, so 205 lines left the measurement. That is the instrument changing, +exactly as [1.5](phase-2-implementation-plan.md) predicted, and it is the one reading +under which criterion 1 stays interpretable. + +`COVERAGE_MINIMUM`'s default is now **78.35**. The gate itself was taught both JSON +shapes *before* the bump (`result['line'] || result['covered_percent']`), so it was +never broken across the transition, and it aborts on neither key rather than comparing +`nil` to a Float. + +### A measurement hazard worth knowing about + +`coverage/.resultset.json` is shared across bundles and suites, and entries persist for +`merge_timeout` (3600s). During this phase it accumulated an `Unknown Test Framework` +entry from a single-file Rails 5 run plus stale suites from earlier phases — any of +which silently shifts the merged percentage. **Clear the resultset before taking a +coverage measurement you intend to quote.** Every figure in this report was taken on a +cleared resultset with exactly the five expected suites present. + +--- + +## 5. What still blocks Rails 5, and who owns it + +Five application defects. None is harness work; all belong to +[Phase 3](phase-3-backwards-compatible-fixes.md). + +| Defect | Impact | +|---|---| +| `skip_callback :redirect_to_cms_site` not defined — [`content_controller.rb:11`](../../app/controllers/cms/content_controller.rb#L11) | **Worst of the five.** 5.0 raises where 4.2 warned, and it is a *load* error: it takes the entire functional suite down and causes most of the 148 cucumber failures | +| `ActiveRecord::StaleObjectError` on `Cms::Page` ×2 | 2 unit errors | +| `PublishableTestCase#test_publish_on_save` | expected false to be truthy | +| `PortletTest#test_.blacklist` | expectation diff | +| `couldn't find file 'ckeditor-jquery'` | asset-pipeline resolution under 5.0; 132 of the cucumber failures | + +### The `next-rails` CI job is now gating and currently red + +This was a deliberate decision, taken with the residue above already known and +documented. The argument for it: a red job in the merge path is visible, and a +"reporting only" job that is red for four phases is how a genuine regression gets +missed. The cost: **CI is red on every PR until Phase 3 lands.** The job's comment +block names all five defects so the red is legible rather than mysterious. + +### Newly found, deferred: `use_route` + +`EngineControllerHacks` injects `:use_route => :cms` into every functional request. +4.2 deprecates it; **5.0 removed it**, so on the next bundle it is no longer consumed +and arrives at the controller as an ordinary request parameter. + +The replacement 4.2's own deprecation message recommends — `@routes = Cms::Engine.routes` — +**is not equivalent, and this was measured, not assumed**: it produced **16 +`UrlGenerationError`s**, because `use_route` is a route *name* hint into the +application's route set (`@routes.path_for(options, route_name)`), not a route-set +swap. The dummy app's own controllers (`dummy/sample_blocks`) are not in the engine's +route set at all. + +Untangling which test classes want engine routes and which want application routes is +a real piece of work and is **not** the harness migration. 4.2 behaviour is preserved +exactly; the finding is recorded in the module's own comment so the next person does +not repeat the 16-error experiment. + +### Also newly found: a dual-boot database hazard + +Switching bundles leaves the test database in a state the other bundle rejects. After a +4.2 run, `bundle exec rake` on `Gemfile.next` aborts at `db:drop` with +`ActiveRecord::NoEnvironmentInSchemaError` — Rails 5's protected-environment guard +looking for `ar_internal_metadata`. Clear it with +`BUNDLE_GEMFILE=Gemfile.next bundle exec rake app:db:environment:set`. CI does not hit +this because each job gets a fresh container, but every local dual-boot run does. + +--- + +## 6. What changed + +| Stage | Change | +|---|---| +| **A** | `create_or_update(*args, &block)`; `Rails::Html::FullSanitizer` ×2. Two Phase 3 fixes borrowed per [D1](phase-2-implementation-plan.md#d1--borrowing-two-fixes-from-phase-3) — they took 5.0 unit errors from 323 to 3 | +| **B** | `factory_bot_rails ~> 5.2`, `mocha ~> 1.16`; 14 files renamed; 52 static attributes converted; `use_parent_strategy = false`; the `MiniTest` alias and its three dependents | +| **C** | `KeywordControllerArgs` shim (4.2 only); 89 call sites + 2 comments; `EngineControllerHacks` made kwargs-aware; `rails-controller-testing` behind `next?`; `Devise::Test::ControllerHelpers`; the dead `Cms::IntegrationTestHelper` call converted and flagged | +| **D** | Static-asset config keys deleted from `test.rb` and `production.rb` — nothing set them false, both frameworks default them true | +| **E** | `poltergeist` gone from the Gemfile and `env.rb`; `silence_stream` replaced | +| **F** | `simplecov ~> 0.22.0`, `enable_coverage :branch`, gate taught both JSON shapes, baseline moved to 78.35 | +| **G** | `next-rails` made gating; this report | + +### Lockfile drift, both files, fully accounted for + +`factory_girl` → `factory_bot` (with its dependency bounds); `mocha` 1.2 → 1.16 and +`metaclass` dropped; `simplecov` 0.12 → 0.22 with `docile` and `simplecov-html` bumped, +`simplecov_json_formatter` added and the `json` gem dropped (a 0.12 dependency); +`poltergeist` dropped with `cliver`, `websocket-driver` and `websocket-extensions`; +`rails-controller-testing` added to the **next lock only**. Nothing else moved. + +--- + +## 7. For Phase 3 + +1. The five defects in [§5](#5-what-still-blocks-rails-5-and-who-owns-it). Do the + `skip_callback` one first — it is a load error, so nothing downstream of it is + measurable until it lands. +2. `use_route` in `test/support/engine_controller_hacks.rb`. +3. Set a branch-coverage floor now that 70.79% is a measured number. +4. Dead code: `Cms::IntegrationTestHelper` is defined, included nowhere, and asserts + `403` immediately after a successful login. +5. The `MiniTest = Minitest` alias goes when mocha goes to 2.x. diff --git a/docs/rails-upgrade/phase-2-implementation-plan.md b/docs/rails-upgrade/phase-2-implementation-plan.md new file mode 100644 index 000000000..79cc0b995 --- /dev/null +++ b/docs/rails-upgrade/phase-2-implementation-plan.md @@ -0,0 +1,510 @@ +# Phase 2 — Implementation Plan + +**Implements:** [`phase-2-harness-migration.md`](phase-2-harness-migration.md) +**Entry condition:** Phase 1 complete — `Gemfile.next` resolves to 5.0.7.2 and boots; the 4.2 bundle is green at 75.82% ([`phase-1-gem-report.md`](phase-1-gem-report.md)). +**Rails at the end of this phase:** `Gemfile` still 4.2.11.3 and still green. The suite runs *and passes* on `Gemfile.next`, and its CI job stops being allowed to fail. *(Outcome: the first half held; the suite runs on `Gemfile.next` but does not yet pass. See the status block below.)* + +Same shape as the [Phase 0](phase-0-implementation-plan.md) and [Phase 1](phase-1-implementation-plan.md) plans: findings first, then an ordered work stream, then the decisions that need a human. + +> ### Status: applied — 11 of 12 exit criteria met +> **All seven stages have been implemented.** The 4.2 suite is green at exit 0 with +> 996 tests and cucumber 154/154; on Rails 5 the unit suite went from 323 errors to 3 +> and cucumber from "does not load" to 154 scenarios collected. +> +> **Criterion 3 is not met and could not have been** — five *application* defects +> remain on Rails 5, and no harness work reaches them ([1.4](#14-exit-criterion-3-is-unreachable-inside-this-phases-own-scope) +> said as much before any code was written). The `next-rails` CI job was made gating +> anyway, deliberately, so it is **red until Phase 3 lands**. +> +> **This document is the plan as it was written, and it was wrong in six places** — +> two of which broke the 4.2 suite before being caught. Do not read the sections below +> as a description of what was built. In particular: +> [B.2](#b2--requires-and-constants)'s instruction to drop `require 'minitest/unit'` +> is unimplementable as stated; [B.3](#b3--static-attributes-50-sites-reviewed-not-sedd) +> undercounts by two and misses factory_bot 5's association-strategy change; +> [C](#c--controller-test-api-22) does not know that `EngineControllerHacks` exists, +> which is why the shim did not fire on the first attempt; and stage +> [E](#e--cucumber-stack-and-housekeeping-24--25) misses that Rails 5.0 removed +> `Kernel#silence_stream`, which was the single thing stopping cucumber from loading. +> +> **[`phase-2-harness-report.md`](phase-2-harness-report.md) is the record** — the +> measurements, the six corrections, and the five remaining defects by name. + +--- + +## 1. Pre-flight findings + +Measured against the working tree at `29b7f92e`, with a confirming full run of the 4.2 bundle first: **exit 0, cucumber 154/154, coverage 75.82%** — identical to the Phase 0 baseline, so everything below is measured from a known-good starting point. + +The phase document's [re-scope note](phase-2-harness-migration.md) already retired the Poltergeist migration and the forced-gem-bump list. Four further findings change work items rather than just shrinking them. + +### 1.1 The phase doc's central claim about controller tests is wrong + +Work item 2.2 says: *"**Rails 4.2 accepts the keyword form**, so this is a safe pre-emptive change with no dual-boot conditional."* + +**It does not.** [`actionpack-4.2.11.3/lib/action_controller/test_case.rb:595-602`](../../vendor/bundle/gems/actionpack-4.2.11.3/lib/action_controller/test_case.rb#L595): + +```ruby +def process(action, http_method = 'GET', *args) + ... + parameters, session, flash = args +``` + +Three positional slots and no keyword handling anywhere in the file. On 4.2, `get :show, params: {id: 5}` sets `params[:params][:id]` — the controller never sees `:id`, and the test fails in a way that looks like an application bug. + +**And the inverse is also true, which is the more useful half.** Rails 5.0 still accepts the *positional* form, via [`actionpack-5.0.7.2/lib/action_controller/test_case.rb:641-663`](../../vendor/bundle/gems/actionpack-5.0.7.2/lib/action_controller/test_case.rb#L641): + +```ruby +def process_with_kwargs(http_method, action, *args) + if kwarg_request?(args) + ... + else + non_kwarg_request_warning if args.any? +``` + +So the honest version of the compatibility table is: + +| Form | 4.2.11.3 | 5.0.7.2 | 5.1+ | +|---|---|---|---| +| `get :show, id: 5` | ✅ only this | ⚠️ deprecated | ❌ removed | +| `get :show, params: {id: 5}` | ❌ silently wrong | ✅ | ✅ only this | + +**Two consequences.** First, the 89 conversions are **not required by this hop at all** — they are the first blocker of hop 2 (5.1), alongside `cucumber-rails`. Second, doing them now cannot be unconditional, because no single form works on both versions. + +Resolution in [2.2](#c--controller-test-api-22). + +### 1.2 `serve_static_assets` has no cross-version replacement — but it has a better fix + +Work item 2.3 says rename it to `config.public_file_server.enabled`. That key **does not exist on Rails 4.2**: + +| Rails | Key | Framework default | +|---|---|---| +| 4.1 and earlier | `serve_static_assets` | — | +| **4.2.11.3** | `serve_static_files` (`serve_static_assets` is a deprecated alias) | `true` — [`configuration.rb:30`](../../vendor/bundle/gems/railties-4.2.11.3/lib/rails/application/configuration.rb#L30) | +| **5.0.7.2** | `public_file_server.enabled` (both older names removed) | `true` — [`configuration.rb:33`](../../vendor/bundle/gems/railties-5.0.7.2/lib/rails/application/configuration.rb#L33) | + +The rename as written would need a version conditional. But **both frameworks already default it to `true`**, and both dummy-app sites set it to `true` — so they set the default and can simply be **deleted**. No conditional, no behaviour change on either version. The doc reached for a rename where a deletion is available. + +Same file, one the doc misses: `test/dummy/config/environments/test.rb:12` sets `config.static_cache_control`, which 5.0 deprecates in favour of `public_file_server.headers` and which likewise has no 4.2 equivalent under the new name. Delete it too — a `max-age` header on static assets in the *test* environment buys nothing. + +### 1.3 The site counts, re-measured + +| Item | Doc says | Measured | Note | +|---|---|---|---| +| positional controller calls | 88 | **89 live, in 9 files**, + 2 in comments | Comments are `pages_controller_test.rb:207,210`. The doc's grep counted them and missed one live site. | +| positional *integration* calls | not mentioned | **1** — [`test/test_helper.rb:209`](../../test/test_helper.rb#L209) | In `Cms::IntegrationTestHelper`, which is **defined and never included anywhere**. Dead code that the doc's `test/`-wide grep pattern could not match. See [D3](#d3--cmsintegrationtesthelper). | +| `mocha` call sites to migrate | 109 | **1 require line each** in `test/test_helper.rb:13` and `spec/minitest_helper.rb:8` | Already corrected by Phase 1. The 109 is the `expects`/`stubs`/`mock` API surface, which survives the rename untouched. | +| `factory_girl` references | 42 | **60**, in 14 code files (+ 3 planning docs, 3 lockfiles) | | +| `assert_template` / `assigns` | 19 / 11 | **19 / 11** ✅ | | +| `Devise::TestHelpers` | 1 | **1** ✅ | | +| `@javascript` tags / Capybara drivers | 0 / commented out | **0 / commented out** ✅ | Confirmed again; nothing to migrate. | + +### 1.4 Exit criterion 3 is unreachable inside this phase's own scope + +Criterion 3 asks for a green suite on `Gemfile.next`. Phase 1 measured 754 unit tests → 2 failures, 323 errors there, and **322 of those 325 are two application fixes that belong to [Phase 3](phase-3-backwards-compatible-fixes.md)**: + +- 320 × `ArgumentError` from the `create_or_update` arity at [`versioning.rb:230`](../../lib/cms/behaviors/versioning.rb#L230) +- 2 × `NameError: uninitialized constant HTML` from [`content_filter.rb:12`](../../lib/cms/content_filter.rb#L12) + +No amount of harness work moves those. Phase 2's "explicitly not in this phase" says *"No application code changes beyond what the harness needs to boot"* — but the harness already boots; what it lacks is a **readable result**, and 320 identical errors are not one. Phase 3's own header agrees: *"Do the arity fix first and re-measure before scoping the rest of this phase."* + +**Resolution: land those two fixes first, as a declared prerequisite, and say so.** Both are backwards-compatible, both are two lines, and Phase 3 keeps ownership of the other ~94 changes. This is recorded as a deliberate deviation, not an oversight — see [D1](#d1--borrowing-two-fixes-from-phase-3). + +### 1.5 The simplecov bump is a change to the measuring instrument + +Work item 2.1 asks for a simplecov bump with branch coverage enabled. Exit criterion 1 asks that coverage still read the Phase 0 baseline. **These interact**: simplecov 0.12 → 0.22 changes how lines are counted and rewrites `.resultset.json` / `.last_run.json`, so the reported percentage can move without a single test being lost. + +Worse, it breaks the gate outright. [`coverage:check`](../../lib/tasks/core_tasks.rake#L43) reads `result.covered_percent`; from simplecov 0.18 that key is gone, replaced by `result.line` (and `result.branch` once branch coverage is on). Left alone, the gate would `KeyError` rather than fail-open — loud, at least, but still broken. + +So this bump goes **last and alone**, in its own commit, with the coverage number measured immediately before and after. That is the only sequence in which criterion 1 stays interpretable: a change in the number across a commit that touches nothing but the coverage tool is an instrument change; a change across a commit that touches tests is a lost test. + +--- + +## 2. Execution order + +| Stage | Work item | Produces | Size | +|---|---|---|---| +| **A** | *prereq* ([1.4](#14-exit-criterion-3-is-unreachable-inside-this-phases-own-scope)) | The two Phase-3 fixes; a re-measured Rails 5 error count that is worth reading | S | +| **B** | 2.1 | `factory_bot`, `mocha/minitest`, `minitest/unit` gone | M | +| **C** | 2.2 | `rails-controller-testing`; 89+1 calls in keyword form; a 4.2-only kwargs shim; Devise | **L** | +| **D** | 2.3 | Dummy-app config keys that exist on both versions | S | +| **E** | 2.4 / 2.5 | `poltergeist` deleted; housekeeping verified | S | +| **F** | 2.1 (coverage) | simplecov bumped, branch coverage on, gate fixed — **alone** | M | +| **G** | exit | Both bundles measured; `next-rails` job made gating; report written | S | + +Order is by signal, not by size. A is first because nothing after it is readable without it. F is last for the reason in [1.5](#15-the-simplecov-bump-is-a-change-to-the-measuring-instrument). C is the only stage with real risk in it. + +**After every stage: the 4.2 suite must still be green at 75.82%.** That is the whole point of criterion 1, and checking it once at the end would tell you a test was lost without telling you which stage lost it. + +--- + +## 3. Stage detail + +### A — Borrowed prerequisites + +#### A.1 — The two fixes + +Three edits, given in full. Each carries a comment naming the Phase 1 finding it closes, because the next person to read `create_or_update(*args, &block)` will otherwise see an unused splat and delete it. + +**[`lib/cms/behaviors/versioning.rb:230`](../../lib/cms/behaviors/versioning.rb#L230)** — signature only; the body is unchanged. The bare `super` at line 249 is a zsuper, so it forwards the new arguments implicitly and needs no edit. + +```ruby + # 3. If new record, its version is set to 1, and its published if needed. + # + # Rails 4.2 declares `def create_or_update` (persistence.rb:502) and Rails 5.0 + # declares `def create_or_update(*args, &block)` (persistence.rb:546). Accept and + # forward whatever the framework passes: on 4.2 nothing is passed, so *args is + # empty and this behaves exactly as the zero-arity version did. Without it, every + # save on Rails 5 raises ArgumentError -- 320 of the 323 unit errors Phase 1 + # measured. See docs/rails-upgrade/phase-1-gem-report.md, P1-2. + def create_or_update(*args, &block) +``` + +**[`lib/cms/content_filter.rb:12`](../../lib/cms/content_filter.rb#L12)**: + +```ruby + # Rails::Html::FullSanitizer, not HTML::FullSanitizer: the latter comes from + # rails-deprecated_sanitizer, which is in the bundle only because + # rails-dom-testing 1.x depends on it -- and 1.x caps activesupport < 5.0. On + # Rails 5 it leaves the bundle and this line raises NameError. Both classes are + # rails-html-sanitizer's and produce identical output on 4.2, verified across + # nil/empty/non-string input. See phase-1-gem-report.md, P1-3. + c[key] = Rails::Html::FullSanitizer.new.sanitize(c[key]).strip +``` + +**[`test/functional/cms/inline_controller_test.rb:7`](../../test/functional/cms/inline_controller_test.rb#L7)** — asserts against the doomed constant directly: + +```ruby + assert_equal "Remove", Rails::Html::FullSanitizer.new.sanitize("

Remove

") +``` + +#### A.2 — Re-measure, then rescope + +```bash +bundle exec rake units # expect: green, unchanged +BUNDLE_GEMFILE=Gemfile.next bundle exec rake units # the number that matters +``` + +Record failures **and** errors for the 5.0 run in the report's scratch section. **Everything after this point is scoped against that number, not against Phase 1's 323.** If the residue is small and harness-shaped, stages B–F are the whole job. If it is large and application-shaped, that is a [contingency](#6-contingencies), not a surprise to absorb quietly. + +Commit A on its own. It is the only stage that touches `lib/`, and keeping it separable is what makes it cheap to hand back to Phase 3 if the borrowing turns out to be a mistake. + +### B — Gem renames and requires (2.1) + +#### B.0 — Settle the DSL question before touching 50 lines + +Every factory in this repo uses the **block-argument** DSL — `factory :root_section, :class => Cms::Section do |m| … end`, with attributes hung off `m`. factory_bot 5 definitely removed *static attributes*; whether it still yields a `DefinitionProxy` to a block argument is a separate question, and the answer changes stage B from a 50-line edit to a rewrite of both factory files. + +`factory_bot` is not installed anywhere on the build machine — not in `vendor/bundle`, not in `vendor/cache`, not in any gem path — so the answer cannot be read off disk. **Measure it first, do not assume it**: + +```bash +BUNDLE_GEMFILE=Gemfile.next bundle exec ruby -e ' + require "factory_bot" + FactoryBot.define { factory(:probe, class: Hash) { |m| m.foo { 1 } } } + puts "block-arg DSL: OK" +' +``` + +If it raises, stage B grows a step — convert both files to the bare-block form (`factory :root_section, class: Cms::Section do name { "My Site" } end`) — and that is worth its own commit ahead of the attribute change, so the two edits can be reviewed apart. + +#### B.1 — Gemfile + +Both Gemfiles are byte-identical (`diff Gemfile Gemfile.next` is empty; the dual-boot difference is expressed with `next?` inside the file), so every change here lands once and applies to both bundles. + +- [ ] `gem 'factory_girl_rails'` → `gem 'factory_bot_rails', '~> 5.2'`. factory_bot 5.2.0 requires `activesupport >= 4.2.0` and Ruby >= 2.3 — resolves on 4.2/Ruby 2.7.8 and on 5.0 alike. +- [ ] `gem 'mocha', require: false` → `gem 'mocha', '~> 1.16', require: false` ([D4](#d4--mocha-1x-vs-2x)). +- [ ] Re-resolve **both** lockfiles and diff them. Anything that moves other than `factory_girl*` → `factory_bot*` and `mocha` is a resolver side-effect and needs an explanation before it is committed — the default lockfile drifting is what invalidates the Phase 0 baseline. + +#### B.2 — Requires and constants + +`FactoryGirl` → `FactoryBot`, `require 'factory_girl'` → `require 'factory_bot'`. Measured inventory — **60 references in 14 code files**, of which the ones that are more than a token swap: + +| Site | Change | +|---|---| +| [`test/test_helper.rb:24`](../../test/test_helper.rb#L24), [`features/support/env.rb:8`](../../features/support/env.rb#L8) | `require 'factory_girl'` → `'factory_bot'` | +| [`test/test_helper.rb:52`](../../test/test_helper.rb#L52), [`test/minitest_helper.rb:34`](../../test/minitest_helper.rb#L34), [`spec/minitest_helper.rb:24`](../../spec/minitest_helper.rb#L24) | `include FactoryGirl::Syntax::Methods` → `FactoryBot::` | +| [`features/support/env.rb:12`](../../features/support/env.rb#L12) | `World(FactoryGirl::Syntax::Methods)` → `FactoryBot::` | +| [`features/support/env.rb:9`](../../features/support/env.rb#L9) | commented `factory_girl/step_definitions` — rename it too, or criterion 4's grep fails on a comment | +| The other 8 files | plain `FactoryGirl.create/build/attributes_for` → `FactoryBot.` | + +Criterion 4's grep is repo-wide and excludes only `vendor/` and `.git/`, so **comments and planning docs count**. The three planning docs and the lockfile entries are in scope for the grep even though they are not code; note them in the report rather than being surprised at stage G. + +- [ ] `require 'mocha/setup'` ([`test/test_helper.rb:13`](../../test/test_helper.rb#L13)) and `require "mocha/mini_test"` ([`spec/minitest_helper.rb:8`](../../spec/minitest_helper.rb#L8)) → `require 'mocha/minitest'`. Available from mocha 1.5.0; the 109 `expects`/`stubs`/`mock` call sites are untouched by the rename. +- [ ] Drop `require 'minitest/unit'` from [`test/test_helper.rb:9`](../../test/test_helper.rb#L9), [`test/minitest_helper.rb:6`](../../test/minitest_helper.rb#L6), [`spec/minitest_helper.rb:7`](../../spec/minitest_helper.rb#L7). **Three files, not the two the phase doc names.** + +#### B.3 — Static attributes: 50 sites, reviewed not sed'd + +factory_bot 5 removed static attributes, so `m.name "My Site"` must become `m.name { "My Site" }`. Measured: **35 in [`factories.rb`](../../test/factories/factories.rb), 15 in [`attachable_factories.rb`](../../test/factories/attachable_factories.rb)**. + +What must **not** be touched, because it is not a static attribute: `m.association …`, `m.sequence(…) { }`, `m.after(:build)` / `after(:create)`, anything already in block form, the `transient do … end` wrapper itself, and the `acts_as_content_block` / `has_attachment` class-body macros at the top of `attachable_factories.rb`. A naive regex hits all of them. + +Three sites need more than braces round the value: + +- [`factories.rb:134`](../../test/factories/factories.rb#L134) — `m.body %q{…}` spans 11 lines. The brace form nests `%q{}` inside `{}`; balanced, but check it renders. +- [`factories.rb:150`](../../test/factories/factories.rb#L150) — `m.name p`, where `p` is the block parameter of the enclosing `Cms::Authoring::PERMISSIONS.each do |p|`. `m.name { p }` closes over a per-iteration binding, so each permission factory still gets its own name — but inside the block `self` is the evaluator, and `p` is only a local rather than `Kernel#p` because the local is in lexical scope. It is worth a comment saying so. +- [`factories.rb:252`](../../test/factories/factories.rb#L252) — `page_path "/random"` sits **inside** `transient do`. Transient declarations are declarations too and take the same change; being one indent deeper is the reason a per-file skim misses it. + +- [ ] All 50 converted, reviewed individually. +- [ ] `grep -nE '^\s+[a-z_.]+ +[^{|]' test/factories/*.rb` returns only `association`, `sequence`, `after` and the class-body macros. + +#### B.4 — Verify + +```bash +bundle exec rake # 4.2: green, 75.82% +BUNDLE_GEMFILE=Gemfile.next bundle exec rake units # 5.0: no worse than A.2 +``` + +A factory whose value silently changed shows up as a 4.2 failure here, in the commit that caused it. That is the whole reason stage B is verified on 4.2 rather than waved through to stage G. + +### C — Controller test API (2.2) + +Given [1.1](#11-the-phase-docs-central-claim-about-controller-tests-is-wrong), there are three ways to satisfy criteria 2, 3 and 5 at once, and only one of them is any good: + +| Option | Verdict | +|---|---| +| Convert to keyword form unconditionally | **Breaks the 4.2 suite.** Not viable. | +| Leave positional, defer to hop 2 | Green on both, but criterion 5 fails and the 5.0 job carries 89 deprecation warnings through Phases 3–5 — polluting exactly the signal those phases read. | +| **Convert to keyword form + a 4.2-only shim in `test_helper.rb`** | ✅ One version-guarded block instead of 89 conditionals. Deleted by Phase 5 when 4.2 goes away. | + +Take the third. The shim translates `params:` / `session:` / `flash:` back into 4.2's three positional slots, and raises on any keyword 4.2 cannot express (`xhr:`, `as:`, `format:` — none of which is used today; verified zero `xhr`/`xml_http_request` sites in the repo) rather than silently dropping it. It sits beside the `MonitorMixin`/`recycle!` patch already in that file and is guarded the same way, on `Rails::VERSION`. + +#### C.1 — The shim + +Goes in [`test/test_helper.rb`](../../test/test_helper.rb) beside the existing `MonitorMixin`/`recycle!` patch, guarded the same way and announcing itself the same way — stage E's checklist reads that output rather than assuming. + +```ruby +# Rails 4.2's ActionController::TestCase#process has three positional slots and +# no keyword handling: `def process(action, http_method = 'GET', *args)` then +# `parameters, session, flash = args` (actionpack-4.2.11.3 test_case.rb:595). +# So `get :show, params: {id: 5}` arrives as params[:params][:id] and the +# controller never sees :id -- a silently wrong answer, not an error. Rails 5.0 +# accepts both forms; 5.1 accepts only the keyword form. No single form works on +# both, so the call sites are written the 5.x way and translated back here, once, +# for the 4.2 bundle only. Delete this whole block in Phase 5. +if Gem::Version.new(Rails.version) < Gem::Version.new('5.0.0') + module KeywordControllerArgs + TRANSLATABLE = [:params, :session, :flash].freeze + + # 4.2 has no positional slot for any of these. Zero call sites use one today + # (no xhr / xml_http_request / as: / format: anywhere in test/functional). + # Raise rather than drop: a dropped keyword is a test that passes for the + # wrong reason, which is the one failure mode this shim must not have. + UNTRANSLATABLE = [:xhr, :as, :format, :body, :env, :headers].freeze + + def process(action, http_method = 'GET', *args) + kwargs = args.first + keyword_form = args.length == 1 && kwargs.is_a?(Hash) && kwargs.any? && + kwargs.keys.all? { |k| TRANSLATABLE.include?(k) || UNTRANSLATABLE.include?(k) } + return super unless keyword_form + + unsupported = kwargs.keys & UNTRANSLATABLE + unless unsupported.empty? + raise ArgumentError, "Rails 4.2 cannot express #{unsupported.inspect} in a " \ + "controller test. Rewrite the call, or extend the shim " \ + "in test/test_helper.rb -- do not drop the keyword." + end + + super(action, http_method, kwargs[:params], kwargs[:session], kwargs[:flash]) + end + end + + ActionController::TestCase.prepend(KeywordControllerArgs) + puts 'Translating keyword controller-test args back to Rails 4.2 positional form' +end +``` + +Two things to verify rather than assume, both one-liners: + +- [ ] `prepend` on the class really does intercept. `process` is defined in `ActionController::TestCase::Behavior`, an *included* module, so a module prepended to the class sits ahead of it — check with `ActionController::TestCase.ancestors.take(3)` rather than trusting the ancestry rule. +- [ ] The verb methods route through `process`. 4.2 defines `get`/`post`/… as thin wrappers over it, which is why one interception covers all 89 sites; confirm before converting any of them. + +#### C.2 — The 89 call sites + +**91 grep hits, 89 live, 9 files** — the two non-live are comments at [`pages_controller_test.rb:207,210`](../../test/functional/cms/pages_controller_test.rb#L207). + +| File | Sites | +|---|---| +| `test/functional/cms/pages_controller_test.rb` | 27 (25 live) | +| `test/functional/cms/sections_controller_test.rb` | 18 | +| `test/functional/cms/content_controller_test.rb` | 16 | +| `test/functional/cms/links_controller_test.rb` | 10 | +| `test/functional/cms/html_blocks_controller_test.rb` | 9 | +| `test/functional/cms/tasks_controller_test.rb` | 4 | +| `test/functional/cms/content_block_controller_test.rb` | 3 | +| `test/functional/cms/file_blocks_controller_test.rb` | 3 | +| `test/dummy/test/controllers/design_controller_test.rb` | 1 | + +Every one is the simple shape — a single trailing hash, no second or third positional argument. Verified: **no call site passes session or flash positionally**, and none uses `format:`. So the conversion is uniformly `get :edit, :id => @page.id` → `get :edit, params: {:id => @page.id}`, including the multi-key ones (`put :update, :id => …, :page => {…}` → `params: {:id => …, :page => {…}}` — one params hash, not two). + +The dummy-app file is easy to miss: it is under `test/dummy/`, it is reached only through the `test:orphans` task Phase 0 added, and criterion 5's grep does cover it. + +- [ ] 89 conversions, 9 files. +- [ ] Convert the two comments as well — they are prose, but leaving them means the next person greps and finds "remaining" sites. + +#### C.3 — `rails-controller-testing` + +For the 19 `assert_template` and 11 `assigns` sites. **Must be `next?`-conditional**: 1.0.5 requires `actionpack >= 5.0.1.rc1` and cannot enter the 4.2 bundle — where both APIs are built into the framework and need no gem. + +```ruby +# 4.2 has assert_template and assigns built in; 5.0 extracted them. The gem +# cannot resolve on 4.2 (it needs actionpack >= 5.0.1.rc1), so this is one of +# the few places a next? branch is not a smell -- it is the only expressible +# form. Criterion 12 is about test *code*, not the Gemfile. +gem 'rails-controller-testing' if next? +``` + +Note the interaction with criterion 12: the criterion greps `test/` and `spec/` for `NextRails`, and this branch is in the `Gemfile`, so it does not trip. That is the right outcome and worth saying out loud, because the alternative reading — "no version branches anywhere" — would make criterion 7 unsatisfiable. + +#### C.4 — Devise + +- [ ] `Devise::TestHelpers` → `Devise::Test::ControllerHelpers` at [`test_helper.rb:202`](../../test/test_helper.rb#L202). Devise 4.9.4 is already in both bundles and the new name works on 4.2, so this one *is* unconditional — the only item in stage C that is. + +#### C.5 — Verify + +```bash +bundle exec rake # 4.2 green, through the shim +BUNDLE_GEMFILE=Gemfile.next bundle exec rake units test:functionals +``` + +Stage C is the only stage where 4.2 green and 5.0 green mean genuinely different things: on 4.2 it proves the shim translates correctly, on 5.0 it proves the call sites are right natively. Both are needed; neither substitutes for the other. Run the functionals on 5.0 here even though the full-suite check waits for stage G — the functionals *are* what stage C changed. + +### D — Dummy app config (2.3) + +- [ ] Delete `config.serve_static_assets = true` from [`test/dummy/config/environments/test.rb:11`](../../test/dummy/config/environments/test.rb#L11) and [`production.rb:20`](../../test/dummy/config/environments/production.rb#L20) — both set the framework default on both versions ([1.2](#12-serve_static_assets-has-no-cross-version-replacement--but-it-has-a-better-fix)). +- [ ] Delete `config.static_cache_control` from [`test.rb:12`](../../test/dummy/config/environments/test.rb#L12). +- [ ] Boot the dummy app under both Gemfiles and **read the deprecation output**, which is the actual point of this stage: + +```bash +for gf in Gemfile Gemfile.next; do + echo "== $gf" + BUNDLE_GEMFILE=$gf bundle exec ruby -e 'require "./test/dummy/config/environment"; puts Rails.version' 2>&1 \ + | grep -i "deprecat\|renamed\|unknown" +done +``` + +The `production.rb` deletion deserves a second look before it goes in: that file's comment says the dummy app runs in "faux production mode", so it is the one place where serving static assets is load-bearing rather than incidental. Both frameworks default it to `true`, so deleting the line is still correct — but confirm nothing in the dummy app sets `config.serve_static_files = false` earlier in the chain, or the deletion changes behaviour rather than preserving it. + +### E — Cucumber stack and housekeeping (2.4 / 2.5) + +- [ ] Delete `gem 'poltergeist'` ([`Gemfile:67`](../../Gemfile#L67)) and `require 'capybara/poltergeist'` ([`features/support/env.rb:16`](../../features/support/env.rb#L16)), plus the two commented driver assignments at [`env.rb:19-20`](../../features/support/env.rb#L19). Nothing selects a driver; P1-7. +- [ ] **Do not** bump `cucumber`, `capybara`, `database_cleaner` or `aruba`. None caps Rails 5 (Phase 1). `cucumber-rails 1.4.5` caps `railties < 5.1` — hop 2's first blocker, and it belongs to the phase that does that hop. See [D2](#d2--deferring-the-cucumber-stack). +- [ ] Run the features on 4.2 straight after the deletion. Criterion 11 is a Cucumber pass rate, and 154/154 is the number to hold — removing a `require` from `env.rb` is exactly the kind of change that is obviously safe and occasionally isn't. + +Housekeeping, all three observable rather than assumed: + +- [ ] No blanket warning suppression has returned. `grep -rn 'VERBOSE' test/ spec/ features/` — the comment block at [`test_helper.rb:28-32`](../../test/test_helper.rb#L28) explains why, and should still be the only hit. +- [ ] The `recycle!` patch self-disables on 5.0. It prints `Monkeypatch for ActionController::TestResponse no longer needed` on the 5.0 bundle and `Patching ActionController::TestResponse …` on 4.2 — so grep the CI logs of both jobs for the right line, rather than reasoning about the guard. +- [ ] The new shim from [C.1](#c1--the-shim) prints on 4.2 and is silent on 5.0. Same check, same reason. + +### F — Coverage (2.1, alone) + +Four steps, strictly in this order. The ordering is the whole value of the stage: fixing the gate *before* the bump means the gate is never broken, and measuring before and after on identical code means any movement is attributable to the tool. + +**F.1 — Measure before.** `bundle exec rake && cat coverage/.last_run.json`. Commit nothing. This is the number the "after" is compared against, and it must be taken on the tree that stage E left behind, not on the Phase 0 baseline from memory. + +**F.2 — Fix the gate, still on simplecov 0.12.** [`coverage:check`](../../lib/tasks/core_tasks.rake#L43) currently does `.fetch('result').fetch('covered_percent')`, and 0.18 removed that key — left alone it would `KeyError` after the bump. Teach it both shapes while the old one is still live, so the commit is verifiable: + +```ruby +result = JSON.parse(File.read(path)).fetch('result') + +# simplecov < 0.18 wrote {"result": {"covered_percent": 75.82}}. From 0.18 that +# key is gone and the shape is {"result": {"line": 75.82}}, plus "branch" once +# enable_coverage :branch is on. Accept either, so the gate keeps working across +# the bump -- and abort on neither, rather than comparing nil to a Float. +actual = result['line'] || result['covered_percent'] +abort "#{path} has no line-coverage key (got #{result.keys.inspect})" if actual.nil? +``` + +**F.3 — Bump.** `simplecov` → `~> 0.22.0` in both Gemfiles; `enable_coverage :branch` in [`.simplecov`](../../.simplecov). Two things in that file need re-checking against 0.22, because both were written against 0.12's constraints: + +- The `merge_timeout 3600` workaround stays — five suites in five processes is still the situation, and 0.22 still defaults to 600s. +- The comment at [`.simplecov:18-22`](../../.simplecov#L18) says block filters are used *because* 0.12's `parse_filter` raises `ArgumentError` on a `Regexp`. 0.22 accepts regexes. The filters work either way, so **do not rewrite them** — but the comment now explains a constraint that no longer binds, and leaving it uncorrected sets a trap for whoever reads it next. + +Branch coverage is **reported, not gated**: there is no committed branch baseline, and inventing a floor in the same commit that first measures the number would be gating on something nobody has looked at. Print it and let Phase 3 set the floor. + +**F.4 — Measure after, on identical code.** Re-run and diff against F.1. If the line number moved, the instrument moved — record both numbers and the new baseline in the report, and update `COVERAGE_MINIMUM`'s default in the rake task to match. If it moved for any other reason, stage F is wrong and the bump comes back out. + +- [ ] Before/after line coverage recorded in the report, with the delta explained. +- [ ] A branch percentage appears in the output (criterion 10). +- [ ] `bundle exec rake coverage:check` passes on the post-bump number. + +### G — Exit + +- [ ] Full run on both bundles; all 12 criteria checked with the commands in the phase doc, using [§7](#7-exit-criteria-traceability) as the checklist. +- [ ] [`.github/workflows/ci.yml:147`](../../.github/workflows/ci.yml#L147): drop `continue-on-error: true` from the `next-rails` job, and rewrite the comment above it — it currently says the job "becomes gating in Phase 5", which stops being true here. Rename the job from `Rails 5.0 (Gemfile.next, reporting only)` too; a gating job labelled *reporting only* is how a red build gets ignored. +- [ ] The `next-rails` job currently runs `bundle exec rake` under `if: always()`. Once it is gating, `always()` is doing nothing useful and should go with the `continue-on-error`. +- [ ] Write [`phase-2-harness-report.md`](phase-2-harness-report.md); update the [README](README.md) status table. + +The `Assert the bundle really is Rails 5.0` step stays exactly as it is. It is the thing that would catch dual-boot silently falling back to 4.2 and reporting a green Rails 5 job — a failure mode that becomes considerably more expensive the moment the job is gating. + +--- + +## 4. Decisions + +### D1 — Borrowing two fixes from Phase 3 + +**Decision: take them.** Criterion 3 cannot pass without them, and Phase 3's header explicitly asks for the arity fix first. The alternative — declaring Phase 2 done with criterion 3 unmet — trades a documented two-line deviation for an unmet contract, which is the worse of the two. Phase 3 keeps the remaining ~94 changes; its exit criteria 1 and 7 already cover both borrowed items, so nothing goes untracked. + +### D2 — Deferring the cucumber stack + +**Decision: defer everything except deleting `poltergeist`.** Phase 1 measured that none of `cucumber`, `capybara`, `database_cleaner` or `aruba` caps Rails 5. Bumping four test gems inside a phase whose success metric is "coverage did not move" adds risk to the one number this phase is judged on, for no Rails-5 benefit. `cucumber-rails`'s `< 5.1` cap is real and is hop 2's problem. + +The counter-argument is that hop 2 then carries both `cucumber-rails` **and** 89 controller calls. Stage C removes the second half of that, which is most of why it is worth doing now. + +### D3 — `Cms::IntegrationTestHelper` + +Defined at [`test_helper.rb:205`](../../test/test_helper.rb#L205), included nowhere, and its `login_as` asserts `assert_response 403` immediately after a successful login — it could not have passed in years. **Decision: convert it to keyword form and leave it in place.** Deleting dead code is the right end state but this phase is a port, and "no tests were lost" is much easier to defend if nothing was deleted. Flag it for Phase 3's dead-code item. + +**Correction to an earlier draft of this decision:** it said "criterion 5 covers it." It does not. Criterion 5's grep requires a symbol action — `:[a-z_]+` — and the call is `post login_url, :login => …`, a method call. The site is invisible to the criterion, which is why [1.3](#13-the-site-counts-re-measured) had to find it by hand. Converting it is therefore a judgement call, not a requirement, and it comes with a caveat: **the [C.1](#c1--the-shim) shim does not cover it.** The shim prepends to `ActionController::TestCase`; integration tests go through `ActionDispatch::IntegrationTest#process`, which has a different signature (`process(method, path, parameters, headers_or_env)`). Writing a second shim for one call site in dead code is not worth it. Convert the call and add a comment saying it is unshimmed and would break on 4.2 if this module were ever revived. + +### D4 — mocha 1.x vs 2.x + +**Decision: `~> 1.16`.** `mocha/minitest` exists from 1.5.0, so 1.x satisfies the work item in full. Mocha 2.0 removes the legacy entry points *and* changes `any_instance` and configuration behaviour across 109 call sites. That is a modernisation with no Rails deadline — the same category Phase 1 put `factory_girl` and `capybara` in — and it does not belong in a phase measured on coverage stability. + +--- + +## 5. Risks + +| Risk | Mitigation | +|---|---| +| The kwargs shim silently mistranslates a call and a test passes for the wrong reason | The shim raises on anything it cannot express rather than dropping it. 4.2 stays green through stage C, so any mistranslation shows up as a failure in the same commit that caused it. | +| factory_bot 5's static-attribute removal changes a factory's value rather than erroring | Reviewed per line; `association`/`sequence`/`after` deliberately excluded. 4.2 green after stage B is the check. | +| simplecov's number moves and looks like lost coverage | Stage F is isolated and measured before/after on identical code ([1.5](#15-the-simplecov-bump-is-a-change-to-the-measuring-instrument)). | +| Rails 5 reveals failures underneath the 322 borrowed-fix errors that this phase cannot fix | Then criterion 3 fails on application behaviour, not harness. Report the residue and hand it to Phase 3 rather than papering over it with a `next?` branch — criterion 12 exists for exactly this temptation. | +| factory_bot 5 has dropped the block-argument DSL as well as static attributes, turning a 50-line edit into a rewrite of both factory files | [B.0](#b0--settle-the-dsl-question-before-touching-50-lines) measures this before any editing, and splits the conversion into its own commit if the answer is bad. | +| The two lockfiles drift on gems nobody asked to move when stage B re-resolves | [B.1](#b1--gemfile) diffs both lockfiles and requires an explanation for anything beyond `factory_bot` and `mocha`. Silent drift in the *default* lock is what invalidates the 75.82% everything is measured against. | + +--- + +## 6. Contingencies + +| If | Then | +|---|---| +| A.2's re-measured Rails 5 error count is still large and application-shaped | **Stop and re-scope before stage B.** Phase 2 cannot fix application behaviour without becoming Phase 3. Report the residue, get a decision on whether criterion 3 moves to Phase 3, and do not quietly absorb the work — [D1](#d1--borrowing-two-fixes-from-phase-3) borrowed two fixes on the strength of them being two lines each, and that argument does not extend. | +| The 4.2 suite goes red after any stage | Fix it inside that stage or revert that stage. Do not carry a red 4.2 suite forward "to fix at G" — criterion 1 is the phase's only real safety net, and it only works if it is checked per stage. | +| The kwargs shim cannot be made to intercept cleanly (`prepend` does not win, or `process` is bypassed) | Fall back to option 2 from [C](#c--controller-test-api-22): leave the call sites positional and defer all of stage C to hop 2. Criterion 5 then fails, and that is the honest outcome — a shim that works for 85 of 89 sites is worse than no shim. | +| factory_bot 5.2 will not resolve on the 4.2 bundle | Pin `factory_bot_rails` per-bundle with `next?` and record it as a dual-boot conditional in the report. This would be the first Gemfile branch this phase adds beyond `rails-controller-testing`, so it needs saying out loud rather than slipping in. | +| Coverage moves in stage F and the cause is not obviously the instrument | Revert F and land the rest of the phase without it. Criteria 1 and 3 are the phase; criterion 10 is not worth trading either of them for, and simplecov can be bumped in any later phase at no extra cost. | +| The Cucumber pass rate drops after the poltergeist deletion | Revert the `env.rb` edit, keep the Gemfile deletion, and record that the `require` had a side-effect nobody expected. Criterion 11 outranks tidiness. | +| Criterion 3 is met only because the 5.0 job is running fewer tests than the 4.2 job | That is not criterion 3 being met. Compare test *counts* between the two jobs, not just exit codes — a suite that green-lights by collecting nothing is the exact failure this phase exists to prevent. | + +--- + +## 7. Exit criteria traceability + +| # | Criterion | Stage | Verification | +|---|---|---|---| +| 1 | Coverage still reads the Phase 0 baseline on 4.2 | every stage; F | `bundle exec rake coverage:check` after each stage; F.1/F.4 before-and-after | +| 2 | Suite green on the default Gemfile | every stage | `bundle exec rake` exits 0 | +| 3 | Suite green on `Gemfile.next`, no longer allow-failure | A, G | `BUNDLE_GEMFILE=Gemfile.next bundle exec rake` green in CI; `continue-on-error` gone | +| 4 | Zero `factory_girl` references | B.2 | `grep -rn "factory_girl\|FactoryGirl" . --exclude-dir=vendor --exclude-dir=.git` — **includes comments, planning docs and lockfiles** | +| 5 | Zero positional controller-test calls | C.2 | The phase doc's anchored grep over `test/` and `spec/`. Does **not** reach the integration site — see [D3](#d3--cmsintegrationtesthelper) | +| 6 | No `mocha/setup`, `mocha/mini_test` or `minitest/unit` requires | B.2 | `grep -rn "mocha/setup\|mocha/mini_test\|minitest/unit" test/ spec/` — three files, not two | +| 7 | `rails-controller-testing` declared; 19 + 11 sites pass | C.3 | Gem present in the next bundle only; those tests green on `Gemfile.next` | +| 8 | No `Devise::TestHelpers` | C.4 | `grep -rn "Devise::TestHelpers" test/ spec/` | +| 9 | No `serve_static_assets` | D | `grep -rn "serve_static_assets" test/ config/` | +| 10 | Branch coverage enabled and reported | F.3 | A branch percentage in the run output; **reported, not gated** | +| 11 | Cucumber pass rate ≥ Phase 0 baseline | E | 154/154, compared against `phase-0-baseline.md` | +| 12 | No `NextRails.next?` branch added to make the suite pass | C.3 | `grep -rn "NextRails" test/ spec/` empty. The two branches this phase adds are both in the `Gemfile`, which the criterion does not grep — deliberately, see [C.3](#c3--rails-controller-testing) | + +**A note on criteria 3 and 5 versus what this phase can honestly claim.** Criterion 3 is inherited, not earned: it passes because [D1](#d1--borrowing-two-fixes-from-phase-3) borrowed two Phase 3 fixes, and if the A.2 residue turns out to be non-trivial it will not pass at all. Criterion 5, meanwhile, is satisfied by conversions this hop does not need — the 89 sites are hop 2's blocker, not 5.0's ([1.1](#11-the-phase-docs-central-claim-about-controller-tests-is-wrong)). Both are worth doing here for the reasons given. Neither should be reported as though this phase discovered a clean bill of health; the report should say which criteria were met by this phase's own work and which were met by borrowing forward or paying down early. diff --git a/docs/rails-upgrade/phase-3-backwards-compatible-fixes.md b/docs/rails-upgrade/phase-3-backwards-compatible-fixes.md new file mode 100644 index 000000000..5990d54d8 --- /dev/null +++ b/docs/rails-upgrade/phase-3-backwards-compatible-fixes.md @@ -0,0 +1,128 @@ +# Phase 3 — Backwards-Compatible Code Fixes + +> ## Goal +> **Land ~96 mechanical API changes that work identically on Rails 4.2 and 5.0+, so the bump diff contains only the things that genuinely require a version change.** +> +> Every change in this phase can be reviewed, merged, and deployed on Rails 4.2 today. None needs a `NextRails.next?` branch. + +**Blocking:** 🟡 Not strictly — but skipping it means debugging these ~96 changes *simultaneously* with the bump. +**Rails version at the end of this phase:** 4.2.11.3, deployed to production. + +> ## Two measured items to do first +> +> [Phase 1](phase-1-gem-report.md) ran the unit suite on Rails 5.0.7.2 and got **2 failures and 323 errors**. Both causes are backwards-compatible fixes and therefore belong here — and between them they account for **all but one** of those errors. +> +> | Fix | Impact | Detail | +> |---|---|---| +> | **`create_or_update` arity** — [`lib/cms/behaviors/versioning.rb:230`](../../lib/cms/behaviors/versioning.rb#L230) | **320 of 323 errors** | Rails 4.2 declares `def create_or_update` (`persistence.rb:502`); Rails 5.0 declares `def create_or_update(*args, &block)` (`persistence.rb:546`). The override still has the old zero-arity signature, so **every save on Rails 5 raises** `ArgumentError: wrong number of arguments`. Accepting `(*args, &block)` and passing through works identically on 4.2. Plausibly the highest-leverage single change in the whole upgrade. | +> | **`HTML::FullSanitizer`** — [`lib/cms/content_filter.rb:12`](../../lib/cms/content_filter.rb#L12) | 2 failures | The constant comes from `rails-deprecated_sanitizer`, which is only in the bundle because `rails-dom-testing 1.x` depends on it — and 1.x caps `activesupport < 5.0`. On Rails 5 it leaves the bundle and the line raises `NameError`. **Two exits:** fix the call site to `Rails::Html::FullSanitizer`, or declare `rails-deprecated_sanitizer` explicitly (it requires only `activesupport >= 4.2.0.alpha`, no upper bound). The first is where it should end up. | +> +> Do the arity fix first and re-measure before scoping the rest of this phase — with 99% of the errors gone, what remains underneath is currently unknown. +> +> One caution carried from Phase 1: `content_filter.rb` is **8/8 lines covered and line 12 is hit twice**, and it still breaks. Coverage did not miss it; coverage cannot express version-dependent resolution. Do not use the coverage report to decide which of the ~96 changes are safe. + +--- + +## Why this phase exists + +This is the skill's "deploy small changes to production before the version bump" principle, applied concretely. + +Each of these APIs has a replacement that already exists in Rails 4.2. `before_action` works on 4.2. `update` works on 4.2. `optional: true` works on 4.2. So there is no reason to carry them into the bump, where a failure could be caused by the rename *or* by Rails 5 *or* by a gem — three hypotheses instead of one. + +Two of these items also retire risk that later phases inherit: + +- **`belongs_to ... optional: true` (29 sites) is not deferrable.** BrowserCMS is an engine — `lib/cms/engine.rb:7` declares `isolate_namespace Cms` — and `load_defaults` appears nowhere in the repo. The `belongs_to_required_by_default` flag is owned by the **host application**, so the usual "bump the gem, leave `load_defaults` at 4.2, decide later" escape hatch does not exist for a library. A host app on `load_defaults 5.0` exercises required-by-default against these models the day it upgrades. +- **`HTML::FullSanitizer` breaks the instant Rails 5 resolves**, because the gem supplying it leaves the bundle. It has to be fixed before the bump can be green, not after. + +## Supporting documentation + +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §4](../../RAILS_UPGRADE_TEST_PRIORITY.md) — the Tier C table, with skill-sourced removal versions, and the closing list of backwards-compatible changes +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §3, B5](../../RAILS_UPGRADE_TEST_PRIORITY.md) — the full `belongs_to` breakdown, all 29 sites +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §0.3](../../RAILS_UPGRADE_TEST_PRIORITY.md) — **read this before touching `belongs_to`.** Why an engine cannot opt out, and why the test must force the flag on. +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §0.2](../../RAILS_UPGRADE_TEST_PRIORITY.md) — ➕A1 (29 not 24), ➕A3 (sanitizer), ➕A4 (`.deliver!`), ➕A5 (`File.exists?` ×5) +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §5](../../RAILS_UPGRADE_TEST_PRIORITY.md) — **the do-not-touch list.** Notably: leave the ~13 `find_by_*` call sites alone. +- Skill: `version-guides/upgrade-4.2-to-5.0.md` §3 (`belongs_to`), `detection-scripts/patterns/rails-42-patterns.yml`, `rails-50-patterns.yml`, `rails-51-patterns.yml` + +## Work items + +Grouped by what breaks if you get them wrong. Counts are grep-verified. + +### 3.1 — Breaks at 5.0 (must land before the bump) + +- [ ] **`belongs_to` → add `optional: true` where the association is legitimately nil. 29 sites.** + - 24 in `app/models/cms/` + - **5 injected by behaviors**, which is where the blast radius is: `behaviors/userstamping.rb:16-17` (`created_by`, `updated_by`), `behaviors/categorizing.rb:16` (`category`), `behaviors/versioning.rb:115` (version → parent), `behaviors/dynamic_attributes.rb:168` (`base_class`). These apply to *every* model using the behavior, in this engine and in every downstream project. + - Near-certain candidates for `optional: true`: all polymorphic associations (`SectionNode#node`, `Connector#connectable`, `Attachment#attachable`, `Tagging#taggable`), **`Category#parent`** (self-referential — every root category has a nil parent, so required-by-default breaks the category tree outright), `Task#assigned_by` / `#assigned_to`, and userstamping's pair. + - The skill's instruction: add `optional: true` **only** where nil is legitimate. Leave genuinely-required associations alone — this is an audit, not a find-and-replace. +- [ ] **`HTML::FullSanitizer` → `Rails::Html::FullSanitizer` / `ActionView::Base.full_sanitizer`.** One site: `lib/cms/content_filter.rb:12`. Also update `test/functional/cms/inline_controller_test.rb:7`, which asserts on the doomed gem directly. +- [ ] **`.deliver` / `.deliver!` → `deliver_now` / `deliver_now!`. 2 sites.** `app/models/cms/email_message.rb:58` (`.deliver`, covered) and **`:18` (`m.deliver!`, uncovered)**. Preserve the bang — `deliver!` becomes `deliver_now!`, not `deliver_now`. (`:15` is `def self.deliver!`, a definition; leave it.) +- [ ] **Declare `responders` in `browsercms.gemspec`.** It's currently in the bundle only as a transitive dependency of `devise`. Six `respond_with` / class-level `respond_to` sites depend on it, including `content_controller.rb:79` — the main page-serving path. One line; removes a landmine before the Devise upgrade can move it. +- [ ] **Unversioned `ActiveRecord::Migration` → `ActiveRecord::Migration[4.2]`. 2 sites.** `db/migrate/20080815014337_browsercms300.rb`, `db/migrate/20130327184912_browsercms400.rb`. +- [ ] **`save!` takes `(*args, &block)`. 1 site** *(added after Phase 2)*. [`lib/cms/behaviors/versioning.rb:271`](../../lib/cms/behaviors/versioning.rb#L271) declares `save!(perform_validations=true)`, but no Rails version calls `save!` with a positional boolean: 4.2 calls `save!(:validate => x)` and 5.0 calls `save!(validate: x, &block)`, both from the collection-association insert path. The parameter therefore receives a truthy Hash and validations run in the path where the framework asked for them to be skipped — **silently, on both versions** — and on 5.0 the block `create_or_update` yields after insert is discarded here, one method above the `(*args, &block)` signature Phase 2 gave that method. Same defect as the arity fix, one level up, and the arity fix is what makes it reachable. ⚠️ **This one changes behaviour** — see the implementation plan's D8. + +### 3.2 — Breaks at 5.1 (free to do now) + +- [ ] **`*_filter` → `*_action`. 37 sites across 17 files.** 14 controllers plus `lib/cms/acts/content_page.rb`, `lib/cms/admin_tab.rb`, `lib/cms/authentication/controller.rb`. Behaviour is identical; this is a rename. Failure mode on 5.1 is a boot-time `NoMethodError`, so it needs no test. +- [ ] **`render text:` → `render plain:`. 4 sites.** `content_block_controller.rb:138`, `form_fields_controller.rb:43`, plus 2 in `tests/pretend_controller.rb`. Use `render html:` if HTML was actually intended. **Both production sites are on untested error branches** — see [Phase 4](phase-4-characterization-tests.md), which adds tests for them precisely because nothing exercises them. +- [ ] **`Relation#uniq` → `.distinct`. 1 site.** `section_nodes_controller.rb:75`, inside `nodes_to_update_on_success`. Receiver already verified as a Relation, not an Array. + +### 3.3 — Breaks at 6.0 (free to do now, and 16 sites is worth removing early) + +- [ ] **`update_attributes` / `update_attributes!` → `update` / `update!`. 16 sites.** 7 in models, 4 in controllers, 5 in behaviors. ⚠️ `app/models/cms/guest_user.rb:48` *defines* a method called `update_attributes` — check its callers before renaming anything. + +### 3.4 — Version-agnostic cleanups + +- [ ] **`File.exists?` → `File.exist?`. 5 sites** (the supporting analysis originally found 1): `app/portlets/list_portlet.rb:22` (**uncovered**), `lib/cms/caching.rb:42`, `lib/cms/attachments/attachment_serving.rb:44`, `lib/tasks/core_tasks.rake:51`, `lib/generators/cms/content_block/content_block_generator.rb:26`. Two are live request-path code. +- [ ] **Bare `HashWithIndifferentAccess` → `ActiveSupport::HashWithIndifferentAccess`. 2 sites.** `app/models/cms/page_component.rb:10` (covered), `app/models/cms/portlet.rb:228` (**uncovered**). The removal version is unverified, so qualify both now and stop tracking it. +- [ ] **Delete dead code** rather than porting it: `lib/cms/commands/to_version400.rb` (BrowserCMS 4.0.0 upgrade command, two majors dead), `app/portlets/deprecated_placeholder.rb`. Also `lib/cms/behaviors/namespacing.rb` — an empty deprecated shim. Note `lib/cms/form_builder/deprecated_inputs.rb` is still contractually alive (`features/content_blocks/deprecated_form_inputs.feature` exists) — keep it. + +### 3.5 — The Rails 5 asset chain *(added after Phase 2)* + +**Neither of these is a backwards-compatible mechanical change, and they are in this phase because nothing else in it can close the phase without them.** Phase 2 handed over five remaining Rails 5 defects; one is an asset failure worth **132 of the 148 cucumber failures**, and a second sits directly behind it, unmeasured until the first clears. Both are argued in the [implementation plan](phase-3-implementation-plan.md) — findings §1.14 and §1.15, decisions D6 and D7, stage D′. + +- [ ] **Move `ckeditor_rails` off 4.3.4 on the Rails 5 bundle.** [`browsercms.gemspec:51`](../../browsercms.gemspec#L51) pins `~> 4.3.0`, and 4.3.4 dispatches its own Railtie on `case ::Rails.version.to_s` with branches for `/^4/` and `/^3/` only. Under Rails 5 no branch matches, the gem defines no `Rails::Engine`, its `lib/assets` never joins the asset load path, and `//= require ckeditor-jquery` ([`app/assets/javascripts/bcms/ckeditor.js:5`](../../app/assets/javascripts/bcms/ckeditor.js#L5)) cannot resolve — so every page that renders the CMS layout raises `ActionView::Template::Error`. **No gem-compatibility instrument could have caught this**: the gem declares no Rails constraint, resolves cleanly on both bundles, and boots. It fails only when something renders. ⚠️ The gem's version *is* CKEditor's version, so this is an editor upgrade as well as a dependency bump, and the suite has zero `@javascript` scenarios to detect a regression in it. +- [ ] **Declare the engine's assets to sprockets-rails 3.** `Gemfile.next.lock` resolves sprockets-rails **3.2.2** where `Gemfile.lock` has **2.3.3**, and 3.x raises `Sprockets::Rails::Helper::AssetNotPrecompiled` for anything referenced through `image_tag` / `asset_path` that is not declared. [`lib/cms/engine.rb:122-133`](../../lib/cms/engine.rb#L122) declares eight JS/CSS files and no images, and there is no `app/assets/config/manifest.js` anywhere in the engine. The first failure is `cms/logo.png` from [`app/views/layouts/cms/_main_menu.html.erb:5`](../../app/views/layouts/cms/_main_menu.html.erb#L5), and it will not be the last — this item has no measured upper bound. **The declaration belongs in the engine, not in `test/dummy/`**: consuming applications hit this identically at [Phase 5](phase-5-the-5.0-bump.md), and a dummy-app fix does not travel to them. + +--- + +## Exit criteria + +Every criterion is a grep that must return **zero results**, plus the two behavioural ones at the end. + +| # | Criterion | How to verify | +|---|---|---| +| 1 | Suite green on **both** Gemfiles, coverage at or above the Phase 2 number | CI both jobs passing | +| 2 | All 29 `belongs_to` declarations have been **audited**, with `optional: true` added where nil is legitimate | Every `belongs_to` in `app/models/` and `lib/cms/behaviors/` either carries `optional:` or is covered by a test asserting it is required (see [Phase 4](phase-4-characterization-tests.md)) | +| 3 | No `*_filter` callbacks remain | `grep -rnE "\b(before\|after\|around\|skip_before\|skip_after)_filter\b" app/ lib/` | +| 4 | No `update_attributes` calls remain | `grep -rn "update_attributes" app/ lib/` — expect only the `guest_user.rb` definition if it was kept deliberately | +| 5 | No `render text:` / `render :text =>` remains | `grep -rnE "render\s+(:text\s*=>\|text:)" app/ lib/` | +| 6 | No `.uniq` on a Relation remains | `grep -rn "\.uniq" app/controllers/ app/models/` reviewed — any remaining receiver is an Array | +| 7 | No `HTML::FullSanitizer` reference anywhere | `grep -rn "HTML::FullSanitizer" app/ lib/ test/ spec/` | +| 8 | No bare `.deliver` / `.deliver!` **call** on a mailer | `grep -rn "\.deliver\b\|\.deliver!" app/ lib/ \| grep -v deliver_now \| grep -v deliver_later \| grep -v "def self\.deliver"` — the `def self.deliver!` definition at `email_message.rb:15` is excluded deliberately; it stays | +| 9 | No `File.exists?` remains | `grep -rn "File.exists?" app/ lib/` | +| 10 | No unqualified `HashWithIndifferentAccess` | `grep -rnE "(^\|[^:A-Za-z])HashWithIndifferentAccess" app/ lib/` | +| 11 | `responders` is declared in the gemspec | `grep -n responders browsercms.gemspec` returns a line | +| 12 | Both legacy migrations are version-qualified | `grep -rn "ActiveRecord::Migration$" db/migrate/` returns nothing | +| 13 | **The ~13 `find_by_*` call sites are untouched** | `git diff` shows no changes to `find_by_login`, `find_by_path`, `find_by_code`, `find_by_from_path`. These are *still supported*; changing them is pure waste. | +| 14 | **Zero `NextRails.next?` branches were added** | `grep -rn "NextRails" app/ lib/` returns nothing. If any change needed a version branch, it did not belong in this phase. | +| 15 | The changes are deployed to production on Rails 4.2 | Deployed and stable, per the skill's "deploy small changes before the version bump" methodology | +| 16 | **The `next-rails` CI job is green** *(added after Phase 2)* | The job was made gating in Phase 2 with the explicit note that *"CI is red on every PR until Phase 3 lands."* Turning it green is what this phase is for | +| 17 | **The Rails 5 asset chain is clear, and the fix is in the engine** *(added after Phase 2)* | No `couldn't find file` and no `AssetNotPrecompiled` in the `Gemfile.next` functional and cucumber logs — **and** `git diff` for §3.5 touches `lib/cms/engine.rb`, `app/assets/config/manifest.js` or `browsercms.gemspec` and nothing under `test/dummy/`. The second half is the one that can pass wrongly | + +**Done means:** criteria 3–12 are all empty greps, criterion 14 is empty, and the suite is green on both Gemfiles with coverage intact. The bump diff is now small enough to reason about. + +**Criteria 16 and 17 were added after Phase 2** measured what was actually still red on Rails 5. Neither is a grep, which is why the original table could not express them — and 16 is the only criterion here that states the phase's purpose directly. + +> **Criterion 14 is the phase's definition of correctness.** If a change required a `NextRails.next?` branch, it isn't backwards-compatible and belongs in [Phase 5](phase-5-the-5.0-bump.md) instead. + +--- + +## Explicitly not in this phase + +- **No Rails bump.** Still 4.2.11.3. That is the point — these all work on 4.2. +- **No new tests**, except where a Tier C fix has a behavioural choice in it. The `render text:` error branches and `Relation#uniq` in `move_to_position` get tests in [Phase 4](phase-4-characterization-tests.md), not here. +- **No Zeitwerk work.** `require_dependency` at `content_types_controller.rb:1` stays. Zeitwerk lands at **6.0** and is a phase of its own on a later hop. +- **No `ApplicationRecord`.** 28 models inherit `ActiveRecord::Base` directly. The skill classifies this `kind: migration` — fix-when-ready, not fix-before-bump — and for an isolated engine the right target is `Cms::ApplicationRecord`, which interacts with `lib/browsercms.rb:36-67` doing `ActiveRecord::Base.send(:include, ...)` at require time. **Deliberately deferred to the 6.0 autoloading work.** Recorded so it reads as a decision, not an oversight. +- **No Paperclip, Devise, SimpleForm, Compass, or jquery-rails migration.** Later hops. +- **`ckeditor_rails` is the one gem that moves** (§3.5), and it is an exception with its reasoning recorded rather than a precedent. It is not a migration — it is the minimum version change that lets the engine's own layout render under Rails 5 at all, and without it criteria 16 and 17 cannot be met by any amount of code fixing. +- **Not fixing the two new `ActionController::Parameters` sites** (`content_controller.rb:72`, `path_helper.rb:33-36`). They're real, but at 153 and 49 hits they're on lines CI executes — CI is the detector. See [Phase 4](phase-4-characterization-tests.md) for the four sites that *do* need attention. diff --git a/docs/rails-upgrade/phase-3-implementation-plan.md b/docs/rails-upgrade/phase-3-implementation-plan.md new file mode 100644 index 000000000..df8179c9c --- /dev/null +++ b/docs/rails-upgrade/phase-3-implementation-plan.md @@ -0,0 +1,636 @@ +# Phase 3 — Implementation Plan + +**Implements:** [`phase-3-backwards-compatible-fixes.md`](phase-3-backwards-compatible-fixes.md) +**Entry condition:** Phase 2 complete at 11 of 12 criteria — the 4.2 suite is green at **78.35%** with cucumber 154/154, and on `Gemfile.next` the unit suite runs at 756 tests / 2F / 3E while cucumber collects 154 scenarios and passes 6 ([`phase-2-harness-report.md`](phase-2-harness-report.md)). +**Rails at the end of this phase:** `Gemfile` still 4.2.11.3, green, deployed. `Gemfile.next` green too — this is the phase where the `next-rails` job stops being red. + +Same shape as the [Phase 0](phase-0-implementation-plan.md), [Phase 1](phase-1-implementation-plan.md) and [Phase 2](phase-2-implementation-plan.md) plans: findings first, then an ordered work stream, then the decisions that need a human. + +> ### Read this first +> The phase document was written before Phases 0 and 2 ran, and **three of its work +> items have already been done** by those phases. Of the ones that remain, measurement +> moved four of them and removed one from the phase entirely. The single largest item — +> the load error that is currently taking down the whole Rails 5 functional suite — is +> **not in the phase document's work items at all**; it arrived in Phase 2's handoff. +> +> Net: the doc's "~96 mechanical changes" is **~89 edits**, they are not all mechanical, +> and the ordering matters more than the doc implies. +> +> **Three items were added after this plan's first draft**, from a second reading of +> Phase 2's residue. Two of them are the Rails 5 asset chain +> ([1.14](#114-ckeditor_rails-434-is-rails-4-only-at-runtime-and-no-declaration-says-so), +> [1.15](#115-sprockets-rails-3-requires-every-referenced-asset-to-be-declared)) — **132 +> of the 148 cucumber failures, and the last thing standing between this phase and +> criterion 16.** The first draft named the symptom in a contingency and said "scope it +> as its own item"; **stage D′** is that item. The third is a second signature override +> of exactly the kind Phase 2 fixed +> ([1.16](#116-save-is-the-same-signature-override-that-p1-2-was)). None of the three is +> a mechanical rename and the first is not strictly backwards-compatible, so all three +> are argued in [D6](#d6--how-far-to-move-ckeditor_rails), +> [D7](#d7--how-to-satisfy-sprockets-rails-3) and +> [D8](#d8--the-save-override-forwards-and-that-changes-behaviour). + +--- + +## 1. Pre-flight findings + +Measured against the working tree at `10ac4ace`. Every count below is a grep or a read of the vendored gem, not a restatement of the phase document. + +### 1.1 Three work items are already done + +| Item | Doc says | Measured now | Landed in | +|---|---|---|---| +| `create_or_update` arity | 320 of 323 Rails 5 errors | done — `versioning.rb` takes `(*args, &block)` | Phase 2, stage A ([D1](phase-2-implementation-plan.md#d1--borrowing-two-fixes-from-phase-3)) | +| `HTML::FullSanitizer` | 1 site + 1 test | **0** — `grep -rn "HTML::FullSanitizer" app/ lib/ test/ spec/` returns nothing. The three surviving `FullSanitizer` hits are `Rails::Html::FullSanitizer` at [`content_filter.rb:18`](../../lib/cms/content_filter.rb#L18) and [`inline_controller_test.rb:7`](../../test/functional/cms/inline_controller_test.rb#L7), plus a comment | Phase 2, stage A | +| `File.exists?` → `File.exist?` | **5 sites** | **0.** All five named sites already read `exist?`. `git log -S "File.exists?" -- app lib` puts the removal in `6da1d60d` — *"[CMS-420] phase 0 implemented"* | Phase 0 | + +Exit criteria 7 and 9 pass today. Do not open them as work. + +### 1.2 The migration item cannot land in this phase — and does not break at 5.0 + +The doc files "unversioned `ActiveRecord::Migration` → `ActiveRecord::Migration[4.2]`" under **3.1, breaks at 5.0**. Both halves of that are wrong, and both are readable in the vendored gems. + +**It is not backwards-compatible.** `self.[]` is defined at [`activerecord-5.0.7.2/lib/active_record/migration.rb:527`](../../vendor/bundle/gems/activerecord-5.0.7.2/lib/active_record/migration.rb#L527). The 4.2 gem has no `self.[]` anywhere in `migration.rb`, and no `active_record/migration/compatibility.rb` at all — the 4.2 directory holds only `command_recorder.rb` and `join_table.rb`. So `class Browsercms300 < ActiveRecord::Migration[4.2]` raises `NoMethodError` on the default bundle. Every other change in this phase runs on both; this one runs on neither-then-both. + +**And it does not break at 5.0.** 5.0's guard is not a raise — [`migration.rb:520-525`](../../vendor/bundle/gems/activerecord-5.0.7.2/lib/active_record/migration.rb#L520): + +```ruby +def self.inherited(subclass) # :nodoc: + super + if subclass.superclass == Migration + subclass.include Compatibility::Legacy + end +end +``` + +A direct subclass is silently given `Compatibility::Legacy`, which is 4.2 behaviour. The `StandardError: Directly inheriting from ActiveRecord::Migration is not supported` message arrives at **5.1**. + +**Resolution: this item leaves Phase 3.** It belongs in [Phase 5](phase-5-the-5.0-bump.md) (where the 4.2 bundle no longer has to run it) or in the 5.1 hop of [Phase 6](phase-6-subsequent-hops.md) (where it becomes forced). Exit criterion 12 is struck — see [§7](#7-exit-criteria-traceability). Only two files are affected, so the deferral costs nothing. + +### 1.3 The Rails 5 load error is a deletion, and it is the highest-leverage change in the phase + +Phase 2's report names `skip_callback :redirect_to_cms_site` as the worst of five remaining defects — a *load* error that takes the entire functional suite down and causes most of the 148 cucumber failures. It is not in the phase document's work items. Here is the full trace, because the fix is not the obvious one. + +**The callback is never registered in that class's chain.** `redirect_to_cms_site` is *defined* as a method in [`lib/cms/controllers/admin_controller.rb:42`](../../lib/cms/controllers/admin_controller.rb#L42), which is included into `Cms::ApplicationController`. But it is *registered as a callback* in exactly one place — [`base_controller.rb:3`](../../app/controllers/cms/base_controller.rb#L3). And `Cms::ContentController` inherits from `Cms::ApplicationController`, not `Cms::BaseController`. They are siblings. So the skip has always been skipping nothing; the method resolving is what let the mistake survive a decade. + +**Two orphan sites, not one:** + +| Site | Superclass | Chain has the callback? | +|---|---|---| +| [`content_controller.rb:11`](../../app/controllers/cms/content_controller.rb#L11) | `Cms::ApplicationController` | ❌ | +| [`portlet_controller.rb:4`](../../app/controllers/cms/portlet_controller.rb#L4) | `Cms::ApplicationController` | ❌ | + +The report names only the first. The second is the same defect and will surface the moment the first is fixed. + +**4.2 is silent, not warning.** [`activesupport-4.2.11.3/lib/active_support/callbacks.rb`](../../vendor/bundle/gems/activesupport-4.2.11.3/lib/active_support/callbacks.rb) `skip_callback` finds `nil` and calls `chain.delete(nil)` — a no-op with no deprecation. 5.0 added the guard: + +```ruby +options[:raise] = true unless options.key?(:raise) +... +if !callback && options[:raise] + raise ArgumentError, "#{type.to_s.capitalize} #{name} callback #{filter.inspect} has not been defined" +end +``` + +**Do not reach for `raise: false`.** It works on 5.0 by design and happens to work on 4.2 by accident — 4.2 would put `{raise: false}` into `options`, making `options.any?` true, and only the `if filter` guard (nil here) stops it being passed to `Callback#merge`, which understands `:if`/`:unless` and nothing else. That is a coincidence, not a contract, and it would preserve two lines that are documented no-ops. **Delete both lines.** Backwards-compatible by construction: on 4.2 the behaviour is provably identical because `chain.delete(nil)` did nothing. + +This is stage A, alone, first — nothing else in this phase is measurable on Rails 5 until it lands. + +### 1.4 `email_message.rb:18` is not a mailer call, and renaming it would break the model + +The doc: *"**`.deliver` / `.deliver!` → `deliver_now` / `deliver_now!`. 2 sites.** … Preserve the bang — `deliver!` becomes `deliver_now!`."* + +[`app/models/cms/email_message.rb:15-20`](../../app/models/cms/email_message.rb#L15): + +```ruby +def self.deliver! + undelivered.all(:limit => 100).each do |m| + m.deliver! # <- :18 + end +end +``` + +`m` is a `Cms::EmailMessage`, and `deliver!` is that model's **own instance method**, defined at `:57`. There is no `deliver_now!` on the model to rename it to. The model does already define `deliver_now` at `:53` — as a one-line wrapper around `deliver!` — so the name is taken and the meaning is inverted from the mailer's. + +**One real site, not two:** `:58`, `Cms::EmailMessageMailer.email_message(self).deliver` → `.deliver_now`. Criterion 8's grep must keep tolerating `m.deliver!` alongside the excluded `def self.deliver!`; the amended form is in [§7](#7-exit-criteria-traceability). + +### 1.5 `.uniq` measures four sites, and the one that must change has a latent bug in it + +Three of the four are Arrays and stay: `portlet.rb:86` (`.flatten.compact.uniq`), `content_type.rb:52` (`subclasses.uniq!`), `page_template.rb:29` (`.map{}.sort.uniq`). The Relation is confirmed — [`section_node.rb:53`](../../app/models/cms/section_node.rb#L53) `not_of_type` is `where(...)`. + +But look at the whole expression, [`section_nodes_controller.rb:74-75`](../../app/controllers/cms/section_nodes_controller.rb#L74): + +```ruby +(previous_parent.children.not_of_type(HIDDEN) + + target_parent.children.not_of_type(HIDDEN).uniq).map { |n| [n.id, n.position, n.depth] } +``` + +The `.uniq` binds to the **second** relation only. It becomes `SELECT DISTINCT` within that half and does nothing about duplicates between the two halves — which is the only kind this method could plausibly produce, since a node can be a sibling in both the previous and target parent. The code reads like a union dedupe and is not one. + +**Take `.distinct` and change nothing else.** It is byte-for-byte the same behaviour and it is what this phase is for. Hoisting the `uniq` outside the parens would fix the bug and is therefore not a backwards-compatible mechanical change — hand it to [Phase 4](phase-4-characterization-tests.md), which already owns a test for `move_to_position`. + +### 1.6 Two of the four `render text:` sites are cucumber-covered, and they render HTML + +The doc says both production sites are on untested error branches, which is right, and implies the `pretend_controller` pair is scaffolding, which is wrong. [`features/acts_as_content_page.feature:25`](../../features/acts_as_content_page.feature#L25) visits `/tests/open` and asserts on two content rows; `:49` visits `/tests/restricted`. Both routes are live in [`test/dummy/config/routes.rb:12-16`](../../test/dummy/config/routes.rb#L12), and both actions emit `

` markup. + +`render plain:` sets `Content-Type: text/plain`. That is a behaviour change under a currently-passing feature, and it is avoidable. So the four sites split two and two: + +| Site | Content | Target | +|---|---|---| +| `content_block_controller.rb:138` | `"Not Implemented"` | `render plain:` | +| `form_fields_controller.rb:43` | `"Fail"` | `render plain:` | +| `pretend_controller.rb:14` | `"

Restricted

…"` | `render html: "…".html_safe` | +| `pretend_controller.rb:18` | `"

Open Page

…"` | `render html: "…".html_safe` | + +`render html:` exists on 4.2. Without `.html_safe` it escapes the markup, which is the same content-type-preserving-but-output-changing trap one layer down — so the `html_safe` is load-bearing, not decoration. + +### 1.7 Two of the three "dead code" deletions are not dead + +**`app/portlets/deprecated_placeholder.rb` — keep.** It is the STI target of a shipped migration, [`db/migrate/20130327184912_browsercms400.rb:76`](../../db/migrate/20130327184912_browsercms400.rb#L76): + +```ruby +Cms::Portlet.connection.execute("UPDATE cms_portlets SET type = 'DeprecatedPlaceholder' WHERE type = 'ResetPasswordPortlet'") +``` + +Every downstream database that ran browsercms400 has rows whose `type` column names this class. Deleting it turns each of them into `ActiveRecord::SubclassNotFound` on load. The class's own comment says exactly this is its job. It is not deprecated code; it is a tombstone, and tombstones are load-bearing. + +**`lib/cms/behaviors/namespacing.rb` — not an empty shim.** The empty `Cms::Behaviors::Namespacing` module exists to satisfy [`lib/cms/behaviors.rb:30`](../../lib/cms/behaviors.rb#L30), which globs the directory and `constantize`s a module name out of every filename: + +```ruby +Dir["#{File.dirname(__FILE__)}/behaviors/*.rb"].each do |b| + require File.join("cms", "behaviors", File.basename(b, ".rb")) + ActiveRecord::Base.send(:include, "Cms::Behaviors::#{File.basename(b, ".rb").camelize}".constantize) +end +``` + +That part is safe to delete, because the glob is file-driven — remove the file and the include goes with it. But the file's actual payload is **`Cms.table_prefix=`**, a public deprecated API that emits a deprecation warning pointing at issue #639. Deleting the file removes a public method from the engine's surface without a deprecation cycle. Either relocate `Cms.table_prefix=` to `lib/browsercms.rb` and delete the rest, or leave the file alone. This plan takes the second option — see [D4](#d4--namespacingrb-stays). + +**`lib/cms/commands/to_version400.rb` — genuinely dead. Delete.** Zero references across `app lib test spec features config`; nothing requires `cms/commands`; `lib/` is not on an autoload path ([`engine.rb:112-116`](../../lib/cms/engine.rb#L112) lists `vendor`, `app/mailers`, `app/helpers`, `app/controllers`, `app/models`, `app/portlets` and two `Rails.root` paths). Nothing can reach it. + +### 1.8 `update_attributes` is 14 calls, and `guest_user.rb` is a security guard the rename would disarm + +16 grep hits = **14 calls**, one definition (`guest_user.rb:48`), one comment (`dynamic_attributes.rb:231`). + +The doc flags `guest_user.rb` with a *"check its callers"* warning. The real problem is sharper than that. [`app/models/cms/guest_user.rb:43-54`](../../app/models/cms/guest_user.rb#L43) blocks writes three ways: + +```ruby +def update_attribute(name, value); false; end +def update_attributes(attrs={}); false; end +def save(perform_validation=true); false; end +``` + +But in **both** 4.2 and 5.0, `update_attributes` is an *alias*, not the method — `persistence.rb:247/256` on 4.2 and `:270/279` on 5.0 both read `def update(attributes)` … `alias update_attributes update`. Overriding the alias name in a subclass leaves `update` bound to the original implementation. **`guest.update(...)` bypasses this guard today.** `save` is separately overridden, so the write still fails at the end — but it fails silently rather than at the guard, and only by luck. + +So the mechanical rename has a real trap: rename the 14 callers to `update` while leaving the definition alone and every renamed path routes around the guard. + +**Rename the definition too, and keep the old name as an alias:** + +```ruby +def update(attrs = {}) + false +end +alias update_attributes update +``` + +That closes the existing hole rather than opening a new one, keeps any downstream caller of `update_attributes` working, and leaves criterion 4's grep matching exactly one line — the alias — which is the "kept deliberately" case the criterion already anticipates. It is a behaviour change; it goes in its own commit with this reasoning in the message, not folded into a 14-site rename. See [D3](#d3--guestuserupdate_attributes-becomes-an-alias). + +### 1.9 `belongs_to` is 29 sites as claimed — plus a 30th, one hop downstream + +24 in `app/models/cms/` and 5 in `lib/cms/behaviors/` (`versioning.rb:115`, `dynamic_attributes.rb:168`, `categorizing.rb:16`, `userstamping.rb:16` and `:17`). The other four `belongs_to` hits in `behaviors/` are the `belongs_to_category` macro name in `categorizing.rb:8-12`, not declarations. The doc's count is right. + +**The 30th is [`lib/templates/active_record/model/model.rb:4`](../../lib/templates/active_record/model/model.rb#L4)** — a generator template that emits a bare `belongs_to :<%= attribute.name %>` into every model scaffolded in every downstream project. Same failure mode, deferred by one `rails generate`. The generator has no way to know whether nil is legitimate, so the template can't just gain `optional: true`; flag it and hand it to the 5.0 bump, where the generated code's target Rails version is settled. + +### 1.10 Two of the doc's four "near-certain" `optional: true` candidates are already validated as required + +The doc names *"all polymorphic associations (`SectionNode#node`, `Connector#connectable`, `Attachment#attachable`, `Tagging#taggable`), `Category#parent`, `Task#assigned_by` / `#assigned_to`, and userstamping's pair"* as near-certain. Two are contradicted by validations already in the models: + +| Association | Existing validation | Verdict | +|---|---|---| +| `Connector#connectable` | [`connector.rb:44`](../../app/models/cms/connector.rb#L44) — `validates_presence_of :page_id, :page_version, :connectable_id, :connectable_type, :container` | **Required.** Leave alone. Also settles `Connector#page`. | +| `Task#assigned_by`, `#assigned_to`, `#page` | [`task.rb:27-29`](../../app/models/cms/task.rb#L27) — all three have `validates_presence_of … :message => "is required"` | **Required.** Leave all three alone. | +| `Category#category_type` | [`category.rb:12`](../../app/models/cms/category.rb#L12) — `validates_presence_of :category_type_id` | **Required.** Leave alone. | +| `Category#parent` | none | **`optional: true`** — self-referential, every root has a nil parent | +| `Attachment#attachable` | [`attachment.rb:23`](../../app/models/cms/attachment.rb#L23) validates `attachable_type` but **not** `attachable_id` | **Needs a decision.** `belongs_to` required-by-default validates the *loaded object*, so type-without-id fails it. Not resolvable by inspection. | + +**And the database will not help.** `test/dummy/db/schema.rb` has **no `null: false` on any foreign-key column** in `cms_categories`, `cms_taggings`, `cms_attachments`, `cms_connectors`, `cms_section_nodes`, `cms_groups`, `cms_form_fields` or `cms_form_entries`. The schema is uniformly permissive, so it carries zero signal about intent. + +That leaves exactly two evidence sources for the remaining sites: an existing presence validation, or a test run with the flag forced on. Which is the next finding. + +### 1.11 Criterion 2 is not verifiable inside this phase's own scope + +`belongs_to_required_by_default` is off on 4.2 and off on 5.0 for an engine (`load_defaults` appears nowhere in the repo, and per the phase doc's own §0.3 argument, the flag is the host application's to set). So **adding `optional: true` to 29 associations changes nothing observable on either bundle**, and removing it would also change nothing. The whole audit is unfalsifiable by the suite as it stands. + +Criterion 2 half-acknowledges this — it asks that each association *"either carries `optional:` or is covered by a test asserting it is required (see Phase 4)"*. The test it points at does not exist yet. + +This is the same structural situation Phase 2 hit with its criterion 3, and it takes the same resolution: **borrow the forced-flag test from Phase 4 as a declared prerequisite.** It is one `setup` block that flips `ActiveRecord::Base.belongs_to_required_by_default = true` around a model-instantiation sweep, and without it the largest work item in this phase ships unverified. See [D2](#d2--borrowing-the-forced-flag-test-from-phase-4). + +### 1.12 `responders` must be declared without a version constraint + +Currently transitive via devise, and the two locks disagree: `Gemfile.lock:258` has **2.4.1**, `Gemfile.next.lock:267` has **3.0.1**. responders 3.0 requires `railties >= 5.0`, so any constraint tight enough to be useful excludes one bundle. Six sites depend on it — `respond_with` at `content_controller.rb:79`, `page_components_controller.rb:14` and `:16`, and class-level `respond_to` at `content_controller.rb:3`, `inline_content_controller.rb:3`, `page_components_controller.rb:4`. + +`s.add_dependency "responders"`, unconstrained, matches what both locks already resolve and moves neither. (If a bound is ever needed, the gemspec already carries a `NEXT_BOOT` conditional for four gems — and criterion 14's `NextRails` ban is scoped to `app/` and `lib/`, so the gemspec is not covered by it. Not needed here.) + +### 1.13 `_filter` is 36 live sites, two of which are the deletions from 1.3 + +`grep -c` reports 37 lines across 17 files. One of those is a comment with two occurrences — [`lib/cms/acts/content_page.rb:73`](../../lib/cms/acts/content_page.rb#L73), *"Hash of options that will be passed to the before_filter call. See before_filter for valid options."* So **36 live declarations**: 32 `before_filter`, 2 `after_filter`, 2 `skip_before_filter`. + +The two `skip_before_filter` are precisely the orphans deleted in stage A. **Stage D therefore renames 34 sites, not 37**, plus the one comment. + +Worth knowing before you start: the codebase already mixes both spellings — 9 `_action` calls exist, and [`base_controller.rb:3-5`](../../app/controllers/cms/base_controller.rb#L3) uses `before_filter`, `before_action` and `before_filter` on three consecutive lines. There is no ordering hazard in that (they are aliases into one chain), but it means "does this file use filters?" is not answerable by grepping for one spelling. + +Four further sites live in `test/`; they are outside every exit criterion's scope and outside this phase. + +### 1.14 `ckeditor_rails` 4.3.4 is Rails-4-only at runtime, and no declaration says so + +Phase 2's report lists `couldn't find file 'ckeditor-jquery'` among five remaining defects and attributes **132 of the 148 cucumber failures** to it. It is not in the phase document's work items, and this plan's first draft said only *"scope it as its own item rather than letting it hold the phase open."* This is that item. + +The cause is a `case` statement in the gem's entry point — [`ckeditor_rails-4.3.4/lib/ckeditor-rails.rb`](../../vendor/bundle/gems/ckeditor_rails-4.3.4/lib/ckeditor-rails.rb): + +```ruby +module Ckeditor + module Rails + case ::Rails.version.to_s + when /^4/ then require 'ckeditor-rails/engine' + when /^3\.[12]/ then require 'ckeditor-rails/engine3' + when /^3\.[0]/ then require 'ckeditor-rails/railtie' + end + end +end +``` + +On Rails 5 **no branch matches**, so `Ckeditor::Rails::Engine` — the `::Rails::Engine` subclass declared in `lib/ckeditor-rails/engine.rb` — is never defined. That class is the only thing that puts the gem's directories on the asset load path, and the file the CMS layout needs is at `lib/assets/javascripts/ckeditor-jquery.js`, under the engine's default `lib/assets` path. With no engine, that directory is invisible to sprockets, and `//= require ckeditor-jquery` at [`app/assets/javascripts/bcms/ckeditor.js:5`](../../app/assets/javascripts/bcms/ckeditor.js#L5) cannot resolve. Every page that renders the CMS layout then raises `ActionView::Template::Error`. + +**No instrument in Phase 1 or Phase 2 could have caught this.** [`browsercms.gemspec:51`](../../browsercms.gemspec#L51) declares `ckeditor_rails ~> 4.3.0` with no Rails constraint, and Phase 1's offline scan reads declared requirements — there are none to read. `bundle_report` searches for newer *compatible* versions, and 4.3.4 is compatible by every declaration it makes. Phase 1's boot smoke test booted; it just never rendered a view. Both locks resolve 4.3.4 today ([`Gemfile.lock:93`](../../Gemfile.lock#L93), [`Gemfile.next.lock:97`](../../Gemfile.next.lock#L97)). The lesson is `panoramic`'s, from the opposite direction: **a declared requirement is not a compatibility claim, and a boot is not a render.** + +The gem's `when` clause widens to `/^[45]/` at **4.5.10** and to `/^[4567]/` by 4.17.0. Only 4.3.4 is vendored here, so confirm the exact first-working release against rubygems at implementation time rather than trusting that number. + +**The catch is that this gem's version *is* CKEditor's version.** 4.3.4 → 4.5.10 moves the bundled editor two minor versions; 4.16+ also changes the default skin from `moono` to `moono-lisa`. This is the WYSIWYG editor in a CMS, and the cucumber suite has **zero `@javascript` scenarios** — so no test in this repository can tell you the editor still works after the bump. See [D6](#d6--how-far-to-move-ckeditor_rails). + +### 1.15 sprockets-rails 3 requires every referenced asset to be declared + +This one is in no phase document at all, and it is the next failure sitting *behind* [1.14](#114-ckeditor_rails-434-is-rails-4-only-at-runtime-and-no-declaration-says-so). + +The two locks differ: [`Gemfile.lock:291`](../../Gemfile.lock#L291) has **sprockets-rails 2.3.3**, [`Gemfile.next.lock:301`](../../Gemfile.next.lock#L301) has **3.2.2**. Version 3 raises `Sprockets::Rails::Helper::AssetNotPrecompiled` for any asset referenced through `image_tag` / `asset_path` that is not reachable from `config.assets.precompile` or an `app/assets/config/manifest.js`. This engine has neither for images: [`lib/cms/engine.rb:122-133`](../../lib/cms/engine.rb#L122) lists eight named JS/CSS files plus `jquery`, and `app/assets/config/` does not exist in this repository. + +The first asset that trips it is `cms/logo.png`, from [`app/views/layouts/cms/_main_menu.html.erb:5`](../../app/views/layouts/cms/_main_menu.html.erb#L5): + +```erb +<%= link_to image_tag('cms/logo.png', class: 'main-logo'), "/" %> +``` + +The file is real and on disk. It is simply undeclared, which 2.3.3 tolerated and 3.2.2 does not. + +**It is the first, not the only one.** This is the single item in the phase with no measured upper bound: fix, re-run, read the next asset name, repeat. Budget stage D′ accordingly and re-scope out loud if it goes deep, rather than absorbing it silently. [D7](#d7--how-to-satisfy-sprockets-rails-3) picks the exit that closes the whole class instead of the instances — and whichever is chosen, **it belongs in the engine, not in `test/dummy/`.** A consuming application hits exactly this wall at [Phase 5](phase-5-the-5.0-bump.md), and a dummy-app fix does not travel to it. + +### 1.16 `save!` is the same signature override that P1-2 was + +[`lib/cms/behaviors/versioning.rb:271`](../../lib/cms/behaviors/versioning.rb#L271), in `Versioning::InstanceMethods`: + +```ruby +def save!(perform_validations=true) + save(:validate => perform_validations) || raise(ActiveRecord::RecordNotSaved.new(errors.full_messages)) +end +``` + +Nothing in Rails calls `save!` with a positional boolean. Both frameworks call it with an **options hash**, from the collection-association insert path: + +| | Framework call site | Signature it is calling into | +|---|---|---| +| 4.2 | [`has_many_association.rb:39`](../../vendor/bundle/gems/activerecord-4.2.11.3/lib/active_record/associations/has_many_association.rb#L39) — `record.save!(:validate => validate)` | `validations.rb:42` — `save!(options={})` | +| 5.0 | [`collection_association.rb:510`](../../vendor/bundle/gems/activerecord-5.0.7.2/lib/active_record/associations/collection_association.rb#L510) — `record.save!(validate: validate, &block)` | `persistence.rb:159` — `save!(*args, &block)` | + +Two consequences, one per bundle: + +- **On both**, `perform_validations` is bound to `{validate: false}` — a Hash, and therefore truthy — so the override calls `save(validate: true)` and validations run in precisely the path where the framework asked for them to be skipped. Silently wrong on 4.2 today. +- **On 5.0 additionally, the block is dropped.** `collection_association.rb:501` passes `{ @_was_loaded = loaded? }` into `insert_record`, and 5.0's `save!(*args, &block)` forwards it to `create_or_update(*args, &block)`, which yields it after the insert. Phase 2 taught `create_or_update` to accept and forward that block ([P1-2](phase-1-gem-report.md#open-items)); this override sits *above* it in the chain and throws the block away before it ever gets there. + +So it is the same defect Phase 2 fixed, one method up — and the Phase 2 fix is what makes the gap reachable. It does not raise, which is why nothing has caught it. The remedy has the same shape as P1-2's, and it is a behaviour change: [D8](#d8--the-save-override-forwards-and-that-changes-behaviour). + +Distinguish it from the sibling already flagged in [D3](#d3--guestuserupdate_attributes-becomes-an-alias): [`guest_user.rb:52`](../../app/models/cms/guest_user.rb#L52)'s `def save(perform_validation=true)` returns `false` unconditionally, so its arity genuinely does not matter. That one is a guard. This one is not. + +--- + +## 2. Execution order + +| Stage | Work item | Produces | Size | +|---|---|---|---| +| **A** | *new* ([1.3](#13-the-rails-5-load-error-is-a-deletion-and-it-is-the-highest-leverage-change-in-the-phase)) | Two orphan `skip_before_filter` lines deleted; a Rails 5 functional suite that loads | **XS, do first** | +| **A′** | — | Re-measure both bundles. The residue is what actually scopes C. | S | +| **B** | *prereq* ([1.11](#111-criterion-2-is-not-verifiable-inside-this-phases-own-scope)) | The forced-flag test borrowed from Phase 4 — the oracle for stage C | S | +| **C** | 3.1 | The `belongs_to` audit, 29 sites, verified against B | **L — the only stage requiring judgement** | +| **D** | 3.1 | `deliver_now` ×1; `responders` in the gemspec; the `save!` override forwards ([1.16](#116-save-is-the-same-signature-override-that-p1-2-was)) | S | +| **D′** | *new* 3.5 ([1.14](#114-ckeditor_rails-434-is-rails-4-only-at-runtime-and-no-declaration-says-so), [1.15](#115-sprockets-rails-3-requires-every-referenced-asset-to-be-declared)) | `ckeditor_rails` on a release that loads under Rails 5; the engine's images declared to sprockets 3; a cucumber number that means something | **M, with an unbounded tail** | +| **E** | 3.2 / 3.3 / 3.4 | `_filter`→`_action` ×34; `update_attributes`→`update` ×14 + the guard; `render text:` ×4; `.uniq`→`.distinct` ×1; `HashWithIndifferentAccess` ×2 | M | +| **F** | 3.4 | `to_version400.rb` deleted; the two non-deletions recorded | XS | +| **G** | exit | Both bundles measured; branch-coverage floor set; `next-rails` green; report | S | + +**A is first for the same reason Phase 2's stage A was**: it is a load error, so nothing downstream of it is measurable. Two deleted lines are expected to take the Rails 5 functional suite from "does not load" to a readable number, and to move most of the 148 cucumber failures. **Do not scope stage C or the contingencies until A′ has run** — the four other defects in Phase 2's §5 (`StaleObjectError` ×2, `PublishableTestCase#test_publish_on_save`, `PortletTest#test_.blacklist`, and the `ckeditor-jquery` asset resolution) are currently measured *behind* a load error, and some of them may be artefacts of it. + +**B before C** because C is 29 judgement calls with no oracle otherwise ([1.10](#110-two-of-the-docs-four-near-certain-optional-true-candidates-are-already-validated-as-required), [1.11](#111-criterion-2-is-not-verifiable-inside-this-phases-own-scope)). + +**D and D′ are the last of the 5.0-breaking set**; once they land, everything remaining is a 5.1-or-later concern and the phase can be cut short without leaving the bump blocked. + +**D′ is placed here rather than earlier** because nothing in B or C depends on it — but **pull it forward ahead of B if A′'s cucumber number is still dominated by the asset error**, which is the likely outcome: [1.14](#114-ckeditor_rails-434-is-rails-4-only-at-runtime-and-no-declaration-says-so) alone is 132 of 148 failures. Until it clears, cucumber cannot tell you anything about the other four defects in Phase 2's §5, and stage G has to close them. + +**After every stage: the 4.2 suite must still be green at 78.35% with cucumber 154/154.** Same rule as Phase 2, same reason — checking once at the end tells you a test was lost without telling you which stage lost it. + +**Two operational notes carried from Phase 2's report, both of which will bite otherwise:** + +- Clear `coverage/.resultset.json` before any coverage number you intend to quote. It is shared across bundles and suites with a 3600s merge timeout, and stale entries silently shift the merged percentage. +- Switching bundles leaves the test database in a state the other rejects. After a 4.2 run, the next bundle aborts at `db:drop` with `ActiveRecord::NoEnvironmentInSchemaError`; clear it with `BUNDLE_GEMFILE=Gemfile.next bundle exec rake app:db:environment:set`. + +--- + +## 3. Stage detail + +### A — The orphan skips (new work item 3.0) + +Delete two lines: + +- [`app/controllers/cms/content_controller.rb:11`](../../app/controllers/cms/content_controller.rb#L11) — `skip_before_filter :redirect_to_cms_site` +- [`app/controllers/cms/portlet_controller.rb:4`](../../app/controllers/cms/portlet_controller.rb#L4) — same line + +Leave a comment where each was, naming the reason, so the next reader does not "restore" them: + +```ruby +# There is deliberately no skip of :redirect_to_cms_site here. That callback is +# registered only on Cms::BaseController (base_controller.rb:3), which is a +# sibling of this class, not an ancestor -- so there has never been anything to +# skip. 4.2's skip_callback silently deleted nil; 5.0 raises ArgumentError. +``` + +Do **not** touch the three `skip_before_action` calls in [`base_controller.rb:15-17`](../../app/controllers/cms/base_controller.rb#L15). Those are inside `allow_guests_to`, they run against a chain that genuinely has all three callbacks, and they are already spelled the modern way. + +Commit alone. + +### A′ — Re-measure, then rescope + +```bash +rm -f coverage/.resultset.json coverage/.last_run.json +bundle exec rake # 4.2: expect 996 / 0F / 0E, cucumber 154/154, 78.35% +BUNDLE_GEMFILE=Gemfile.next bundle exec rake app:db:environment:set +BUNDLE_GEMFILE=Gemfile.next bundle exec rake # 5.0: the number that scopes the rest +``` + +Record the Rails 5 unit and cucumber figures in the report before doing anything else. Compare them against Phase 2's §5 list of five defects and write down which of the four remaining ones survived the load error — that list, not the phase document, is what stage G has to close. + +### B — Borrow the forced-flag test from Phase 4 + +One test, in `test/unit/` — enough to make stage C falsifiable and no more: + +```ruby +# belongs_to_required_by_default is the host application's flag, not the engine's +# (there is no load_defaults in this repo), so nothing in either bundle exercises +# required-by-default against these models. This forces it on for the duration of +# one sweep, which is the only oracle the belongs_to audit has. Phase 4 owns the +# permanent version of this; borrowed here per D2. +``` + +It flips `ActiveRecord::Base.belongs_to_required_by_default = true`, instantiates each of the 24 models plus one consumer of each of the 5 behaviors with its associations unset, and asserts validity matches the intended verdict. Restore the flag in `teardown` — leaking it turns every later test in the process into a different test. + +It must pass on **both** bundles. If it can only be made to pass on one, that is a finding about the model, not about the test. + +### C — The `belongs_to` audit (3.1) + +29 sites. Not a find-and-replace; the doc says so and [1.10](#110-two-of-the-docs-four-near-certain-optional-true-candidates-are-already-validated-as-required) shows why. + +**Decide each site from evidence, in this order:** + +1. **An existing `validates_presence_of` on the FK ⇒ required. Leave alone.** This settles `Connector#page`, `Connector#connectable`, `Task#assigned_by`, `Task#assigned_to`, `Task#page`, `Category#category_type` — six sites, six of them named or implied as optional candidates by the doc. +2. **Self-referential or polymorphic with no presence validation ⇒ almost certainly `optional: true`.** `Category#parent` is the clearest: every root category has a nil parent, so required-by-default breaks the category tree outright. +3. **Everything else ⇒ the stage-B test decides.** The schema does not, because nothing is `null: false`. + +**The five behavior-injected sites carry the blast radius** and are worth more care than the 24 model sites combined — they apply to every model using the behavior, in this engine *and in every downstream project*: + +| Site | Association | Note | +|---|---|---| +| [`userstamping.rb:16`](../../lib/cms/behaviors/userstamping.rb#L16), `:17` | `created_by`, `updated_by` | Nil for anything created outside a request — seeds, rake tasks, migrations. `optional: true`. | +| [`categorizing.rb:16`](../../lib/cms/behaviors/categorizing.rb#L16) | `category` | Categorising is opt-in per instance; nil is the normal state. | +| [`versioning.rb:115`](../../lib/cms/behaviors/versioning.rb#L115) | version → parent | Declared dynamically via `version_class.belongs_to(...)`, so the `optional:` goes into that call's options hash, not a literal. Grep for `optional:` will not find it in the shape criterion 2 expects — note it in the report. | +| [`dynamic_attributes.rb:168`](../../lib/cms/behaviors/dynamic_attributes.rb#L168) | `base_class` | Also a dynamic declaration. | + +**Also flag, do not fix:** [`lib/templates/active_record/model/model.rb:4`](../../lib/templates/active_record/model/model.rb#L4) ([1.9](#19-belongs_to-is-29-sites-as-claimed--plus-a-30th-one-hop-downstream)). The template cannot know whether nil is legitimate for a generated attribute; the decision belongs with whoever settles what Rails version generated code targets, which is [Phase 5](phase-5-the-5.0-bump.md). + +Commit the 24 model sites and the 5 behavior sites separately. The behavior sites are the ones a reviewer needs to look at properly. + +### D — The rest of the 5.0-breaking set (3.1) + +**`deliver` → `deliver_now`, one site.** [`email_message.rb:58`](../../app/models/cms/email_message.rb#L58) only. Leave `:15` (`def self.deliver!`) and `:18` (`m.deliver!`) exactly as they are — [1.4](#14-email_messagerb18-is-not-a-mailer-call-and-renaming-it-would-break-the-model). + +**`responders` in the gemspec, unconstrained** — [1.12](#112-responders-must-be-declared-without-a-version-constraint). Add near the other `add_dependency` lines with a one-line comment saying it is currently transitive via devise and that the six `respond_with`/`respond_to` sites should not depend on that. Re-run `bundle install` (`bundle lock` is enough) on **both** Gemfiles and confirm the locks do not move — if either does, the constraint was wrong. + +**The `save!` override forwards** — [1.16](#116-save-is-the-same-signature-override-that-p1-2-was), decided by [D8](#d8--the-save-override-forwards-and-that-changes-behaviour). Own commit; it is the only change in stage D that alters behaviour. [`versioning.rb:271`](../../lib/cms/behaviors/versioning.rb#L271) takes the same shape Phase 2 gave `create_or_update` one method below it, with a comment that names the call sites so the splat does not read as unused: + +```ruby + # Rails never calls save! with a positional boolean. 4.2 calls it as + # save!(:validate => x) (has_many_association.rb:39) and 5.0 as + # save!(validate: x, &block) (collection_association.rb:510) -- so the old + # `perform_validations` parameter was being handed a truthy Hash, and on 5.0 the + # block that create_or_update yields after insert was being dropped here before + # it could reach the (*args, &block) signature Phase 2 gave that method. Same + # defect as P1-2, one method up. See docs/rails-upgrade/phase-1-gem-report.md. + def save!(*args, &block) + save(*args, &block) || raise(ActiveRecord::RecordNotSaved.new(errors.full_messages)) + end +``` + +`save` with no arguments already defaults to validating, so a bare `record.save!` is unchanged. The behaviour that *does* change is the autosave path, where validations will now correctly be skipped — run the full suite on **both** bundles after this commit and read the diff in failures carefully, because a test that was passing on accidental validation will surface here and that is the fix working, not the fix breaking. + +### D′ — The Rails 5 asset chain (new work item 3.5) + +Two changes, in this order, because the second is invisible until the first lands. **Neither is a code fix and neither is a rename** — this is the one stage whose work the phase document's "backwards-compatible mechanical change" contract does not describe. Do not start it until [D6](#d6--how-far-to-move-ckeditor_rails) and [D7](#d7--how-to-satisfy-sprockets-rails-3) are answered. + +#### D′.1 — `ckeditor_rails` onto a release that loads under Rails 5 + +The constraint is at [`browsercms.gemspec:51`](../../browsercms.gemspec#L51), and the gemspec already carries the `NEXT_BOOT` pattern for three other gems ([`:45`](../../browsercms.gemspec#L45), [`:54`](../../browsercms.gemspec#L54), [`:62`](../../browsercms.gemspec#L62)), so whichever way [D6](#d6--how-far-to-move-ckeditor_rails) goes there is a shape to follow: + +```ruby + # 4.3.4 dispatches its Railtie on `case ::Rails.version` and has no Rails 5 branch, so + # under 5.0 the gem defines no Rails::Engine at all, its lib/assets never joins the + # asset load path, and `//= require ckeditor-jquery` (bcms/ckeditor.js:5) cannot + # resolve -- which takes down every page rendering the CMS layout. The version tracks + # CKEditor itself, so this is an editor upgrade as well as a gem bump. See D6. + s.add_dependency("ckeditor_rails", NEXT_BOOT ? "~> 4.5" : "~> 4.3.0") +``` + +- [ ] `couldn't find file 'ckeditor-jquery'` gone from the Rails 5 functional and cucumber logs +- [ ] `Gemfile.lock` unchanged — inspect the diff; this must not move the 4.2 bundle +- [ ] **Open the editor by hand on Rails 5 and edit a block.** Zero `@javascript` scenarios means no test here can do it for you ([1.14](#114-ckeditor_rails-434-is-rails-4-only-at-runtime-and-no-declaration-says-so)) + +#### D′.2 — Declare the engine's assets to sprockets 3 + +[1.15](#115-sprockets-rails-3-requires-every-referenced-asset-to-be-declared), decided by [D7](#d7--how-to-satisfy-sprockets-rails-3). Iterate: run, read the raised asset name, declare it, run again. `cms/logo.png` is the first and will not be the last. + +Wherever the declaration goes, it goes in **the engine** — [`lib/cms/engine.rb`](../../lib/cms/engine.rb) or a new `app/assets/config/manifest.js` — and **not** in `test/dummy/`. A dummy-app fix turns this phase's suite green and leaves every consuming application to hit the identical wall at [Phase 5](phase-5-the-5.0-bump.md), where it is far more expensive to diagnose. + +- [ ] No `AssetNotPrecompiled` and no `couldn't find file` in the Rails 5 logs +- [ ] The declaration is in the engine — `git diff` for this stage shows nothing under `test/dummy/` +- [ ] 4.2 still green at 78.35% with cucumber 154/154. sprockets-rails 2.3.3 does not enforce the declaration, so it must be a no-op on the default bundle + +#### D′.3 — Read the cucumber number + +This is the first point in the phase at which the Rails 5 cucumber figure means anything. Record it in the report against Phase 0's baseline (154/154, default profile) and against the four remaining defects from Phase 2's §5 — some of those have been sitting behind *this* stage rather than behind stage A, and A′ could not have told them apart. + +If the sprockets iteration goes deeper than a handful of assets, **stop and re-scope out loud** ([§6](#6-contingencies)). It is the only item in the phase with no measured upper bound, and it is a better contingency than a surprise. + +### E — The renames (3.2 / 3.3 / 3.4) + +Five independent changes; one commit each, so a bisect lands on one of them. + +**`*_filter` → `*_action`, 34 sites across 17 files** ([1.13](#113-_filter-is-36-live-sites-two-of-which-are-the-deletions-from-13)). Pure rename; identical behaviour on both versions. Update the comment at `content_page.rb:73` too. Failure mode on 5.1 is a boot-time `NoMethodError`, so no test is needed — but the 4.2 suite still has to be green afterwards, because a typo in a callback name is *also* a boot-time failure. + +**`update_attributes` → `update`, 14 call sites** ([1.8](#18-update_attributes-is-14-calls-and-guest_userrb-is-a-security-guard-the-rename-would-disarm)): 6 in models (`page_component.rb:28`, `email_message.rb:59`, `page.rb:220`, `:243`, `:258`, `task.rb:34`), 4 in controllers (`content_block_controller.rb:267`, `inline_content_controller.rb:7`, `links_controller.rb:38`, `page_route_options_controller.rb:22`), 4 in behaviors (`publishing.rb:132`, `soft_deleting.rb:73`, `:75`, `connecting.rb:119`). The comment at `dynamic_attributes.rb:231` mentions `ActiveRecord::Persistence#update_attributes` by name and is describing Rails' API, not this codebase's — update it or leave it, but do not let it fail criterion 4 by accident. + +**The `GuestUser` guard, separately** ([D3](#d3--guestuserupdate_attributes-becomes-an-alias)) — its own commit, with the reasoning in the message. + +**`render text:` → `plain:` ×2 and `html:` ×2** ([1.6](#16-two-of-the-four-render-text-sites-are-cucumber-covered-and-they-render-html)). The two `pretend_controller` sites need `.html_safe`; verify with cucumber, not with the unit suite. + +**`.uniq` → `.distinct`, one site** ([1.5](#15-uniq-measures-four-sites-and-the-one-that-must-change-has-a-latent-bug-in-it)). Leave the other three. Add a `# TODO(Phase 4)` naming the union-dedupe question so it does not get lost. + +**`HashWithIndifferentAccess` → `ActiveSupport::HashWithIndifferentAccess`, 2 sites.** `page_component.rb:10` (covered) and `portlet.rb:228` (uncovered). Trivial, and the removal version is unverified, so qualifying both now retires the tracking rather than the risk. + +### F — Dead code (3.4) + +**Delete** `lib/cms/commands/to_version400.rb` ([1.7](#17-two-of-the-three-dead-code-deletions-are-not-dead)). + +**Do not delete** `app/portlets/deprecated_placeholder.rb` or `lib/cms/behaviors/namespacing.rb`. Add a comment to each saying why, so this does not get re-litigated at the next hop: + +- `deprecated_placeholder.rb` — named by `db/migrate/20130327184912_browsercms400.rb:76`; downstream rows point at it. +- `namespacing.rb` — the empty module satisfies `behaviors.rb:30`'s glob-and-constantize, and the file also carries the public `Cms.table_prefix=` deprecation. + +`lib/cms/form_builder/deprecated_inputs.rb` stays; `features/content_blocks/deprecated_form_inputs.feature` is its contract. The doc already says so. + +Optional, from Phase 2's handoff: `Cms::IntegrationTestHelper` (`test/test_helper.rb`) is defined, included nowhere, and asserts `403` immediately after a successful login. It is test-side and outside every criterion here — delete it if the phase has room, defer it if not. + +### G — Exit + +1. Clear the resultset. Full run on both bundles. Cucumber on both. +2. **Set the branch-coverage floor.** Phase 2 measured 70.79% and reported it without gating. Now that a stage-A-through-F diff has moved through, take a fresh reading and set `COVERAGE_MINIMUM_BRANCH` in [`core_tasks.rake`](../../lib/tasks/core_tasks.rake) at the measured value. Phase 2's report asks for this and it is cheap here. +3. Run every criterion grep from [§7](#7-exit-criteria-traceability) and paste the output into the report — including the ones that pass already, so the record shows they were checked and not assumed. +4. **The `next-rails` job should now be green.** If it is not, [§6](#6-contingencies) says what to do; do not merge over it silently, because the whole argument for making it gating in Phase 2 was that a permanently-red job hides real regressions. +5. Deploy to production on 4.2 (criterion 15) and write `phase-3-report.md`. + +--- + +## 4. Decisions + +### D1 — The migration item leaves the phase + +Per [1.2](#12-the-migration-item-cannot-land-in-this-phase--and-does-not-break-at-50): `ActiveRecord::Migration[4.2]` does not exist on 4.2, and 5.0 does not need it. Keeping it would require the phase's first `NextRails` branch, which criterion 14 exists to forbid. Moved to Phase 5 / the 5.1 hop; criterion 12 struck. **Needs a human to confirm the deferral**, since it changes a phase document. + +### D2 — Borrowing the forced-flag test from Phase 4 + +Per [1.11](#111-criterion-2-is-not-verifiable-inside-this-phases-own-scope). Same shape as Phase 2's [D1](phase-2-implementation-plan.md#d1--borrowing-two-fixes-from-phase-3), and the same justification: the alternative is shipping the phase's largest work item with no oracle at all. Phase 4 keeps ownership of the permanent version and of everything else in its scope. The README already notes Phases 3 and 4 can run in parallel, so this is a sequencing detail between them rather than a scope transfer. + +Recorded as a deliberate deviation. The phase doc's "no new tests" exclusion is written as *"except where a Tier C fix has a behavioural choice in it"* — 29 nil-legitimacy judgements is that, several times over. + +### D3 — `GuestUser#update_attributes` becomes an alias + +Per [1.8](#18-update_attributes-is-14-calls-and-guest_userrb-is-a-security-guard-the-rename-would-disarm). Renaming the definition to `update` and aliasing the old name is the only option that does not either leave the guard bypassable through 14 renamed paths or break downstream callers. + +It closes a hole that is open today (`guest.update(...)` reaches `ActiveRecord::Persistence#update` right now), which makes it a behaviour change in a phase whose contract is "no behaviour changes." **Flagging it rather than deciding it.** The alternative — leave `update_attributes` as the only definition and exclude `guest_user.rb` from the rename — preserves the phase's contract exactly and leaves a known hole in place. That trade is a human's to make. + +While in the file: `def save(perform_validation=true)` at `:52` is the same class of signature-override bug as the `create_or_update` arity Phase 2 fixed. It is not currently breaking — 5.0's `save` is `save(*args)` and a keyword hash lands harmlessly in the positional slot — but it will not survive later hops. Note it; do not fix it here. + +### D4 — `namespacing.rb` stays + +Per [1.7](#17-two-of-the-three-dead-code-deletions-are-not-dead). Relocating `Cms.table_prefix=` to `lib/browsercms.rb` and deleting the rest is the tidier end state and is maybe six lines — but it moves a public API's definition site in a phase that is supposed to contain nothing but no-op renames, for no upgrade benefit. Leave the file, comment it, revisit when the 6.0 autoloading work touches `behaviors.rb`'s glob anyway. + +### D5 — Scope of the `_filter` rename + +`test/` has four `_filter` sites. They are outside criterion 3's grep (`app/ lib/`) and outside this phase. They break at 5.1 exactly like the production ones, so they will be picked up by the 5.1 hop's detection run. Not doing them here keeps stage E's commit reviewable as one mechanical change to one tree. + +### D6 — How far to move `ckeditor_rails` + +Per [1.14](#114-ckeditor_rails-434-is-rails-4-only-at-runtime-and-no-declaration-says-so). The gem's version *is* CKEditor's version, so this is an editor upgrade wearing a dependency bump's clothes, and no test in this repository can see the difference. + +**(a)** `NEXT_BOOT ? "~> 4.5" : "~> 4.3.0"` — the next bundle gets a modern editor, 4.2 is untouched. The two bundles then run two different WYSIWYG editors, which is a real divergence in the thing users actually touch. +**(b)** `"~> 4.5"` unconditionally — both bundles get the same editor, so anything the upgrade breaks in the CMS UI is caught by the 4.2 suite as well, which is the suite that is currently green. Costs a change to a bundle Phase 0 baselined, and moves `Gemfile.lock`. +**(c)** Pin the oldest release that works on Rails 5 (reported as 4.5.10) for the next bundle — the smallest possible editor jump, and it keeps the `moono` default skin that 4.16+ replaces with `moono-lisa`. + +**Recommendation: (c) now, (b) at [Phase 5](phase-5-the-5.0-bump.md).** A default-skin change is a visible, user-facing difference in a CMS's editor; taking it during an upgrade means a UI regression and a Rails regression land in the same commit and get diagnosed as each other. Whichever is chosen, the by-hand editor check in D′.1 is not optional — it is the only oracle that exists. + +**This needs a human** for the same reason [D1](#d1--the-migration-item-leaves-the-phase) does: it changes what the phase document says the phase contains, and it is the phase's first gem bump. + +### D7 — How to satisfy sprockets-rails 3 + +Per [1.15](#115-sprockets-rails-3-requires-every-referenced-asset-to-be-declared). + +**(a)** Add `app/assets/config/manifest.js` to the engine with `link_tree ../images` — the Rails 5+ idiom, declares the whole class at once, and it is what the engine needs at every subsequent hop anyway. +**(b)** Extend `config.assets.precompile` in [`lib/cms/engine.rb:122`](../../lib/cms/engine.rb#L122) — consistent with the eight entries already there, and it grows by one line per asset discovered. +**(c)** Set `config.assets.check_precompiled_asset = false` in the dummy app's `test.rb` — makes the suite pass today and guarantees the same failure reappears inside a consuming application at Phase 5. + +**Recommendation: (a).** It is the destination, it is one file, and it fixes the class rather than the instances — which also bounds the one unbounded item in this phase. **(c) is the trap**: it converts a loud test failure into a silent production one, which is exactly the failure mode [`RAILS_UPGRADE_TEST_PRIORITY.md`](../../RAILS_UPGRADE_TEST_PRIORITY.md) ranks the whole plan by. (b) works and is more in keeping with the file, but it books another discovery round at every later hop. + +### D8 — The `save!` override forwards, and that changes behaviour + +Per [1.16](#116-save-is-the-same-signature-override-that-p1-2-was). Taking `(*args, &block)` and forwarding is the only signature that matches what both frameworks actually call, and it is what Phase 2 already did to `create_or_update` directly beneath it. + +But it is a **behaviour change in a phase whose contract is that there are none**: autosaved children of a versioned record are validated today, in a path where Rails asked for `validate: false`, and after the fix they will not be. If any test depends on that accidental validation it will go red, and the red will be correct. + +**Flagging rather than deciding.** The alternative is to leave it, note it beside the `guest_user.rb:52` sibling already recorded in [D3](#d3--guestuserupdate_attributes-becomes-an-alias), and hand both to [Phase 4](phase-4-characterization-tests.md) to characterise before either is touched — which is the more conservative reading of this phase's contract, and defensible. What is *not* defensible is leaving it undocumented: it is a live defect on the bundle that is in production today, not a Rails 5 concern. + +--- + +## 5. Risks + +| # | Risk | Mitigation | +|---|---|---| +| R1 | **Stage A does not produce the expected improvement**, and the Rails 5 functional suite stays down for a different reason. | A′ exists precisely to find this out in one step. The two lines are provably no-ops on 4.2, so stage A cannot regress the default bundle regardless. | +| R2 | **The `belongs_to` audit gets one wrong** — an association marked optional that a host app depends on being required, or vice versa. Nothing in either bundle detects it. | Stage B is the whole mitigation. Where B cannot decide, prefer `optional: true`: a wrongly-optional association fails later and loudly at the call site; a wrongly-required one fails immediately and everywhere for every host app on `load_defaults 5.0`. | +| R3 | **The behavior-injected `belongs_to` changes break downstream projects** that this repo's suite cannot see. Five declarations reach every model using the behavior. | These four sites get their own commit and their own reviewer. Two are dynamic declarations that grep will not find later ([C](#c--the-belongs_to-audit-31)) — say so in the report. | +| R4 | **The `GuestUser` change is a real behaviour change** in a no-behaviour-change phase. | [D3](#d3--guestuserupdate_attributes-becomes-an-alias) puts the decision in front of a human instead of resolving it silently. Own commit either way. | +| R5 | **`render html:` without `html_safe`** escapes the markup, changing the two cucumber-covered responses in a way `plain:` would not have. | The instruction in [E](#e--the-renames-32--33--34) is explicit, and `acts_as_content_page.feature` asserts on the rendered content. Run cucumber, not just the unit suite, after that commit. | +| R6 | **Coverage moves and nobody can say why.** 34 renamed callbacks and 14 renamed calls do not change line counts, but the deleted file and the deleted skip lines do. | Same discipline as Phase 2's stage F: measure on a cleared resultset, immediately before and after the commits that delete code, so an instrument change is distinguishable from a lost test. | +| R7 | **`responders` in the gemspec moves a lock.** | Check both locks after `bundle lock`. If either moves, the constraint was wrong — remove it rather than accepting the drift ([1.12](#112-responders-must-be-declared-without-a-version-constraint)). | +| R8 | **The `ckeditor_rails` bump breaks the CMS editor** and nothing notices. Zero `@javascript` scenarios means the suite cannot see a WYSIWYG regression; a green cucumber run proves the *asset resolves*, not that the editor works. | [D6](#d6--how-far-to-move-ckeditor_rails)'s recommendation minimises the editor jump, and D′.1 requires a by-hand check. If (b) is chosen instead, the 4.2 suite becomes a second detector — which is the main argument for it. | +| R9 | **The sprockets chain is deeper than a handful of assets** and stage D′ becomes the phase. It has no measured upper bound; only the first failure has been observed. | [D7](#d7--how-to-satisfy-sprockets-rails-3)(a) closes the whole class in one file rather than one asset at a time. If it still runs long, [§6](#6-contingencies) says to re-scope out loud rather than absorb it — the phase can ship A–D and hand D′ on. | + +--- + +## 6. Contingencies + +**If stage A′ shows Rails 5 still substantially red**, the four defects from Phase 2's §5 are the next thing to read, and they are not all Phase 3 work: + +- `StaleObjectError` on `Cms::Page` ×2 and `PublishableTestCase#test_publish_on_save` are plausibly downstream of the `create_or_update` arity change interacting with optimistic locking — application behaviour, in scope, but they need diagnosis before they can be scoped. +- `PortletTest#test_.blacklist` is one expectation diff. Small. +- **`couldn't find file 'ckeditor-jquery'` is now scoped** — it is [1.14](#114-ckeditor_rails-434-is-rails-4-only-at-runtime-and-no-declaration-says-so), it is a gem bump rather than a code fix, and it owns **stage D′** together with the sprockets-rails 3 problem sitting behind it ([1.15](#115-sprockets-rails-3-requires-every-referenced-asset-to-be-declared)). It accounted for 132 of the 148 cucumber failures, so **expect A′'s cucumber number to still be dominated by it**; that is not evidence stage A failed. Read the functional suite, not cucumber, to judge stage A. + +**If stage D′ runs long**, it is still this phase's — the `next-rails` job cannot go green without it, and criterion 16 is the phase's purpose. But it is the one item here with no measured upper bound, so re-scope out loud rather than letting it hold the phase open silently. A–D unblock the bump on their own; D′ can be handed to [Phase 5](phase-5-the-5.0-bump.md) with an honest gap recorded, at the cost of leaving the CI job red and Phase 2's gating decision looking wrong. + +**`use_route`** ([`test/support/engine_controller_hacks.rb`](../../test/support/engine_controller_hacks.rb), Phase 2 §5) is removed in 5.0 and now arrives at controllers as an ordinary request parameter. Phase 2 measured that the obvious replacement produces 16 `UrlGenerationError`s and left the finding in the module's comment. It is test-harness work, it needs a per-test-class decision about engine versus application route sets, and it has no backwards-compatible form. **Not this phase.** It will surface in stage A′; expect it and do not chase it. + +**If the phase has to be cut short**, stages A through D′ are the part that unblocks the bump. E and F break at 5.1 and 6.0 and can slip to [Phase 6](phase-6-subsequent-hops.md) without blocking anything — at the cost the phase document names: debugging them simultaneously with a version change. + +--- + +## 7. Exit criteria traceability + +| # | Criterion | Stage | Verification | Status now | +|---|---|---|---|---| +| 1 | Suite green on both Gemfiles, coverage ≥ Phase 2 | G | CI both jobs | 4.2 green at 78.35%; 5.0 red | +| 2 | All 29 `belongs_to` audited | B, C | Stage B's forced-flag test, plus the two dynamic declarations named in the report | not started | +| 3 | No `*_filter` callbacks | E | `grep -rnE "\b(before\|after\|around\|skip_before\|skip_after)_filter\b" app/ lib/` | 37 lines (36 live + 1 comment) | +| 4 | No `update_attributes` calls | E | `grep -rn "update_attributes" app/ lib/` | 16 hits = 14 calls + 1 definition + 1 comment. Expect 1–2 survivors by design ([D3](#d3--guestuserupdate_attributes-becomes-an-alias)) | +| 5 | No `render text:` | E | `grep -rnE "render\s+(:text\s*=>\|text:)" app/ lib/` | 4 | +| 6 | No `.uniq` on a Relation | E | `grep -rn "\.uniq" app/controllers/ app/models/` reviewed | 4 hits, 3 are Arrays and stay ([1.5](#15-uniq-measures-four-sites-and-the-one-that-must-change-has-a-latent-bug-in-it)) | +| 7 | No `HTML::FullSanitizer` | — | `grep -rn "HTML::FullSanitizer" app/ lib/ test/ spec/` | ✅ **already passes** (Phase 2) | +| 8 | No bare mailer `.deliver` | D | **Amended.** The doc's grep excludes `def self.deliver` but not `m.deliver!`, which is a model method, not a mailer ([1.4](#14-email_messagerb18-is-not-a-mailer-call-and-renaming-it-would-break-the-model)). Expect two survivors at `email_message.rb:15` and `:18`; both are correct | 3 hits, 1 to change | +| 9 | No `File.exists?` | — | `grep -rn "File.exists?" app/ lib/` | ✅ **already passes** (Phase 0, `6da1d60d`) | +| 10 | No unqualified `HashWithIndifferentAccess` | E | `grep -rnE "(^\|[^:A-Za-z])HashWithIndifferentAccess" app/ lib/` | 2 | +| 11 | `responders` in the gemspec | D | `grep -n responders browsercms.gemspec` | 0 | +| 12 | ~~Migrations version-qualified~~ | — | **Struck.** Not backwards-compatible and not required at 5.0 — [1.2](#12-the-migration-item-cannot-land-in-this-phase--and-does-not-break-at-50), [D1](#d1--the-migration-item-leaves-the-phase). Moved to Phase 5 / the 5.1 hop | n/a | +| 13 | `find_by_*` untouched | all | `git diff` shows no change to `find_by_login`, `find_by_path`, `find_by_code`, `find_by_from_path` | trivially true | +| 14 | Zero `NextRails` branches | all | `grep -rn "NextRails" app/ lib/` | ✅ 0 today — **the phase's definition of correctness. Keep it 0.** | +| 15 | Deployed to production on 4.2 | G | Deployed and stable | not started | + +**Two criteria pass before the phase begins** (7 and 9), **one is struck** (12), and **one cannot be verified without borrowed work** (2, via [D2](#d2--borrowing-the-forced-flag-test-from-phase-4)). + +**Add two more.** The first is the phase's actual purpose and nothing above measures it; the second is what now stands in the first's way, and it has a failure mode that passes silently. + +| # | Criterion | Stage | Verification | +|---|---|---|---| +| **16** | **The `next-rails` CI job is green** | G | It was made gating in Phase 2 with the explicit statement that *"CI is red on every PR until Phase 3 lands."* Turning that red green is what this phase is for, and criterion 1's "suite green on both Gemfiles" is the same claim stated less directly. The `ckeditor-jquery` asset problem is no longer an excuse for missing it — it is scoped as stage D′ | +| **17** | **The Rails 5 asset chain is clear, and the fix is in the engine** | D′ | Two halves. First: no `couldn't find file` and no `AssetNotPrecompiled` in the `Gemfile.next` functional and cucumber logs. Second, and the one that can pass wrongly: `git diff` for stage D′ touches [`lib/cms/engine.rb`](../../lib/cms/engine.rb), `app/assets/config/manifest.js` or [`browsercms.gemspec`](../../browsercms.gemspec) and **nothing under `test/dummy/`** — a dummy-app fix satisfies the first half and hands every consuming application the same failure at [Phase 5](phase-5-the-5.0-bump.md) ([D7](#d7--how-to-satisfy-sprockets-rails-3)) | + +Neither is a grep over `app/` and `lib/`, and criterion 14 is not endangered by either: the `NEXT_BOOT` conditional D′.1 needs lives in the gemspec, which [1.12](#112-responders-must-be-declared-without-a-version-constraint) already establishes is outside criterion 14's scope. diff --git a/docs/rails-upgrade/phase-3-report.md b/docs/rails-upgrade/phase-3-report.md new file mode 100644 index 000000000..5e8cac38a --- /dev/null +++ b/docs/rails-upgrade/phase-3-report.md @@ -0,0 +1,548 @@ +# Phase 3 — Report + +**Implements:** [`phase-3-backwards-compatible-fixes.md`](phase-3-backwards-compatible-fixes.md) +**Plan:** [`phase-3-implementation-plan.md`](phase-3-implementation-plan.md) +**Entry state:** `10ac4ace` — 4.2 green at 78.35% / cucumber 154/154; `Gemfile.next` at 756 tests / 2F / 3E, functional suite down with a load error, cucumber 6 of 154 passing. + +--- + +## 1. Headline + +`Gemfile` is still Rails 4.2.11.3 and still green — 1007 tests, 0F/0E, cucumber 154/154. + +`Gemfile.next` went from **"the functional suite does not load and 148 of 154 cucumber +scenarios fail"** to **85 of 88 functional and 150 of 154 cucumber passing.** + +**Criterion 16 is not met.** Nine failures/errors remain on 5.0, so the `next-rails` job is +still red. None of them is a work item of this phase, and none is caused by it: seven are one +pre-existing optimistic-locking cluster (ruled out against this phase's only behaviour change +by a control run — §6), and three are singletons. They need characterization before they can +be fixed, which is [Phase 4](phase-4-characterization-tests.md)'s job. **The honest summary is +that Phase 3 removed everything in its own scope that was holding the job red, and what +remains is a different kind of problem than the one this phase was built to solve.** + +| | Entry (Phase 2) | Exit (Phase 3) | +|---|---|---| +| 4.2 unit + spec + functional + orphan | 996, 0F / 0E | **1007, 0F / 0E** | +| 4.2 cucumber | 154 / 154 | **154 / 154** | +| 5.0 unit | 756, 2F / 3E | 767, 1F / 2E | +| 5.0 spec | — | 145, 0F / 0E | +| 5.0 functional | **load error — 0 tests ran** | **88 runs, 0F / 3E** | +| 5.0 cucumber | 6 passed / 148 failed | **150 passed / 4 failed** | + +--- + +## 2. Final measurements + +### Rails 4.2 (`Gemfile`) — green + +| Suite | Result | +|---|---| +| unit | **767** tests, 1766 assertions, **0F / 0E**, 4 skips | +| spec | 145 tests, 260 assertions, 0F / 0E, 7 skips | +| functional | 88 runs, 203 assertions, 0F / 0E, 9 skips | +| orphan | 7 runs, 9 assertions, 0F / 0E | +| **total** | **1007 tests, 0F / 0E** | +| cucumber | **154 scenarios (154 passed)**, 837 steps (837 passed) | +| line coverage | **78.37%** — baseline 78.35%, passes | +| branch coverage | **70.83%** — Phase 2 measured 70.79% on the same instrument | + +Both figures are from a full chain on a **cleared** `coverage/.resultset.json`, so they are the +authoritative ones. (The earlier run against a stale resultset also read 78.37% — the +contamination turned out not to have distorted anything, but that is now verified rather than +assumed.) + +767 unit tests, up from 756: **+9** from the stage-B oracle, **+2** from the `GuestUser` +characterization pair (one of which is the deliberate skip, taking skips 3 → 4). + +### Rails 5.0 (`Gemfile.next`) + +| Suite | Entry (Phase 2) | Exit | +|---|---|---| +| unit | 756, 2F / 3E | **767, 1F / 2E**, 4 skips | +| spec | — | **145, 0F / 0E** | +| functional | **load error — 0 tests ran** | **88, 0F / 3E**, 9 skips | +| orphan | 1E | **7, 0F / 0E** | +| cucumber | 154 collected, **6 passed** / 148 failed | **150 passed / 4 failed** | + +The cucumber figure is the closing run's 148 plus the two `manage_users` scenarios fixed by +§4's `users_controller` change, verified by re-running that feature. The remaining four are +characterised in §6 and are **not** caused by anything in this phase — see the control run +there. + +**Cucumber went from 6 passing to 150 passing, and the functional suite from "does not load" +to 85 of 88 green.** + +--- + +## 3. What the plan got right, and where measurement moved it + +Three findings changed the shape of the work. All three were caught by reading the vendored +gems or the shipped code rather than by a test, which is the same lesson Phase 1 recorded +about `panoramic` and Phase 2 recorded about `ckeditor_rails`. + +### 3.1 `optional: true` is not backwards-compatible — the audit uses `required: false` + +**This is the largest correction in the phase.** The phase document and the implementation +plan both specify `optional: true` for the `belongs_to` audit. That declaration **breaks the +4.2 bundle at class-definition time**: + +``` +ArgumentError: Unknown key: :optional. Valid keys are: :class_name, :anonymous_class, +:foreign_key, :validate, :autosave, :dependent, :primary_key, :inverse_of, :required, +:foreign_type, :polymorphic, :touch, :counter_cache +``` + +`:optional` enters `valid_options` only at Rails 5.0 +([`builder/belongs_to.rb:8`](../../vendor/bundle/gems/activerecord-5.0.7.2/lib/active_record/associations/builder/belongs_to.rb#L8)). +4.2's list comes from `Association.valid_options` + +[`singular_association.rb:6`](../../vendor/bundle/gems/activerecord-4.2.11.3/lib/active_record/associations/builder/singular_association.rb#L6), +and `validate_options` runs `assert_valid_keys` against it. Applying the phase document +literally would have taken the production bundle down with a load error on 29 models — the +same category of mistake finding 1.2 already caught and struck for the migration item. + +**`required: false` is the spelling that works on both.** `:required` is in `valid_options` +on 4.2 *and* 5.0, and 5.0 normalises it in `define_validations`: +`options[:optional] = !options.delete(:required)`. Measured directly on both bundles: + +| | `belongs_to :x, optional: true` | `belongs_to :x, required: false` | +|---|---|---| +| 4.2.11.3 | `ArgumentError: Unknown key: :optional` | accepted — `options={:required=>false}`, **no validator added** | +| 5.0.7.2 | accepted — `options={:optional=>true}` | accepted — `options={:optional=>true}`, no validator added | + +On 4.2 the option is consumed at +[`singular_association.rb:31-36`](../../vendor/bundle/gems/activerecord-4.2.11.3/lib/active_record/associations/builder/singular_association.rb#L31) +— `if reflection.options[:required]` — so `false` is a provable no-op. On 5.0 it means +exactly what `optional: true` means. **Criterion 2's verification grep is amended accordingly** +(see §8). + +### 3.2 Stage B's mechanism could not have worked as scoped + +The plan describes stage B as a `setup` block that "flips +`ActiveRecord::Base.belongs_to_required_by_default = true` around a model-instantiation +sweep." Two independent reasons that cannot work: + +1. **The flag does not exist on 4.2.** It is introduced at + [`activerecord-5.0.7.2 core.rb:117`](../../vendor/bundle/gems/activerecord-5.0.7.2/lib/active_record/core.rb#L117); + `grep -rn belongs_to_required_by_default` over the 4.2 gem returns nothing. Touching it + raises `NoMethodError` on the bundle in production — and the plan requires the test to pass + on both. +2. **The flag is read at class-definition time, not validation time.** It is consumed inside + `Builder::BelongsTo.define_validations`, which runs when `belongs_to` is *called*. Flipping + it in `setup`, after the models are loaded, cannot retroactively add validations to + associations that already exist. It would be a no-op even on 5.0. + +**What was built instead** ([`test/unit/belongs_to_optionality_test.rb`](../../test/unit/belongs_to_optionality_test.rb)): +the flag's entire effect is one line — `model.validates_presence_of reflection.name` — so the +audit's claim is checkable directly without ever setting it. + +- `:required` ⇒ the model already rejects nil through its own presence validation, so + required-by-default adds nothing and the declaration is left bare. +- `:optional` ⇒ nothing rejects nil today, on either bundle, so `required: false` pins that. + +Both halves are assertions about the loaded class, so both run on both bundles. The test also +carries a probe asserting that `required: false` adds no presence validation — if that ever +stops holding, all 22 verdicts are wrong at once. + +### 3.3 The one "genuinely dead" file is not dead — `bin/` was not searched + +The plan's finding 1.7 clears `lib/cms/commands/to_version400.rb` for deletion on the strength +of "zero references across `app lib test spec features config`". That search omits `bin/`: + +- [`bin/bcms:11`](../../bin/bcms#L11) — `require 'cms/commands/to_version400'` +- [`bin/bcms:28`](../../bin/bcms#L28) — `include Cms::Commands::ToVersion400` + +`bcms` is a shipped executable ([`browsercms.gemspec:43`](../../browsercms.gemspec#L43)), and +`generate_devise_configuration` — the module's only method — is called by `bcms new`, `demo`, +`module`, `install` and `upgrade`. Deleting the file breaks the command at require time for +every downstream user. + +**Stage F therefore deletes nothing.** All three of its candidates are load-bearing, for three +different reasons, and each now carries a comment saying which. + +--- + +## 4. Stage-by-stage + +### A — The orphan skips ✅ + +Two lines deleted, comments left in their place: +[`content_controller.rb:11`](../../app/controllers/cms/content_controller.rb#L11) and +[`portlet_controller.rb:4`](../../app/controllers/cms/portlet_controller.rb#L4). + +Provably a no-op on 4.2 (`skip_callback` finds nil, `chain.delete(nil)` does nothing); +`ArgumentError` at class-definition time on 5.0. + +### A′ — Re-measure ✅ + +Stage A did exactly what was predicted, and nothing more: + +| | Before A | After A | +|---|---|---| +| 4.2, everything | 996, 0F/0E, cucumber 154/154, 78.36% | unchanged | +| 5.0 unit | 756, 2F / 3E | 756, 2F / **2E** | +| 5.0 functional | **did not load** | **88 runs, 0F / 30E** | +| 5.0 cucumber | 6 passed | **23 passed / 131 failed** | + +**Every one of the 131 cucumber failures, and 28 of the 30 functional errors, was +`couldn't find file 'ckeditor-jquery'`** — a single signature. Per the plan's own contingency, +that pulled stage D′ ahead of B and C. + +The four Phase 2 §5 defects that survived stage A: `StaleObjectError` ×2, +`PortletTest#test_.blacklist`, `PublishableTestCase#test_publish_on_save`. §6 covers where +they stand. + +### D′ — The Rails 5 asset chain ✅ + +**D′.1 — `ckeditor_rails`.** [D6](phase-3-implementation-plan.md#d6--how-far-to-move-ckeditor_rails)(c), +as recommended: `NEXT_BOOT ? "~> 4.5.10" : "~> 4.3.0"`. + +4.5.10 was verified as the first release whose dispatch reads `/^[45]/` — 4.4.8, 4.5.1, 4.5.2 +and 4.5.3 were each unpacked and all still read `/^4/`. The lock resolves **4.5.11**, which +was checked to have the same branch, to ship `lib/assets/javascripts/ckeditor-jquery.js`, and +to still default to the `moono` skin (4.16 is where `moono-lisa` arrives). `Gemfile.lock`'s +`ckeditor_rails` line is untouched. + +Result: `couldn't find file 'ckeditor-jquery'` went to **zero** in both the functional and +cucumber logs — and `cms/logo.png` surfaced immediately behind it, exactly as finding 1.15 +predicted. + +**D′.2 — the sprockets-rails 3 declaration.** [D7](phase-3-implementation-plan.md#d7--how-to-satisfy-sprockets-rails-3)(a) +was recommended and **does not work on this bundle.** sprockets-rails only honours +`app/assets/config/manifest.js` when sprockets **4** is loaded +([`railtie.rb:104-110`](../../vendor/bundle/gems/sprockets-rails-3.2.2/lib/sprockets/railtie.rb#L104), +`if using_sprockets4?`), and both locks resolve sprockets 3.7.x. The file would have been inert. + +What was done instead is D7(a)'s *intent* by the mechanism that works here, in +[`lib/cms/engine.rb`](../../lib/cms/engine.rb). sprockets-rails' own default already covers +loose assets — but only the host application's: + +```ruby +LOOSE_APP_ASSETS = lambda do |logical_path, filename| + filename.start_with?(::Rails.root.join("app/assets").to_s) && ... +``` + +An engine's `app/assets` is never under `Rails.root`, which is why every image and font this +engine ships is undeclared. The fix applies the same rule rooted at the engine instead, so it +**declares the class rather than the instances** — which is what bounded the one item in the +phase with no measured upper bound. Enumerating filenames was never viable anyway: the icon +helpers build their paths at runtime +([`application_helper.rb:66`](../../app/helpers/cms/application_helper.rb#L66), `:70`; +[`file_blocks/render.html.erb:2`](../../app/views/cms/file_blocks/render.html.erb#L2)), so the +set is not readable from the views. + +**The sprockets tail the plan budgeted for did not materialise: one iteration closed it.** +Rails 5 functional errors went **30 → 3**, with no `AssetNotPrecompiled` and no +`couldn't find file` anywhere in the logs. The change is in the engine; `git diff` for this +stage touches nothing under `test/dummy/` (criterion 17, second half). + +### B — The oracle ✅ + +[`test/unit/belongs_to_optionality_test.rb`](../../test/unit/belongs_to_optionality_test.rb), +9 tests. Redesigned per §3.2; passes on both bundles. + +### C — The `belongs_to` audit ✅ + +29 sites. **7 left bare, 22 given `required: false`.** + +Left bare, each backed by an existing presence validation on the foreign key — so +required-by-default in a host app would agree with the model's stated intent: + +| Site | Evidence | +|---|---| +| `Cms::Category#category_type` | [`category.rb:12`](../../app/models/cms/category.rb#L12) | +| `Cms::Connector#page`, `#connectable` | [`connector.rb:44`](../../app/models/cms/connector.rb#L44) | +| `Cms::PageRoute#page` | [`page_route.rb:26`](../../app/models/cms/page_route.rb#L26) | +| `Cms::Task#assigned_by`, `#assigned_to`, `#page` | [`task.rb:27-29`](../../app/models/cms/task.rb#L27) | + +That confirms all four of the plan's finding 1.10 contradictions of the phase document: the +doc named `Connector#connectable`, `Task#assigned_by` and `Task#assigned_to` as "near-certain" +`optional:` candidates, and all three are validated as required today. + +The remaining 17 model sites and 5 behavior sites carry `required: false`, on the rule that +**nothing rejects nil there today on either bundle, so pinning it is exactly +behaviour-preserving.** That is a stronger justification than "we judged nil to be legitimate", +and it is what makes the whole audit a no-op change rather than 22 judgement calls. Where the +plan's R2 said "where B cannot decide, prefer optional", this rule reaches the same answer for +a reason that is checkable. + +**Two of the five behavior sites are dynamic declarations that grep will not find** — +[`versioning.rb:115`](../../lib/cms/behaviors/versioning.rb#L115) passes the option into +`version_class.belongs_to(...)`'s options hash, and +[`dynamic_attributes.rb:168`](../../lib/cms/behaviors/dynamic_attributes.rb#L168) does the same. +Both are commented as such at the site, and stage B asserts the versioning one through the +reflection (`Cms::HtmlBlock::Version`), which is the only place it is visible. + +**The 30th site is flagged, not fixed.** +[`lib/templates/active_record/model/model.rb`](../../lib/templates/active_record/model/model.rb) +emits a bare `belongs_to` into every model scaffolded downstream. It now carries an ERB comment +explaining why it cannot simply gain `required: false` and that Phase 5 owns the decision — +including the note that `optional: true` would be *wrong* for a 4.2 target, per §3.1. + +### D — The rest of the 5.0-breaking set ✅ + +- **`deliver` → `deliver_now`, one site**: [`email_message.rb:58`](../../app/models/cms/email_message.rb#L58). + `:15` (`def self.deliver!`) and `:18` (`m.deliver!`) left alone — `m` is a `Cms::EmailMessage` + and `deliver!` is the model's own instance method, so renaming it would have broken the model. + Criterion 8 expects exactly these two survivors. +- **`responders` in the gemspec, unconstrained.** Both locks re-resolved; neither moved + `responders` (2.4.1 on 4.2, 3.0.1 on 5.0). Re-resolving did pull incidental drift on + `net-protocol`, `websocket-driver` and `websocket-extensions`; per R7 that was reverted, so + each lock's diff is now exactly the intended lines and nothing else. +- **The `save!` override forwards** ([D8](phase-3-implementation-plan.md#d8--the-save-override-forwards-and-that-changes-behaviour), + decided: fix now). [`versioning.rb:271`](../../lib/cms/behaviors/versioning.rb#L271) now takes + `(*args, &block)`. **This is the only behaviour change in the phase**: autosaved children of a + versioned record were being validated in a path where Rails asked for `validate: false`, and + will no longer be. See §5. + +### E — The renames ✅ + +| Change | Sites | +|---|---| +| `*_filter` → `*_action` | **34** across 16 files, plus the comment at [`content_page.rb:73`](../../lib/cms/acts/content_page.rb#L73) | +| `update_attributes` → `update` | **14** call sites | +| `render text:` → `plain:` / `html:` | **2 + 2** | +| `.uniq` → `.distinct` | **1** | +| `HashWithIndifferentAccess` → qualified | **2** | + +The two `pretend_controller` sites took `render html:` with `.html_safe`, not `plain:` — both +are cucumber-covered ([`acts_as_content_page.feature:25`](../../features/acts_as_content_page.feature#L25) +and `:49`) and emit markup, so `plain:` would have changed a passing feature's Content-Type. + +The `.distinct` site carries a `TODO(Phase 4)` recording that the dedupe is on the wrong side +of the parenthesis: it binds to the second relation only and does nothing about duplicates +*between* the two halves, which is the only kind the method can produce. Fixing that is a +behaviour change and belongs with the `move_to_position` characterization test. + +**`GuestUser` was left alone** ([D3](phase-3-implementation-plan.md#d3--guestuserupdate_attributes-becomes-an-alias), +decided: leave the hole, record it). The file now documents that `update_attributes` is an +alias and `guest.update(...)` walks past the guard, and +[`test/unit/models/user_test.rb`](../../test/unit/models/user_test.rb) gains two tests: a +**skipped** one asserting the behaviour the fix should produce, and a passing one that pins the +defect as it actually stands and fails the moment someone defines `update` on `GuestUser` — so +the skip cannot be silently orphaned. + +### Added — an empty `WHERE` clause (not in any work item) + +Found by cucumber once D′ cleared the asset errors: 2 scenarios in +[`manage_users.feature`](../../features/authentication/manage_users.feature) died with + +``` +PG::SyntaxError: ERROR: syntax error at or near "ORDER" +... WHERE ORDER BY first_name, last_name, email LIMIT $1 OFFSET $2 +``` + +[`users_controller.rb:10-35`](../../app/controllers/cms/users_controller.rb#L10) builds its +filter list from three optional clauses, so `query` is empty whenever `show_expired` is set +with no keyword and no group — making `conditions` equal `[""]`. **4.2 dropped an empty +condition string and emitted no `WHERE` at all; 5.0 emits a literal empty one.** Skipping the +`where` call when `query` is empty reproduces 4.2's SQL exactly on both versions, so it is +backwards-compatible by construction — the same standard as the rest of the phase. + +Verified both ways: the two scenarios pass on 5.0 after the change, and +`manage_users.feature` is still 14/14 on 4.2. + +### G.2 — The branch-coverage floor ✅ + +Phase 2 measured branch coverage and reported it without gating, on the explicit grounds that +"inventing a floor in the same commit that first measures the number would be gating on +something nobody has looked at." Phase 3 sets it, which is what both +[`.simplecov`](../../.simplecov) and [`core_tasks.rake`](../../lib/tasks/core_tasks.rake) said +this phase would do. + +`COVERAGE_MINIMUM_BRANCH` did not exist — the task *printed* the branch figure and compared +nothing — so this is a new gate, not a changed number. It defaults to **70.83**, the measured +value on a cleared resultset after the Phase 3 diff landed, set with no slack for the same +reason `COVERAGE_MINIMUM` was: a floor with headroom silently absorbs the first regression. + +The task now prints both figures *before* aborting and collects both failures, so a run that +trips one gate still reports where the other stands. Verified in three directions: + +``` +$ rake coverage:check → Coverage 78.37% (baseline 78.35%) + Branch coverage 70.83% (baseline 70.83%) exit 0 +$ COVERAGE_MINIMUM_BRANCH=70.9 rake coverage:check → Branch coverage 70.83% is below 70.90% exit 1 +$ COVERAGE_MINIMUM=99 COVERAGE_MINIMUM_BRANCH=99 → both reported exit 1 +``` + +### F — Dead code ✅ (nothing deleted) + +Per §3.3, zero deletions. All three candidates commented in place: +`to_version400.rb` (required by `bin/bcms`), `deprecated_placeholder.rb` (STI target of a +shipped migration), `namespacing.rb` (satisfies `behaviors.rb:30`'s glob and carries the public +`Cms.table_prefix=` deprecation). + +--- + +## 5. The one behaviour change + +[`versioning.rb`](../../lib/cms/behaviors/versioning.rb)'s `save!` override. Before: + +```ruby +def save!(perform_validations=true) + save(:validate => perform_validations) || raise(...) +end +``` + +Rails never calls `save!` with a positional boolean. 4.2 calls it as `save!(:validate => x)` +([`has_many_association.rb:39`](../../vendor/bundle/gems/activerecord-4.2.11.3/lib/active_record/associations/has_many_association.rb#L39)) +and 5.0 as `save!(validate: x, &block)` +([`collection_association.rb:510`](../../vendor/bundle/gems/activerecord-5.0.7.2/lib/active_record/associations/collection_association.rb#L510)). +So `perform_validations` was bound to a Hash — truthy — and the override called +`save(validate: true)` in precisely the path where the framework had asked for validations to +be skipped. **That is wrong on 4.2 today, silently, on the bundle in production.** On 5.0 it +additionally dropped the block that `create_or_update` yields after insert — the same defect +Phase 2 fixed one method below, which is what made this one reachable. + +A bare `record.save!` is unchanged (`save` with no arguments validates by default). The path +that changes is autosave. + +--- + +## 6. Rails 5 residue + +Nine failures/errors across four suites, and they are **essentially one cluster plus three +singletons.** + +### The cluster: content updates do not persist on 5.0 + +Seven of the nine are one problem wearing four different masks: + +| Where | Symptom | +|---|---| +| unit ×2 | `ActiveRecord::StaleObjectError: Attempted to touch a stale object: Cms::Page` | +| functional ×2 | `Missing partial cms/shared/_version_conflict_error` | +| cucumber ×2 | `manage_images.feature` — the image's section and path are unchanged after save | +| cucumber ×1 | `sitemap/pages.feature:19` — "I change the page name" | + +**The missing partial is a symptom of a symptom.** +`Cms::PagesControllerTest#test_unhide` does `put :update` and expects a redirect; on 5.0 the +update fails, so the controller falls through to re-render the form, and +[`_main_form.html.erb:2`](../../app/views/cms/pages/_main_form.html.erb#L2) renders +`cms/shared/version_conflict_error` — which does not exist. The real file is +`app/views/cms/application/_version_conflict_error.html.erb`. **That partial reference is +broken on 4.2 too**; 4.2 simply never takes the branch. So there is a latent view bug sitting +behind an optimistic-locking difference, and the locking difference is what to diagnose first. + +The two `manage_images` failures read backwards because the step definitions have expected and +actual reversed ([`image_steps.rb:1-9`](../../features/step_definitions/image_steps.rb#L1) — +`expect(section_name).to eq(image.parent.name)`). Decoded: `image.parent.name` is still +`"My Site"` and `image.path` is still the old path. Same shape as `test_unhide` — the update +did not take. + +**This is not caused by anything in this phase.** The `save!` change (§5) is the only +behaviour change here and it lands in exactly this area, so it was ruled out directly: with +`save!` reverted to its old signature and everything else in place, the same four cucumber +scenarios fail in the same way (`12 scenarios (4 failed, 8 passed)` vs the identical four with +the fix in). The cluster is pre-existing Rails 5 behaviour. + +Not a rename and not backwards-compatible — **Phase 4**, characterization first. + +### Singletons + +- **`PublishableTestCase#test_publish_on_save`** (unit failure) — `Expected false to be truthy`. + Survives from Phase 2's §5. Worth re-reading now that `save!` forwards. +- **`Cms::TasksControllerTest#test_complete_no_tasks`** (functional) — + `PG::InvalidTextRepresentation: invalid input syntax for type integer: ""`. Rails 5 stopped + coercing `""` to nil on integer casts. Application behaviour; scope in Phase 4. +- **`features/portlets/portlets_with_params.feature`** (cucumber) — the portlet renders the + page layout instead of its own `"I worked"` content. Unrelated to this phase's work. + +### Resolved since Phase 2's §5 list + +- **`ckeditor-jquery`** — gone (stage D′.1). +- **`PortletTest#test_.blacklist`** — passed in the closing run. It compares a class list whose + order depends on load order, so treat it as flaky rather than fixed. +- **The orphan-suite error** — gone; that suite is 7/7. +- **`use_route`** ([`test/support/engine_controller_hacks.rb`](../../test/support/engine_controller_hacks.rb)) + was expected to surface at A′ and never became a blocker. Still not this phase's. + +**`use_route`** ([`test/support/engine_controller_hacks.rb`](../../test/support/engine_controller_hacks.rb)) +was expected to surface at A′ and did not become a blocker. Still not this phase's. + +--- + +## 7. Deviations from the plan + +| # | Deviation | Why | +|---|---|---| +| 1 | Audit uses `required: false`, not `optional: true` | §3.1 — `optional:` raises `ArgumentError` on 4.2 and would take the production bundle down | +| 2 | Stage B is a reflection-and-validation invariant, not a forced flag | §3.2 — the flag does not exist on 4.2 and is read at class-definition time on 5.0 | +| 3 | Stage F deletes nothing | §3.3 — `bin/bcms` requires `to_version400.rb` | +| 4 | D′.2 uses an engine-rooted precompile lambda, not `manifest.js` | §4, D′ — sprockets-rails ignores `manifest.js` unless sprockets 4 is loaded | +| 5 | Stages run in order A, A′, D′, C+B, D, E, F | The plan's own contingency: A′'s cucumber number was 131/131 one signature | +| 6 | Migration item deferred ([D1](phase-3-implementation-plan.md#d1--the-migration-item-leaves-the-phase)) | `ActiveRecord::Migration[4.2]` does not exist on 4.2 and 5.0 does not need it. Criterion 12 struck | +| 7 | `GuestUser` left alone, documented + skipped test ([D3](phase-3-implementation-plan.md#d3--guestuserupdate_attributes-becomes-an-alias)) | Human decision: preserve the phase's no-behaviour-change contract; record the hole rather than close it | +| 8 | `test/`'s four `_filter` sites were renamed after all | See below — [D5](phase-3-implementation-plan.md#d5--scope-of-the-_filter-rename) miscounted what they are | + +**On D5.** The plan scoped `test/`'s four `_filter` sites out, on the reading that they "break +at 5.1 exactly like the production ones". They are not independent declarations. All four are +in [`test/unit/lib/acts_as_content_page_test.rb`](../../test/unit/lib/acts_as_content_page_test.rb), +and all four are mocha expectations *on the production call*: + +```ruby +NewController.expects(:before_filter).with(:check_access_to_section, {}) +``` + +`before_action` and `before_filter` are aliases into one chain, but they are distinct methods +as far as mocha is concerned — so renaming `lib/cms/acts/content_page.rb` made all three tests +fail on 4.2 with "expected exactly once, invoked never". They are mirrors of the renamed call +and had to move with it. **This is what caught the rename**, and it is the reason the plan's +instruction to re-run the 4.2 suite after every stage is worth following: a white-box test +asserting a method name is exactly the thing a "pure rename" does not stay pure through. + +After the update there are **zero** `*_filter` references anywhere in `app/`, `lib/`, `test/`, +`spec/` or `features/`. + +--- + +## 8. Exit criteria + +Every grep was run and its output pasted, including the ones that already passed — so the +record shows they were checked rather than assumed. + +| # | Criterion | Verification | Result | +|---|---|---|---| +| 1 | Suite green on both Gemfiles | both jobs | 4.2 ✅ green (1007, 0F/0E, cucumber 154/154); 5.0 ❌ — 9 remaining, §6 | +| 2 | All 29 `belongs_to` audited | `grep -rnE "belongs_to.*required(:\| =>) *false" app/ lib/` | ✅ **22** declarations + **7** left bare = 29. Two of the 22 are dynamic (§4, C) and are asserted through the reflection in stage B instead | +| 3 | No `*_filter` callbacks | `grep -rnE "\b(before\|after\|around\|skip_before\|skip_after)_filter\b" app/ lib/` | ✅ **0** (also 0 across `test/ spec/ features/`) | +| 4 | No `update_attributes` calls | `grep -rn "update_attributes" app/ lib/` | ✅ **0 calls.** Survivors: the definition at [`guest_user.rb:66`](../../app/models/cms/guest_user.rb#L66) (kept by [D3](phase-3-implementation-plan.md#d3--guestuserupdate_attributes-becomes-an-alias)), plus 3 comment lines that name the Rails API. The criterion anticipates 1–2 | +| 5 | No `render text:` | `grep -rnE "render\s+(:text\s*=>\|text:)" app/ lib/` | ✅ **0** | +| 6 | No `.uniq` on a Relation | `grep -rn "\.uniq" app/controllers/ app/models/` | ✅ **3 hits, all Arrays** — `portlet.rb:86` (`.flatten.compact.uniq`), `content_type.rb:52` (`subclasses.uniq!`), `page_template.rb:29` (`.map{}.sort.uniq`). The one Relation is now `.distinct` | +| 7 | No `HTML::FullSanitizer` | `grep -rn "HTML::FullSanitizer" app/ lib/ test/ spec/` | ✅ **1 hit, a comment** at [`content_filter.rb:12`](../../lib/cms/content_filter.rb#L12) that names the constant to say it is *not* used. Passed on entry (Phase 2) | +| 8 | No bare mailer `.deliver` | amended grep | ✅ **2 survivors, both correct**: `email_message.rb:15` (`def self.deliver!`) and `:18` (`m.deliver!`, the model's own instance method). The one real site is now `deliver_now` | +| 9 | No `File.exists?` | `grep -rn "File.exists?" app/ lib/` | ✅ **0**. Passed on entry (Phase 0, `6da1d60d`) | +| 10 | No unqualified `HashWithIndifferentAccess` | `grep -rnE "(^\|[^:A-Za-z])HashWithIndifferentAccess" app/ lib/` | ✅ **0** | +| 11 | `responders` in the gemspec | `grep -n responders browsercms.gemspec` | ✅ [`:83`](../../browsercms.gemspec#L83), unconstrained | +| 12 | ~~Migrations version-qualified~~ | — | **Struck** ([D1](phase-3-implementation-plan.md#d1--the-migration-item-leaves-the-phase)). Moved to Phase 5 / the 5.1 hop | +| 13 | `find_by_*` untouched | `git diff` for `find_by_login\|path\|code\|from_path` | ✅ **0 changed lines** | +| 14 | Zero `NextRails` branches | `grep -rn "NextRails" app/ lib/` | ✅ **0**. The one conditional the phase needed (`NEXT_BOOT`, D′.1) is in the gemspec, which is outside this criterion's scope | +| 15 | Deployed to production on 4.2 | — | ❌ **not done** — §9 | +| 16 | The `next-rails` CI job is green | both jobs | ❌ **not yet** — 9 failures/errors remain across the 5.0 suites, down from "the functional suite does not load and 148 of 154 cucumber scenarios fail". §6 shows they are one pre-existing cluster plus three singletons, none of them a Phase 3 work item and none caused by this phase | +| 17 | Rails 5 asset chain clear, fix in the engine | logs + `git diff` | ✅ **both halves.** No `couldn't find file` and no `AssetNotPrecompiled` anywhere in the 5.0 logs; `git diff --name-only -- test/dummy/` is **empty**, so the fix is in [`lib/cms/engine.rb`](../../lib/cms/engine.rb) and [`browsercms.gemspec`](../../browsercms.gemspec) where a consuming application will inherit it | + +--- + +## 9. Open items + +- **Commits.** Every change below is in the working tree; the per-stage commits the plan calls + for were not made because `git commit` was refused by the environment's permission layer. + The stage boundaries are clean and the intended commit split is in §4. +- ~~**Coverage.**~~ ✅ Resolved. Measured on a cleared resultset: line **78.37%**, branch + **70.83%**, and `COVERAGE_MINIMUM_BRANCH` is now set and gating (§4, G.2). +- **The by-hand CKEditor check (D′.1) has not been done.** The gem moved 4.3.4 → 4.5.11, which + is two minor versions of CKEditor itself, and there are zero `@javascript` scenarios — a green + cucumber run proves the asset *resolves*, not that the editor *works*. This is the only oracle + that exists and it is a human's to run. +- **Criterion 15 (deployed to production on 4.2)** — not done. +- **Watch `test/dummy/db/schema.rb`.** Running the suite regenerates it from the live database: + `app:test:prepare` rewrote it during this phase, dropping ~500 lines of dynamically-created + test tables and reformatting it in 5.0's style. It was reverted, and criterion 17's + "nothing under `test/dummy/`" check is clean — but it will come back on the next run, and + committing it would be a silent, large, and entirely accidental change. Check + `git status` for it before every commit. +- **Criterion 16 is not met and the `next-rails` job stays red.** §6 is the argument for why + that is the right place to stop rather than a reason to keep going: what remains needs + characterization, which is [Phase 4](phase-4-characterization-tests.md)'s scope, and the + gating decision made in Phase 2 assumed Phase 3 would clear items that were in Phase 3's + scope. All of those are cleared. diff --git a/docs/rails-upgrade/phase-4-characterization-tests.md b/docs/rails-upgrade/phase-4-characterization-tests.md new file mode 100644 index 000000000..917e32871 --- /dev/null +++ b/docs/rails-upgrade/phase-4-characterization-tests.md @@ -0,0 +1,123 @@ +# Phase 4 — Characterization Tests + +> ## Goal +> **Write tests that pin the behaviour Rails 5 changes *silently* — the places where the code keeps running and quietly does something different.** +> +> Characterization tests assert what the code does *today*, not what it should do. Their job is to scream when a Rails upgrade changes semantics without raising an error. + +**Blocking:** 🔴 Yes for the four 5.0-specific items (4.1–4.4). The rest are strongly recommended but can trail the bump. +**Rails version at the end of this phase:** 4.2.11.3, with a materially better regression net. + +--- + +## Why this phase exists + +**Because loud breakage is self-reporting and silent breakage is not.** + +`before_filter` was removed in 5.1. There are 37 call sites. On 5.1 the app won't boot — `NoMethodError` at class-definition time. That needs zero tests; it needs the rename [Phase 3](phase-3-backwards-compatible-fixes.md) already did. Writing a test to catch it would be writing a test to tell you something the boot sequence already screams. + +What needs tests is code that keeps running: a monkeypatch whose target method vanished, so the patch defines a method nobody calls. A validation that now fires where it didn't. An attribute read that silently returns `nil`. + +This phase is scoped to those. It deliberately does **not** try to raise coverage — the 72.64% aggregate is adequate; its *distribution* isn't. Models, the layer Rails changes least, sit at 92%. Controllers, where the breakage lives, sit at 58%. + +## Supporting documentation + +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §2](../../RAILS_UPGRADE_TEST_PRIORITY.md) — "The prioritization principle: silence, not likelihood." The reasoning behind this phase. +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §3](../../RAILS_UPGRADE_TEST_PRIORITY.md) — **B1 through B10 in full.** Each has a "Test to write" paragraph; this phase is those paragraphs, sequenced. +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §0.3](../../RAILS_UPGRADE_TEST_PRIORITY.md) — **required reading for 4.1.** Why the `belongs_to` test must force the flag on or it asserts nothing. +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §7](../../RAILS_UPGRADE_TEST_PRIORITY.md) — the coverage-adequacy table, per testing-checklist category +- [`TEST_COVERAGE_PLAN.md` §1](../../TEST_COVERAGE_PLAN.md) — per-file coverage, for judging where a test adds signal versus where CI already covers it +- Skill: `references/testing-checklist.md` "Common Test Fixes", `version-guides/upgrade-4.2-to-5.0.md` §3 and §8 + +## Work items + +Ordered by confidence-per-hour. **4.1–4.4 are the 5.0-blocking set.** + +### 4.1 — `belongs_to` required by default (B5) — highest confidence per hour + +**⚠️ Set `config.active_record.belongs_to_required_by_default = true` in the test environment first.** BrowserCMS is an engine with no `load_defaults` of its own, so tests written against the dummy app's inherited defaults pass whether or not the declarations are correct. Without this, the whole item quietly tests nothing. + +- [ ] For each of the **29** declarations, one test that either (a) asserts the association is required, or (b) asserts a record saves without it. Mechanical, fast, and it forces a decision on all 29. +- [ ] Pay particular attention to the 5 injected by behaviors — they apply to every model using the mixin. +- [ ] Note the existing `userstamping` tests cover only the *nil-user* cases, so under required-by-default they go red immediately. That is the one place this flag fails loudly, and it's an accident of a coverage gap rather than a designed net. Don't rely on the accident; write the explicit assertions. + +### 4.2 — Eager-load / autoload contract (B10) — one test, one hour, enormous signal + +- [ ] `Rails.application.eager_load!`, then assert every expected `Cms::` constant resolves. +- [ ] One test, and it catches an entire class of autoload regressions. +- [ ] **Do this even though Zeitwerk lands at 6.0, not 5.0.** It is the cheapest possible defence against the largest single item in the whole upgrade — `lib/cms/engine.rb` is 135 lines with a 7-line test containing 1 assertion, and it pushes 6 directories onto `ActiveSupport::Dependencies.autoload_paths`, an API Zeitwerk does not have. This test's output will scope the 6.0 hop. + +### 4.3 — `create_content_table` migration DSL (B3) + +`lib/cms/extensions/.../schema_statements.rb` — 86.79%, 7 missed. It calls `create_table table_name, options, &block`, and the positional-options signature changes at Rails 5+. + +- [ ] A migration test calling `create_content_table` with each option combination (`versioned: true/false`, `name: true/false`), asserting the resulting column set on **both** the content table and the `_versions` table. +- [ ] Blast radius is the largest in the codebase — every migration in BrowserCMS and in every downstream project. Currently nothing runs the DSL at all. + +### 4.4 — `ActionController::Parameters` on the four uncovered sites (B9) + +Ten sites exist; **only four need tests.** The other six are on lines CI executes (`content_controller.rb:72` at 153 hits, `path_helper.rb:33-36` at 49) — a `Parameters`-vs-`Hash` break there raises in CI, so test budget belongs elsewhere. + +- [ ] `app/controllers/cms/form_fields_controller.rb:16` — `params[:form_field].delete(:form_id)`. **File is 0% covered.** +- [ ] `app/controllers/cms/forms_controller.rb:33` — `params[:form].delete(:new_entry)`. **File is 0% covered.** +- [ ] `app/controllers/cms/pages_controller.rb:126-128` — `strip_visibility_params`; 3 of its lines are untested. +- [ ] `app/controllers/cms/sections_controller.rb:43` — `params[:section].delete('group_ids')`, inside the 9 missed lines. +- [ ] Assert the stripped key is absent from the resulting record. **These two are authorization logic** — `strip_visibility_params` and the `group_ids` deletion exist to stop a non-admin setting fields they shouldn't — so they sit on a security boundary, not just a compatibility one. + +### 4.5 — Schema dumper guard (B1) + +The version claim here is **unverified** — the skill has no entry for `ColumnDumper` or `column_spec`. That is exactly why the guard test is the right response: it converts an unknown into a loud failure at whichever hop it lands. + +- [ ] A guard test that **fails loudly if `ActiveRecord::ConnectionAdapters::ColumnDumper` is not already defined** when `lib/cms/extensions/.../schema_dumper.rb` loads. Reopening a vanished module silently defines a new empty one — no error, no warning, patch gone. +- [ ] An end-to-end test: dump the schema for a table with boolean defaults, assert the output contains `published` / `deleted` / `archived` with their defaults. **Assert on the dumped output, not on `column_spec`'s return value** — the point is to detect the patch going missing. +- [ ] Why this ranks despite only 3 missed lines: the file's own comment documents this failure already happening once, producing a `db/schema.rb` missing half its tables. A silently-truncated schema corrupts every developer's database and every CI run downstream, and it looks like a working build. + +### 4.6 — Callback ordering audit (B6) — read before you write + +`lib/cms/behaviors/versioning.rb` is 96.91% covered with 162 relevant lines. The risk isn't "untested," it's "tested for the wrong thing" — line coverage cannot tell you whether anything asserts that versioning happens in the right order *relative to* validation and dirty-tracking. Lines 206–225 are a comment block documenting an **observed** ActiveRecord `save` call order, and Rails 5 changed callback halting. + +- [ ] Read `test/unit/behaviors/versioning_test.rb` and `publishing_mini_test.rb` and answer three questions: does anything assert that a failed validation produces **no** new version? That `version_comment` reflects the changes from *this* save? That a rolled-back transaction leaves no orphan version row? +- [ ] Write whichever of those is missing. Budget reading time before writing time. + +### 4.7 — The remaining Tier B items (can trail the bump) + +- [ ] **B2 — `dynamic_attributes` attribute chain.** Assert `read_attribute` **and** `_read_attribute` both resolve dynamic attributes; that `respond_to?` agrees with `method_missing`; that a dynamic attribute survives `save` → `reload` → read; round-trips through `assign_attributes` and `[]=`. Also cover `nonversioned_class`, which has no test. Worst test-to-source ratio in the repo (384 LOC / 73 test LOC). *Version claim unverified; the test is version-agnostic.* +- [ ] **B7 — `publishing.rb` hand-built SQL.** Assert `publish!` flips `published` in the database for versioned and non-versioned models, **read back through a fresh query**, not the in-memory object. *Version claim unverified; behavioural test survives being wrong about it.* +- [ ] **B8 — `soft_deleting.rb` default scope.** Assert `deleted` records are excluded by default, included under `unscoped`, and that the default scope composes correctly with `where` and `or`. +- [ ] **B4 — the three untested Paperclip validation macros.** `validates_attachment_size` (9 missed), `validates_attachment_content_type` (6), `validates_attachment_presence` (3). One passing and one failing test each, pinning the current error messages. These become the acceptance criteria for whatever replaces Paperclip. ⚠️ `validates_attachment_presence` is **defined twice** — `attaching.rb:89` and `:98`; the first is dead code silently overwritten. +- [ ] **Tier C fixes with a behavioural choice:** the two `render text:` error branches (`content_block_controller.rb:138` "Not Implemented", `form_fields_controller.rb:43` "Fail"/500) and `Relation#uniq` in `move_to_position`. Worth testing *because* they're error paths nothing exercises. + +--- + +## Exit criteria + +| # | Criterion | How to verify | +|---|---|---| +| 1 | Suite green on both Gemfiles | CI both jobs passing | +| 2 | Coverage is **at or above** the Phase 2 number, with branch coverage reported | Coverage artifact compared against Phase 2 | +| 3 | **`belongs_to_required_by_default = true` is set in the test environment** | `grep -rn "belongs_to_required_by_default" test/` returns a line — without this, criterion 4 is meaningless | +| 4 | All 29 `belongs_to` declarations have an explicit test: required, or saves-without-it | A test file enumerates them; the count in the test matches 29 | +| 5 | An eager-load test exists and passes | `Rails.application.eager_load!` appears in a test; it runs in the default suite, not a manual task | +| 6 | `create_content_table` is exercised with every option combination | Test asserts columns on both the content table and the `_versions` table | +| 7 | The four uncovered `Parameters` sites have tests; `form_fields_controller.rb` and `forms_controller.rb` are **no longer 0%** | Coverage report shows both files above 0% | +| 8 | A guard test fails if `ColumnDumper` is undefined at load time | Temporarily rename the constant in the patch file; the guard test must go red. **Verify the guard actually guards.** | +| 9 | Schema dump output is asserted end-to-end for boolean-default columns | Test asserts on dumped `db/schema.rb` content, not on `column_spec` | +| 10 | The B6 audit is **written down**, with the three questions answered yes/no | A committed note or test comments state which of the three assertions existed and which were added | +| 11 | Every new test is a *characterization* test | Each asserts current behaviour with no `NextRails.next?` branching: `grep -rn "NextRails" test/ spec/` stays empty | +| 12 | No test was written for a loud failure | Review: no new test exists solely to catch `*_filter`, `update_attributes`, or `File.exists?` — the boot sequence catches those | + +**Done means:** criteria 3+4 hold together (the flag is on *and* all 29 are asserted), criterion 8's guard has been proven to guard by deliberately breaking it, and the two 0%-coverage Forms controllers are no longer at zero. + +> **Criterion 8 deserves emphasis.** A guard test that has never been seen to fail is not a guard. Break the constant, watch it go red, put it back. + +--- + +## Explicitly not in this phase + +- **No Rails bump.** Still 4.2.11.3. +- **No coverage-driven test writing.** The `TEST_COVERAGE_PLAN.md` coverage-per-effort backlog is a *later* activity, pursued as regression pressure demands. Writing tests to raise a percentage is the wrong objective here — every test in this phase exists to detect a specific silent semantic change. +- **No tests for loud breakage** (criterion 12). `*_filter`, `update_attributes`, `File.exists?`, `render text:` as such — all fail at boot or raise immediately. Only their *behavioural* edges get tests. +- **Not testing the six covered `Parameters` sites.** CI is already the detector. +- **No Paperclip replacement**, only the validation tests that will constrain it later. +- **No content-block lifecycle integration specs.** The single most valuable test asset — create → edit → publish → connect → render → version → revert in one chain — is a `cms`-side deliverable and is out of scope for these files. See [`TEST_COVERAGE_ANALYSIS.md` Phase 3](../../TEST_COVERAGE_ANALYSIS.md). +- **No helper tests** (11 untested, including the 168-line `ui_elements_helper.rb`). Real gaps, but not silent-Rails-5-change gaps. diff --git a/docs/rails-upgrade/phase-5-the-5.0-bump.md b/docs/rails-upgrade/phase-5-the-5.0-bump.md new file mode 100644 index 000000000..ef0ebc61f --- /dev/null +++ b/docs/rails-upgrade/phase-5-the-5.0-bump.md @@ -0,0 +1,119 @@ +# Phase 5 — The Rails 5.0 Bump + +> ## Goal +> **Make Rails 5.0 the default version, with the suite green, CI matching the new Gemfile, and `load_defaults` handled as a deliberate decision rather than a side effect.** +> +> By this point every known breakage has already been fixed on 4.2. This phase should be small. If it isn't, an earlier phase was incomplete. + +**Blocking:** — (this is the payoff, not a gate) +**Rails version at the end of this phase:** **5.0.x**, deployed. + +--- + +## Why this phase exists + +Everything before this was preparation designed to make the bump boring: + +- [Phase 0](phase-0-baseline-and-ci.md) gave a green button and a known baseline. +- [Phase 1](phase-1-gem-compatibility-and-dual-boot.md) proved Rails 5.0 boots and named every blocking gem. +- [Phase 2](phase-2-harness-migration.md) got the suite passing on `Gemfile.next` — **so Rails 5.0 is already green before this phase starts.** +- [Phase 3](phase-3-backwards-compatible-fixes.md) removed ~96 breakages while still on 4.2. +- [Phase 4](phase-4-characterization-tests.md) pinned the behaviour that changes silently. + +So the actual bump is mostly bookkeeping: swap the constraint, retire the dual-boot scaffolding *or* keep it for the next hop, sync CI, and decide the `load_defaults` question — which for an engine is genuinely different from what the skill's guide assumes. + +**The one thing that is not bookkeeping** is `load_defaults`. See §5.4. + +## Supporting documentation + +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §6, Phase 4](../../RAILS_UPGRADE_TEST_PRIORITY.md) — the version-by-version rules, including CI sync and the per-hop patch re-check +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §0.3](../../RAILS_UPGRADE_TEST_PRIORITY.md) — **the engine `load_defaults` problem.** The most important section for this phase. +- [`RAILS_UPGRADE_TEST_PRIORITY.md` §7](../../RAILS_UPGRADE_TEST_PRIORITY.md) — the coverage-adequacy table, whose ❌ rows are the honest known-risk list to verify manually +- Skill: `version-guides/upgrade-4.2-to-5.0.md` — the hop guide, including the `belongs_to` migration checklist and common issues +- Skill: `references/testing-checklist.md` — **the source for the manual verification in §5.5** +- Skill: `workflows/ci-sync-workflow.md` — mandatory before the PR +- Skill: `workflows/app-update-preview-workflow.md` — for reviewing config changes before applying them +- Skill: the `rails-load-defaults` skill (§5.4), the `upgrade-cleanup` plugin (§5.6) + +## Work items + +### 5.1 — Swap the version + +- [ ] Update `browsercms.gemspec`: `s.add_dependency "rails", "~> 4.2.0"` → `"~> 5.0.0"`. +- [ ] Bring across every gem bump from Phase 1's required-bumps bucket. +- [ ] `bundle update rails`. Confirm the resolved version is the **latest 5.0 patch**, not 5.0.0 — the skill's Step 0 applies at every hop, and starting a hop on an old patch means debugging bugs that were already fixed. +- [ ] Verify `rails console` and `rails server` work against the dummy app, and that the homepage renders. These are the skill's post-`bundle update` smoke checks and they catch boot-level problems before the suite obscures them. + +### 5.2 — Review the config changes + +- [ ] Generate an `app:update` preview and review each proposed config change individually rather than accepting wholesale. As an engine, most app-level config belongs to `test/dummy`, not to BrowserCMS itself — be deliberate about which changes are the engine's and which are the dummy app's. +- [ ] Confirm the `config.public_file_server.enabled` rename from [Phase 2](phase-2-harness-migration.md) is still correct under the real 5.0. + +### 5.3 — Sync CI + +- [ ] Enumerate every CI file and diff Ruby version, Rails matrix, and service versions against the upgraded Gemfile. The skill calls stale CI the **most common cause of red builds on upgrade PRs**. +- [ ] The default job now runs 5.0. Decide what `Gemfile.next` points at: either retire it, or repoint it at **5.1** to keep dual-boot rolling into the next hop. Keeping it is the cheaper path if you're continuing immediately. + +### 5.4 — Decide `load_defaults` — the real work of this phase + +The skill's Step 7 says: after the version bump, delegate to the `rails-load-defaults` skill and walk each config change one risk tier at a time, re-running tests between each. + +**That assumes an application. BrowserCMS is an engine**, and `load_defaults` appears nowhere in this repo (`lib/cms/engine.rb:7` declares `isolate_namespace Cms`). The flag values are owned by the **host app** — `cms`, and every other downstream BrowserCMS project. + +So the question is not "which defaults do we adopt?" It is **"which range of host `load_defaults` values does BrowserCMS claim to support?"** + +- [ ] Make that decision and write it down — in the README, the gemspec description, or a `SUPPORT.md`. It's a contract with downstream projects, not an internal config choice. +- [ ] For each behaviour the decision touches, ensure the test suite covers **both** settings, or state explicitly that only one is supported. [Phase 4](phase-4-characterization-tests.md)'s `belongs_to_required_by_default = true` test env is the template. +- [ ] Set `deprecation_behavior = :raise` in the test environment from 5.0 onward, so the next hop's deprecations surface as failures rather than log noise. +- [ ] **Do not fix deprecation warnings about Rails 5.1+ behaviour during this hop.** Those belong to the next cycle. Triaging tomorrow's warnings today expands scope and risks shipping something half-finished. Record them for [Phase 6](phase-6-subsequent-hops.md). + +### 5.5 — Verify manually where tests can't + +The [§7 coverage-adequacy table](../../RAILS_UPGRADE_TEST_PRIORITY.md) has four ❌ rows. Those are known-uncovered areas, so they need eyes. From the skill's `references/testing-checklist.md`: + +- [ ] **Auth / authorization** — log in, log out, password reset, role-based access. The permission join-models are untested and `persistent_user.rb` (209 LOC) has no test at all; **permissions failing *open* is the worst possible upgrade regression.** +- [ ] **Content-block CRUD end to end** — create, edit, publish, connect to a page, render on the public page, view version history, revert. +- [ ] **File upload / download / image variants** — Paperclip is still in place, but this is the public upload path. +- [ ] **Forms** — submission, validation display, CSRF. `form_entries_controller.rb` is 140 lines at **0% coverage** and handles public form submission. +- [ ] **Assets** — CSS, JS, images load; fingerprinting and compilation work. +- [ ] Record the results. An unrecorded manual pass is indistinguishable from no manual pass. + +### 5.6 — Ship it + +- [ ] Deploy and monitor. The skill's rollback triggers: error rate > 2× normal, response time > 3× normal, a critical feature broken, or any data-integrity issue. +- [ ] **Do not auto-run cleanup.** The `upgrade-cleanup` plugin removes `NextRails.next?` branches and retires dual-boot scaffolding, but only when explicitly asked. If you're heading straight to 5.1, keeping dual-boot in place is the right call. + +--- + +## Exit criteria + +| # | Criterion | How to verify | +|---|---|---| +| 1 | `Gemfile.lock` resolves the **latest 5.0 patch** | `bundle list \| grep " rails "`; cross-check against RubyGems for the newest 5.0.x | +| 2 | `browsercms.gemspec` declares `~> 5.0.0` | `grep -n 'add_dependency("rails"' browsercms.gemspec` | +| 3 | **Full suite green on 5.0** — unit, spec, functional, and features | CI passing on the default job | +| 4 | Coverage at or above the Phase 4 number | Coverage artifact compared against Phase 4's | +| 5 | **Cucumber pass rate at or above Phase 0's baseline** | Compare against the committed number. Regression here means the end-to-end net shrank during the upgrade. | +| 6 | `rails console` and `rails server` both work; the dummy app's homepage renders | Manual, recorded | +| 7 | CI config matches the upgraded Gemfile — Ruby version, Rails matrix, services | Every CI file diffed; the skill's ci-sync verdict is OK with no DRIFT entries | +| 8 | **The `load_defaults` support decision is written down** in a durable place | The statement exists in the repo and names which host `load_defaults` values are supported | +| 9 | `deprecation_behavior = :raise` is set in the test environment | `grep -rn "deprecation_behavior" test/dummy/config/environments/test.rb` | +| 10 | The manual verification checklist from §5.5 is **completed and recorded**, including an explicit auth/permissions pass | A committed note with per-item results | +| 11 | Deprecation warnings emitted by 5.0 about 5.1 behaviour are **captured, not fixed** | A committed list, carried into [Phase 6](phase-6-subsequent-hops.md) | +| 12 | Deployed to production and stable past the skill's rollback window | No rollback trigger fired | +| 13 | Zero `NextRails.next?` branches were needed for application code | `grep -rn "NextRails" app/ lib/` — if any exist, each needs a comment saying why the fix couldn't be version-neutral | + +**Done means:** criteria 3, 5, 7, 8, and 10 all hold. Green tests alone are not sufficient — the Cucumber rate must not have regressed, CI must actually match the Gemfile, the engine's `load_defaults` contract must be stated, and someone must have logged in and confirmed permissions still deny. + +> **If this phase turned out to be hard, that is diagnostic information.** It means [Phase 1](phase-1-gem-compatibility-and-dual-boot.md), [3](phase-3-backwards-compatible-fixes.md), or [4](phase-4-characterization-tests.md) was incomplete. Record what surprised you before starting 5.1 — the same gap will bite again. + +--- + +## Explicitly not in this phase + +- **No 5.1 work.** Not `*_filter` (already done in Phase 3), not `redirect_to :back` (verified absent), not the 5.1 deprecations this hop surfaces. One hop at a time; the skill is emphatic that **version skipping is not allowed.** +- **No Zeitwerk, no `ApplicationRecord`.** Both are 6.0. +- **No Paperclip, Devise config, SimpleForm, or asset-pipeline migration.** +- **Not fixing forward-looking deprecation warnings** (criterion 11). Capture them; fix them in the hop that owns them. +- **No dual-boot cleanup**, unless you've decided to stop here. If 5.1 is next, keep the scaffolding. +- **No coverage improvement.** Holding the line is the goal. diff --git a/docs/rails-upgrade/phase-6-subsequent-hops.md b/docs/rails-upgrade/phase-6-subsequent-hops.md new file mode 100644 index 000000000..bc5b85ba6 --- /dev/null +++ b/docs/rails-upgrade/phase-6-subsequent-hops.md @@ -0,0 +1,114 @@ +# Phase 6 — Subsequent Hops (5.1 → 8.0) + +> ## Goal +> **Repeat a known-good loop for each remaining version, one minor at a time, ending with Rails 8.x green and deployed.** +> +> This file is a template, not a plan. It is deliberately thinner than Phases 0–5 because **only the 4.2 → 5.0 hop has had a real detection pass.** Anything more specific about 6.x/7.x/8.x would be invented rather than measured. + +**Rails version at the end:** 8.x. +**Remaining hops:** 5.0 → 5.1 → 5.2 → 6.0 → 6.1 → 7.0 → 7.1 → 7.2 → 8.0. **Nine versions, no skipping.** + +--- + +## Why this file is a template + +The supporting analysis was reconciled against the `rails-upgrade` skill **for the 4.2 → 5.0 hop only**. Its Tier C table lists removal versions for later hops, and those versions are now skill-sourced and trustworthy — but **a version claim is not a detection pass.** Nobody has run the 6.0, 7.0, or 8.0 pattern sets against this codebase. + +Writing detailed phase files for those hops now would produce confident-looking documents with nothing behind them. The honest move is to record the loop, flag the three hops that are known to be hard, and re-run detection when each one arrives. + +## The per-hop loop + +Run this for every hop. It is Phases 0–5 compressed, because the expensive one-time work (CI, harness, dual-boot) is already done. + +| Step | Action | Skill reference | +|---|---|---| +| 1 | **Confirm you're on the latest patch** of the current series before moving | `SKILL.md` Step 0 — applies at *every* hop, not just the first | +| 2 | **Confirm the suite is green** on the current version | `workflows/test-suite-verification-workflow.md` | +| 3 | **Repoint `Gemfile.next`** at the next version | the `dual-boot` skill | +| 4 | **Run the detection patterns** for the target version | `detection-scripts/patterns/rails-{VERSION}-patterns.yml` | +| 5 | **Read the hop guide** | `version-guides/upgrade-{FROM}-to-{TO}.md` | +| 6 | **Check gem compatibility** against the target | `workflows/gem-compatibility-workflow.md` | +| 7 | **Boot smoke test** against `Gemfile.next` | `workflows/boot-smoke-test-workflow.md` | +| 8 | **Fix `kind: breaking` and `kind: deprecation` findings** *before* the bump | `SKILL.md` Step 6 | +| 9 | **Bump**, run both suites, sync CI | `workflows/ci-sync-workflow.md` | +| 10 | **Align `load_defaults`** as a separate change — but see the engine caveat below | the `rails-load-defaults` skill | +| 11 | **Capture forward-looking deprecations; do not fix them** | carry to the next hop | +| 12 | **Deploy before starting the next hop** | — | + +### Two rules that apply at every hop + +**Prefer backwards-compatible fixes.** Most replacements work on both versions, which means they can ship independently of the bump — the [Phase 3](phase-3-backwards-compatible-fixes.md) pattern. Use `NextRails.next?` only when the fix genuinely requires an API that doesn't exist in the current version. Never `respond_to?`. + +**`load_defaults` is a support-range question, not a config change.** BrowserCMS is an engine; the host app owns the flag. Every hop that introduces a `load_defaults`-gated behaviour change means re-answering "which host values do we support?" See [`RAILS_UPGRADE_TEST_PRIORITY.md` §0.3](../../RAILS_UPGRADE_TEST_PRIORITY.md). + +--- + +## The hops that are known to be hard + +Difficulty ratings are the skill's. Three of the nine carry most of the risk. + +| Hop | Difficulty | What lands | Already prepared? | +|---|---|---|---| +| 5.0 → 5.1 | ⭐ Easy | `*_filter` removed, `render text:` removed, `Relation#uniq` removed, `redirect_to :back` removed | ✅ **Mostly done** on the code side. All three removals fixed in [Phase 3](phase-3-backwards-compatible-fixes.md); `redirect_to :back` verified absent. ⚠️ **But two gems block this hop**, measured in [Phase 1](phase-1-gem-report.md): `cucumber-rails 1.4.5` caps `railties < 5.1` (it clears 5.0, so the cucumber-stack work can be deferred to exactly here), and `minitest` is pinned down to `~> 5.10.3` to work around Rails 5.0's test reporter — revisit at 5.1. | +| 5.1 → 5.2 | ⭐⭐ Medium | Bare `HashWithIndifferentAccess`; unquoted `class_name:` constants now raise `ArgumentError`; `halt_callback_chains_on_return_false` config removed | ✅ Partly. `HashWithIndifferentAccess` qualified in Phase 3. The config and unquoted `class_name:` are **verified absent in browsercms** — but `cms` has **11 unquoted `class_name:` sites**, which become a hard boot failure here. | +| 5.2 → **6.0** | ⭐⭐⭐ **Hard** | **Zeitwerk**, `update_attributes` removed, Action Mailbox/Text, Ruby 2.5+ | ⚠️ **Partly.** `update_attributes` done in Phase 3; [Phase 4](phase-4-characterization-tests.md)'s eager-load test is the Zeitwerk defence. **The rest is a phase of its own — see below.** | +| 6.0 → 6.1 | ⭐⭐ Medium | Horizontal sharding, strict loading, `ActiveModel::Errors` **rewritten** | ⚠️ `lib/cms/extensions/active_record/errors.rb` patches `ActiveModel::Errors` and is **untested**. Write its characterization test before this hop. | +| 6.1 → **7.0** | ⭐⭐⭐ **Hard** | Hotwire/Turbo, Import Maps, `rails-ujs` removed, classic autoloader **removed outright** | ❌ Not prepared. `jquery_ujs` at `application.js:5` and `page_editor.js:2`; `jquery-rails` pinned at 3.1. | +| 7.0 → 7.1 | ⭐⭐ Medium | Composite keys, async queries | ❌ Not assessed | +| 7.1 → **7.2** | ⭐⭐ Medium | Transaction-aware jobs, `show_exceptions` symbols, **Ruby 3.1+ required** | ⚠️ **Ruby 2.7.8 carries through 7.1 and stops here.** Plan the Ruby upgrade into this hop. | +| 7.2 → **8.0** | ⭐⭐⭐⭐ **Very Hard** | **Propshaft**, Solid gems, Kamal, multi-database config | ❌ Not prepared. Sprockets 3 + **EOL Compass**; `app/assets/stylesheets/cms/*.scss` needs a new pipeline. Not unit-testable — this hop depends on the Cucumber suite being healthy. | + +### The 6.0 Zeitwerk hop needs its own phase file + +It is the largest single item in the whole upgrade, and unlike everything else it cannot be reduced to a checklist in advance. `lib/cms/engine.rb` is 135 lines with a **7-line test containing 1 assertion**, and it: + +- pushes 6 directories onto `ActiveSupport::Dependencies.autoload_paths` — an API Zeitwerk does not have +- anchors an initializer `:after => 'action_controller.deprecated_routes'`, an initializer **that no longer exists** +- calls `routes_reloader.reload!` in `after_initialize` + +And the loaders around it are the classic Zeitwerk failure pattern: `lib/cms/behaviors.rb:32` and `lib/cms/concerns.rb:6` build class names from filenames with `File.basename(b, ".rb").camelize` and `constantize` them at load time, while `lib/browsercms.rb:36-67` does `ActiveRecord::Base.send(:include, ...)` at require time. Zeitwerk forbids this shape. + +**When you reach 5.2, write `phase-7-zeitwerk.md` before starting the hop.** Its inputs: [Phase 4](phase-4-characterization-tests.md)'s eager-load test output, a fresh `rails-60-patterns.yml` detection pass, and engine/autoload contract tests documenting which paths land where. See [`TEST_COVERAGE_ANALYSIS.md` §3.6](../../TEST_COVERAGE_ANALYSIS.md) and Phase 2d of its plan. + +--- + +## Exit criteria + +### Per hop + +| # | Criterion | How to verify | +|---|---|---| +| 1 | On the **latest patch** of the target series | `bundle list \| grep " rails "` against RubyGems | +| 2 | Full suite green — unit, spec, functional, features | CI passing | +| 3 | Coverage at or above the previous hop's number | Coverage artifact comparison | +| 4 | **Cucumber pass rate at or above Phase 0's baseline** | Compare against the committed number — this is the metric most likely to erode silently across nine hops | +| 5 | A detection pass was **actually run** for this version, and its findings are committed | The pattern-run output is in the repo. Reading the version guide is not a detection pass. | +| 6 | Gem compatibility check run; blockers have written decisions | Committed three-bucket report for this hop | +| 7 | CI config matches the Gemfile — no DRIFT | ci-sync verdict OK | +| 8 | `load_defaults` support range re-answered if this hop changed it | Statement updated or explicitly confirmed unchanged | +| 9 | Forward-looking deprecations captured, not fixed | Committed list carried forward | +| 10 | Deployed and stable before the next hop begins | — | + +### Overall + +| # | Criterion | How to verify | +|---|---|---| +| A | Rails **8.x** in `Gemfile.lock`, gemspec constraint updated | `bundle list \| grep " rails "` | +| B | All nine hops shipped **sequentially**, each deployed before the next | Git history shows nine distinct bumps, none skipping a version | +| C | Coverage ≥ the Phase 0 baseline, with branch coverage reported | Coverage artifact | +| D | Cucumber suite ≥ Phase 0's pass rate | Committed comparison | +| E | Ruby ≥ 3.2 (8.0's floor) | `cat .ruby-version` | +| F | Paperclip, Sprockets/Compass, Devise config, SimpleForm, `jquery-rails`, and the vendored `acts_as_list` fork all resolved | Each has a shipped decision — replaced, upgraded, or removed | +| G | Dual-boot scaffolding retired | `NextRails` absent from `app/`, `lib/`, `test/`, `spec/`; no `Gemfile.next`. Run the `upgrade-cleanup` plugin — **only when explicitly asked.** | +| H | No `require_dependency`, no `ActiveSupport::Dependencies.autoload_paths` | `grep -rn "require_dependency\|autoload_paths" app/ lib/` returns nothing | + +**Done means:** criterion B holds — nine sequential, individually-deployed hops — with C and D showing the test net never shrank along the way. + +--- + +## Explicitly not in this file + +- **Detailed work items for 6.x, 7.x, or 8.x.** They'd be fiction. Run the detection patterns when you get there. +- **Effort estimates.** Nine hops of unmeasured work; any number would be invented. +- **The Zeitwerk plan.** Genuinely needs its own file, written at 5.2 when the eager-load test has told you what you're dealing with. +- **The `cms`-side work.** Its blockers — the 11 unquoted `class_name:` constants (fatal at **5.2**), `override_csrf_encode_decode.rb`'s `exit(1)` guard that hard-fails boot unless `Rails.version == '4.2.11'`, and `browsercms_overrides.rb` at 28.8% coverage — are real and some are urgent, but they belong to that repo's plan. See [`TEST_COVERAGE_ANALYSIS.md` §5](../../TEST_COVERAGE_ANALYSIS.md). diff --git a/features/step_definitions/command_line_steps.rb b/features/step_definitions/command_line_steps.rb index 902df9631..263b67b5c 100644 --- a/features/step_definitions/command_line_steps.rb +++ b/features/step_definitions/command_line_steps.rb @@ -56,7 +56,7 @@ Given /^a BrowserCMS project named "([^"]*)" exists$/ do |project_name| - unless File.exists?("#{@scratch_dir}/#{project_name}") + unless File.exist?("#{@scratch_dir}/#{project_name}") old_dirs = @dirs @dirs = [@scratch_dir] create_bcms_project("petstore") @@ -117,10 +117,10 @@ end end -# Opposite of aruba step 'the file "x" should contain:' -When /^the file "([^"]*)" should not contain:$/ do |file, partial_content| - check_file_content(file, partial_content, false) -end +# The negated form used to be missing from aruba, so it was defined here. Aruba +# 0.14 provides it -- `(?:a|the) file(?: named)? "..." should (not )?contain:` -- +# and keeping this one made every use of it a Cucumber::Ambiguous abort, which +# took down the whole @cli run before a single result was reported. When /^the correct version of Rails should be added to the Gemfile$/ do check_file_content("#{project_name}/Gemfile", "gem 'rails', '#{Rails::VERSION::STRING}'", true) diff --git a/features/step_definitions/edit_page_templates_steps.rb b/features/step_definitions/edit_page_templates_steps.rb index fabf273dc..c37be66b1 100644 --- a/features/step_definitions/edit_page_templates_steps.rb +++ b/features/step_definitions/edit_page_templates_steps.rb @@ -1,7 +1,7 @@ Given /^the following page template exists:$/ do |table| @page_templates =[] table.hashes.each do |r| - @page_templates << FactoryGirl.create(:page_template, r) + @page_templates << FactoryBot.create(:page_template, r) end end diff --git a/features/step_definitions/more_custom_block_steps.rb b/features/step_definitions/more_custom_block_steps.rb index 81ef898c0..abaf7d715 100644 --- a/features/step_definitions/more_custom_block_steps.rb +++ b/features/step_definitions/more_custom_block_steps.rb @@ -6,8 +6,8 @@ end Given /^a product "([^"]*)" has been added to a page$/ do |name| - @product = FactoryGirl.create(:product, :name => name) - page = FactoryGirl.create(:public_page) + @product = FactoryBot.create(:product, :name => name) + page = FactoryBot.create(:public_page) page.add_content(@product) page.publish! end @@ -17,29 +17,29 @@ end Given /^html with "([^"]*)" has been added to a page$/ do |body| - @block = FactoryGirl.create(:html_block, :content => body) - page = FactoryGirl.create(:public_page) + @block = FactoryBot.create(:html_block, :content => body) + page = FactoryBot.create(:public_page) page.add_content(@block) page.publish! end Given /^portlet named "([^"]*)" has been added to a page$/ do |name| - @subject = FactoryGirl.create(:portlet, :name=>name) - page = FactoryGirl.create(:public_page) + @subject = FactoryBot.create(:portlet, :name=>name) + page = FactoryBot.create(:public_page) page.add_content(@subject) page.publish! end Given /^there is a page route for viewing a product$/ do - page = FactoryGirl.create(:public_page, :name=>"View Product", :path=>"/products/view") + page = FactoryBot.create(:public_page, :name=>"View Product", :path=>"/products/view") route = page.page_routes.build(:name=>"Product", :pattern=>"/products/view/:id", :code=>"# Non-blank code") route.save! - portlet_page = FactoryGirl.create(:public_page, :name=>"Product Catalog", :path=>"/products") + portlet_page = FactoryBot.create(:public_page, :name=>"Product Catalog", :path=>"/products") portlet_page.add_content(ProductCatalogPortlet.create!(:name=>"Catalog")) portlet_page.publish! - FactoryGirl.create(:product, name: "A Widget", slug: "/widget") + FactoryBot.create(:product, name: "A Widget", slug: "/widget") end When /^I view a page that lists products$/ do @@ -56,7 +56,7 @@ EXPECTED_PRODUCT_NAME = "About Us" Given(/^a product with a slug "(.*?)" exists$/) do |slug| - FactoryGirl.create(:product, name: EXPECTED_PRODUCT_NAME, slug: slug) + FactoryBot.create(:product, name: EXPECTED_PRODUCT_NAME, slug: slug) expect(Dummy::Product.with_slug(slug)).not_to eq(nil) end @@ -73,7 +73,7 @@ end Given(/^a product exists with two versions$/) do - @product = FactoryGirl.create(:product, name: "Version 1") + @product = FactoryBot.create(:product, name: "Version 1") @product.name = "Version 2" @product.save! expect(@product.versions.size).to eq(2) diff --git a/features/step_definitions/taxonomy_steps.rb b/features/step_definitions/taxonomy_steps.rb index a69bc3e30..476424ce1 100644 --- a/features/step_definitions/taxonomy_steps.rb +++ b/features/step_definitions/taxonomy_steps.rb @@ -1,6 +1,6 @@ Given /^the following Category Types exist:$/ do |table| table.hashes.each do |attributes| - FactoryGirl.create(:category_type, attributes) + FactoryBot.create(:category_type, attributes) end end @@ -8,7 +8,7 @@ type = Cms::CategoryType.named(category_type).first table.hashes.each do |attributes| attributes.merge!({:category_type_id => type.id }) - FactoryGirl.create(:category, attributes) + FactoryBot.create(:category, attributes) end end Then /^an image with id "([^"]*)" should exist$/ do |arg| diff --git a/features/support/env.rb b/features/support/env.rb index 58202b218..f15adbdc8 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -5,19 +5,19 @@ require File.expand_path("../../../test/dummy/config/environment.rb", __FILE__) ENV["RAILS_ROOT"] ||= File.dirname(__FILE__) + "../../../test/dummy" -require 'factory_girl' -# require 'factory_girl/step_definitions' +require 'factory_bot' +# require 'factory_bot/step_definitions' require File.join(File.dirname(__FILE__), '../../test/factories/factories') require File.join(File.dirname(__FILE__), '../../test/factories/attachable_factories') -World(FactoryGirl::Syntax::Methods) +World(FactoryBot::Syntax::Methods) require 'aruba/cucumber' -require 'capybara/poltergeist' - require 'capybara/dsl' -#Capybara.javascript_driver = :poltergeist -#Capybara.default_driver = :poltergeist +# poltergeist deleted: it drives PhantomJS, which was abandoned in 2018, and +# nothing here ever selected it. There are no @javascript scenarios and no +# Capybara driver is assigned anywhere, so the require and its two commented-out +# driver assignments were the whole of its presence. Phase 1, P1-7. Before do # Configure where Aruba generates files. @@ -82,6 +82,25 @@ # Load the seed data once at the start of the test run. # By doing this here, and using transaction strategy, we ensure the fastest possible tests. DatabaseCleaner.clean_with :truncation -silence_stream(STDOUT) do +# Seeding is noisy and the output tells you nothing. This used to call +# Kernel#silence_stream, which Rails 4.2 deprecated and 5.0 removed outright -- +# on the next bundle it raised NoMethodError right here and took the whole +# cucumber run down before a single scenario was collected. +# +# 5.0 keeps the implementation, but as a private instance method on +# ActiveSupport::Testing::Stream, a module meant to be mixed into test cases and +# one that does not exist at all on 4.2. So there is no name that resolves on +# both. It is six lines; define it locally and neither version matters. +def without_output_from(stream) + original = stream.dup + stream.reopen(IO::NULL) + stream.sync = true + yield +ensure + stream.reopen(original) + original.close +end + +without_output_from(STDOUT) do require File.join(File.dirname(__FILE__), '../../db/seeds.rb') end diff --git a/features/support/file_operations.rb b/features/support/file_operations.rb index 6cfa7c3b0..1b5a35143 100644 --- a/features/support/file_operations.rb +++ b/features/support/file_operations.rb @@ -3,7 +3,7 @@ require File.expand_path(File.join(__FILE__, "..", "..", "..", "test", "mock_file")) module FileOperations - # @deprecated Use FactoryGirl.create(:file_block) directly instead of this whereever possible. + # @deprecated Use FactoryBot.create(:file_block) directly instead of this whereever possible. # # Creates an 'uploaded' file block at a given relative path # @param [String] relative_file_path A relative file path (i.e. /test.txt) for the uploaded block. @@ -12,7 +12,7 @@ def create_file(relative_file_path, text="Test Content", section=nil) file_name, upload_file = text_file(relative_file_path, text) options = {:attachment_file => upload_file, :attachment_file_path => file_name} options[:parent] = section if section - FactoryGirl.create(:file_block, options) + FactoryBot.create(:file_block, options) end def text_file(relative_file_path, text) diff --git a/lib/cms/acts/content_page.rb b/lib/cms/acts/content_page.rb index d201e6c6c..40467e16c 100644 --- a/lib/cms/acts/content_page.rb +++ b/lib/cms/acts/content_page.rb @@ -70,7 +70,7 @@ module ClassMethods # # Params: # path - Should match the 'path' attribute for a given section. - # options - Hash of options that will be passed to the before_filter call. See before_filter for valid options. + # options - Hash of options that will be passed to the before_action call. See before_action for valid options. # # Example: # MyController < ApplicationController @@ -81,7 +81,7 @@ module ClassMethods def requires_permission_for_section(path, options={}) logger.warn "Setting path #{path}" @section_path = path - before_filter :check_access_to_section, options + before_action :check_access_to_section, options end def in_section diff --git a/lib/cms/admin_tab.rb b/lib/cms/admin_tab.rb index 21081965e..3acdc8f69 100644 --- a/lib/cms/admin_tab.rb +++ b/lib/cms/admin_tab.rb @@ -5,7 +5,7 @@ module AdminTab extend ActiveSupport::Concern included do - before_filter :set_menu_section + before_action :set_menu_section end def new_button_path diff --git a/lib/cms/attachments/attachment_serving.rb b/lib/cms/attachments/attachment_serving.rb index 27009465b..0ad5b26a0 100644 --- a/lib/cms/attachments/attachment_serving.rb +++ b/lib/cms/attachments/attachment_serving.rb @@ -41,7 +41,7 @@ def self.send_attachment(attachment, controller) style = controller.params[:style] style = "original" unless style path_to_file = attachment.path(style) - if File.exists?(path_to_file) + if File.exist?(path_to_file) Rails.logger.debug "Sending file #{path_to_file}" controller.send_file(path_to_file, :filename => attachment.file_name, diff --git a/lib/cms/authentication/controller.rb b/lib/cms/authentication/controller.rb index 0b7749cc3..2467fe792 100644 --- a/lib/cms/authentication/controller.rb +++ b/lib/cms/authentication/controller.rb @@ -42,7 +42,7 @@ module ClassMethods # end def check_permissions(*perms) opts = Hash === perms.last ? perms.pop : {} - before_filter(opts) do |controller| + before_action(opts) do |controller| raise Cms::Errors::AccessDenied unless controller.send(:current_user).able_to?(*perms) end end @@ -89,7 +89,7 @@ def store_location # Redirect to the URI stored by the most recent store_location call or # to the passed default. Set an appropriately modified - # after_filter :store_location, :only => [:index, :new, :show, :edit] + # after_action :store_location, :only => [:index, :new, :show, :edit] # for any controller you want to be bounce-backable. def redirect_back_or_default(default) redirect_to(session[:return_to] || default) diff --git a/lib/cms/behaviors/categorizing.rb b/lib/cms/behaviors/categorizing.rb index 8adf84bcd..46f018b8d 100644 --- a/lib/cms/behaviors/categorizing.rb +++ b/lib/cms/behaviors/categorizing.rb @@ -13,7 +13,10 @@ def belongs_to_category extend ClassMethods include InstanceMethods - belongs_to :category, :class_name => 'Cms::Category' + # Categorising is opt-in per instance -- nil is the normal state. required: false + # rather than optional: true because :optional is not valid on 4.2. See + # test/unit/belongs_to_optionality_test.rb. + belongs_to :category, :class_name => 'Cms::Category', :required => false scope :in_category, lambda{|cat| {:conditions => ["category_id = ?", cat.id]}} diff --git a/lib/cms/behaviors/connecting.rb b/lib/cms/behaviors/connecting.rb index 2203c1925..de7fff0bb 100644 --- a/lib/cms/behaviors/connecting.rb +++ b/lib/cms/behaviors/connecting.rb @@ -116,7 +116,7 @@ def update_connected_pages if p != updated_by_page #This just creates a new version of the page action = deleted? ? "Deleted" : "Edited" - p.update_attributes(:version_comment => "#{self.class.name.demodulize} ##{id} was #{action}", :publish_on_save => false) + p.update(:version_comment => "#{self.class.name.demodulize} ##{id} was #{action}", :publish_on_save => false) #The previous step will copy over a connector pointing to the previous version of this connectable #We need to change that to point at the new version of this connectable diff --git a/lib/cms/behaviors/dynamic_attributes.rb b/lib/cms/behaviors/dynamic_attributes.rb index c73195d44..070aa5716 100644 --- a/lib/cms/behaviors/dynamic_attributes.rb +++ b/lib/cms/behaviors/dynamic_attributes.rb @@ -165,7 +165,10 @@ def self.reloadable? #:nodoc: attribute_class = options[:class_name].constantize base_class = self.name.underscore.to_sym attribute_class.class_eval do - belongs_to base_class, :foreign_key => options[:base_foreign_key] + # Dynamic declaration -- like versioning.rb:115, grep will not find this in the + # shape the exit criteria expect. required: false rather than optional: true + # because :optional is not a valid belongs_to option on Rails 4.2. + belongs_to base_class, :foreign_key => options[:base_foreign_key], :required => false alias_method :base, base_class # For generic access #attr_accessible :name, :value, "#{base_class.to_s}_id".to_sym end diff --git a/lib/cms/behaviors/namespacing.rb b/lib/cms/behaviors/namespacing.rb index c0e72df90..6c0c8db1e 100644 --- a/lib/cms/behaviors/namespacing.rb +++ b/lib/cms/behaviors/namespacing.rb @@ -1,3 +1,14 @@ +# Do not delete, despite the empty module below. Two things depend on this file: +# +# 1. lib/cms/behaviors.rb:30 globs this directory and constantizes a module name out of +# every filename it finds, so Cms::Behaviors::Namespacing has to exist for as long as +# namespacing.rb does. (That half IS safe to remove -- the glob is file-driven, so the +# include goes with the file.) +# 2. Cms.table_prefix= is a public, deprecated API on the engine's surface. Deleting the +# file removes it without a deprecation cycle. Relocating it to lib/browsercms.rb is +# the tidier end state and about six lines -- but it moves a public method's definition +# site during a phase that is supposed to contain nothing but no-op renames, for no +# upgrade benefit. Revisit when the 6.0 autoloading work touches behaviors.rb's glob. module Cms # @deprecated To be removed in BrowserCMS 4.1 or later. diff --git a/lib/cms/behaviors/publishing.rb b/lib/cms/behaviors/publishing.rb index 798179dd3..5b1332042 100644 --- a/lib/cms/behaviors/publishing.rb +++ b/lib/cms/behaviors/publishing.rb @@ -129,7 +129,7 @@ def publish! # or the draft version is greater than the live version if !self.published? || d.version > self.version - d.update_attributes(:published => true) + d.update(:published => true) main_record = self.class.unscoped.where("#{self.class.primary_key} = ?", id).first self.class.versioned_columns.each do |column| diff --git a/lib/cms/behaviors/soft_deleting.rb b/lib/cms/behaviors/soft_deleting.rb index dc5ea5656..f87908218 100644 --- a/lib/cms/behaviors/soft_deleting.rb +++ b/lib/cms/behaviors/soft_deleting.rb @@ -70,9 +70,9 @@ module InstanceMethods def destroy run_callbacks :destroy do if self.class.publishable? - update_attributes(:deleted => true, :publish_on_save => true) + update(:deleted => true, :publish_on_save => true) else - update_attributes(:deleted => true) + update(:deleted => true) end end end diff --git a/lib/cms/behaviors/userstamping.rb b/lib/cms/behaviors/userstamping.rb index 70540f894..2ddf9ae90 100644 --- a/lib/cms/behaviors/userstamping.rb +++ b/lib/cms/behaviors/userstamping.rb @@ -13,8 +13,16 @@ def is_userstamped(options={}) extend ClassMethods include InstanceMethods - belongs_to :created_by, :class_name => "Cms::User" - belongs_to :updated_by, :class_name => "Cms::User" + # required: false, not optional: true -- :optional is not a valid belongs_to + # option on Rails 4.2 (ArgumentError at class-definition time); :required is + # valid on both, and 5.0 normalises it to optional = !required. + # + # These two are nil for anything created outside a request -- seeds, rake + # tasks, migrations, console. This behaviour is injected into every model + # that stamps users, here and in every downstream project, so a host app + # turning on load_defaults 5.0 would otherwise start rejecting all of them. + belongs_to :created_by, :class_name => "Cms::User", :required => false + belongs_to :updated_by, :class_name => "Cms::User", :required => false before_save :set_userstamps diff --git a/lib/cms/behaviors/versioning.rb b/lib/cms/behaviors/versioning.rb index 40bd34ea6..1949d4a01 100644 --- a/lib/cms/behaviors/versioning.rb +++ b/lib/cms/behaviors/versioning.rb @@ -112,7 +112,12 @@ def versioned_object version_class.versioned_class = self - version_class.belongs_to(name.demodulize.underscore.to_sym, :foreign_key => version_foreign_key, :class_name => name) + # required: false is passed into the options hash rather than written as a + # literal, so `grep -rn "required: false" app/ lib/` will not find this site in + # the shape the exit criteria expect. A version is routinely built before its + # parent is saved (build_new_version_and_add_to_versions_list_for_saving), so + # nil here is a normal intermediate state. + version_class.belongs_to(name.demodulize.underscore.to_sym, :foreign_key => version_foreign_key, :class_name => name, :required => false) version_class.is_userstamped if userstamped? @@ -227,7 +232,14 @@ def default_version_comment # 1. If the record is unchanged, no save is performed, but true is returned. (Skipping after_save callbacks) # 2. If its an update, a new version is created and that is saved. # 3. If new record, its version is set to 1, and its published if needed. - def create_or_update + # + # Rails 4.2 declares `def create_or_update` (persistence.rb:502) and Rails 5.0 + # declares `def create_or_update(*args, &block)` (persistence.rb:546). Accept and + # forward whatever the framework passes: on 4.2 nothing is passed, so *args is + # empty and this behaves exactly as the zero-arity version did. Without it, every + # save on Rails 5 raises ArgumentError -- 320 of the 323 unit errors Phase 1 + # measured. See docs/rails-upgrade/phase-1-gem-report.md, P1-2. + def create_or_update(*args, &block) logger.debug { "#{self.class}#create_or_update called. Published = #{!!publish_on_save}" } self.skip_callbacks = false unless different_from_last_draft? @@ -261,8 +273,21 @@ def build_new_version logger.debug { "New version of #{self.class}::Version is #{@new_version.attributes}" } end - def save!(perform_validations=true) - save(:validate => perform_validations) || raise(ActiveRecord::RecordNotSaved.new(errors.full_messages)) + # Rails never calls save! with a positional boolean. 4.2 calls it as + # save!(:validate => x) (has_many_association.rb:39) and 5.0 as + # save!(validate: x, &block) (collection_association.rb:510) -- so the old + # `perform_validations` parameter was being bound to a Hash, which is truthy, and + # the override then called save(validate: true) in precisely the path where the + # framework had asked for validations to be skipped. Wrong on 4.2 today, silently. + # + # On 5.0 there is a second half: collection_association.rb:501 passes a block into + # insert_record for create_or_update to yield after the insert, and the old + # signature dropped it before it could reach the (*args, &block) signature Phase 2 + # gave create_or_update directly below. Same defect as P1-2, one method up -- and + # the Phase 2 fix is what makes this gap reachable at all. + # See docs/rails-upgrade/phase-1-gem-report.md. + def save!(*args, &block) + save(*args, &block) || raise(ActiveRecord::RecordNotSaved.new(errors.full_messages)) end # Returns the most recently created Version for this class. Drafts are the most recent change from diff --git a/lib/cms/caching.rb b/lib/cms/caching.rb index e196ac9a6..5768721bf 100644 --- a/lib/cms/caching.rb +++ b/lib/cms/caching.rb @@ -39,7 +39,7 @@ def flush_caches end def flush_cache_directory(cache) - if File.exists?(cache) && not_public_directory?(cache) + if File.exist?(cache) && not_public_directory?(cache) FileUtils.rm_rf Dir.glob("#{cache}/*") Rails.logger.info "Flush cache in '#{cache}'" end diff --git a/lib/cms/commands/to_version400.rb b/lib/cms/commands/to_version400.rb index 8328b00c3..f57dab7c9 100644 --- a/lib/cms/commands/to_version400.rb +++ b/lib/cms/commands/to_version400.rb @@ -1,3 +1,8 @@ +# Do not delete. This looked dead to a grep over app/ lib/ test/ spec/ features/ config/, +# but bin/bcms -- a shipped executable (browsercms.gemspec:43) -- requires it at :11 and +# includes it into Cms::Install at :28. Removing the file breaks the `bcms` command at +# require time for every downstream user. generate_devise_configuration below is called +# by `bcms new`, `bcms demo`, `bcms module`, `bcms install` and `bcms upgrade`. module Cms module Commands module ToVersion400 diff --git a/lib/cms/content_filter.rb b/lib/cms/content_filter.rb index 36f3320aa..423200f6b 100644 --- a/lib/cms/content_filter.rb +++ b/lib/cms/content_filter.rb @@ -9,7 +9,13 @@ def filter(content) c = content.clone c.keys.each do |key| if(key != :content && key != "content") - c[key] = HTML::FullSanitizer.new.sanitize(c[key]).strip + # Rails::Html::FullSanitizer, not HTML::FullSanitizer: the latter comes from + # rails-deprecated_sanitizer, which is in the bundle only because + # rails-dom-testing 1.x depends on it -- and 1.x caps activesupport < 5.0. On + # Rails 5 it leaves the bundle and this line raises NameError. Both classes are + # rails-html-sanitizer's and produce identical output on 4.2, verified across + # nil/empty/non-string input. See phase-1-gem-report.md, P1-3. + c[key] = Rails::Html::FullSanitizer.new.sanitize(c[key]).strip end end c diff --git a/lib/cms/engine.rb b/lib/cms/engine.rb index 0bd4ce32a..d102d0e30 100644 --- a/lib/cms/engine.rb +++ b/lib/cms/engine.rb @@ -130,6 +130,39 @@ class Engine < Rails::Engine # Unsure why jquery isn't getting precompiled. app.config.assets.precompile += ['jquery'] + + # sprockets-rails 3 raises AssetNotPrecompiled for any asset reached through + # image_tag / asset_path that is not in config.assets.precompile. 2.3.3 (the 4.2 + # bundle) does not enforce that, which is why this has never been needed before. + # + # Its own default covers loose assets, but only the *host application's*: + # + # LOOSE_APP_ASSETS = lambda do |logical_path, filename| + # filename.start_with?(::Rails.root.join("app/assets").to_s) && ... + # + # (sprockets-rails-3.2.2/lib/sprockets/railtie.rb:85). An engine's app/assets is + # never under Rails.root, so every image and font this engine ships is undeclared. + # The first one to raise is cms/logo.png from layouts/cms/_main_menu.html.erb:5, + # and it is only the first -- the icon helpers build their paths at runtime + # (application_helper.rb:66, :70; file_blocks/render.html.erb:2), so the set is + # not enumerable by reading the views. + # + # So declare the class rather than the instances: the same rule sprockets-rails + # applies to the application, rooted at this engine instead. Enumerating filenames + # would book another discovery round at every later hop. + # + # This belongs here and not in test/dummy: a consuming application hits exactly + # this wall on 5.0, and a dummy-app fix does not travel to it. + # + # Note that app/assets/config/manifest.js -- the Rails 5+ idiom -- would be inert + # on this bundle. sprockets-rails only honours a manifest when sprockets 4 is + # loaded (railtie.rb:104-110, `if using_sprockets4?`), and both locks resolve + # sprockets 3.7.x. Revisit when the sprockets 4 hop makes it live. + engine_assets = "#{self.root}/app/assets" + app.config.assets.precompile += [lambda { |logical_path, filename| + filename.start_with?(engine_assets) && + !['.js', '.css', ''].include?(File.extname(logical_path)) + }] end end end diff --git a/lib/cms/extensions/active_record/connection_adapters/abstract/schema_dumper.rb b/lib/cms/extensions/active_record/connection_adapters/abstract/schema_dumper.rb new file mode 100644 index 000000000..0bbd87bee --- /dev/null +++ b/lib/cms/extensions/active_record/connection_adapters/abstract/schema_dumper.rb @@ -0,0 +1,24 @@ +require 'active_record' + +module ActiveRecord + module ConnectionAdapters + module ColumnDumper + + # Rails 4.2 builds each column's schema spec by mutating the option + # strings in place with String#insert. Ruby 2.7 returns a *frozen* string + # from true.inspect/false.inspect, and a boolean column's default is + # rendered with exactly that call, so any table with a boolean default + # (every content table has published/deleted/archived) blew up with + # "can't modify frozen String: \"false\"" and was skipped by the dumper, + # leaving db/schema.rb missing half its tables. + # + # Build a new string instead of mutating the existing one. + def column_spec(column, types) + spec = prepare_column_options(column, types) + (spec.keys - [:name, :type]).each { |k| spec[k] = "#{k}: #{spec[k]}" } + spec + end + + end + end +end diff --git a/lib/cms/extensions/big_decimal.rb b/lib/cms/extensions/big_decimal.rb new file mode 100644 index 000000000..35153d680 --- /dev/null +++ b/lib/cms/extensions/big_decimal.rb @@ -0,0 +1,11 @@ +require 'bigdecimal' + +# bigdecimal 2.0+ removed BigDecimal.new, but Rails 4.2 (bundled with this +# gem) still calls it in a few places (e.g. NumberHelper, the postgres +# decimal type). Restore it as a thin wrapper around Kernel#BigDecimal +# until Rails is upgraded. +class BigDecimal + def self.new(*args) + BigDecimal(*args) + end +end diff --git a/lib/generators/cms/content_block/content_block_generator.rb b/lib/generators/cms/content_block/content_block_generator.rb index 265a64c9a..5067cbdbf 100644 --- a/lib/generators/cms/content_block/content_block_generator.rb +++ b/lib/generators/cms/content_block/content_block_generator.rb @@ -23,7 +23,7 @@ def set_classpath def generate_controller application_controller = File.join('app/controllers', class_path, "application_controller.rb") - unless File.exists?(application_controller) + unless File.exist?(application_controller) template 'application_controller.rb.erb', application_controller end end diff --git a/lib/tasks/core_tasks.rake b/lib/tasks/core_tasks.rake index 0f2058327..b58260ce9 100644 --- a/lib/tasks/core_tasks.rake +++ b/lib/tasks/core_tasks.rake @@ -26,6 +26,66 @@ namespace :db do end end +namespace :coverage do + + # Not SimpleCov's own `minimum_coverage`: that is enforced in *every* test + # process's at_exit, so the units suite alone would fail the build for not + # meeting a threshold set for the whole chain. coverage/.last_run.json is + # written unconditionally, and the last suite to finish writes the fully + # merged figure, so one check after the chain is both correct and enough. + desc 'Fail if merged coverage fell below the recorded Phase 0 baseline' + task :check do + require 'json' + # 78.35, not the 75.82 Phase 0 recorded: the simplecov 0.12 -> 0.22 bump + # changed the instrument, not the tests. Measured across that bump on + # identical code, the covered-line count was identical at 4901 and only the + # denominator moved, 6460 -> 6255, because 0.18+ narrowed what counts as a + # relevant line. See docs/rails-upgrade/phase-2-harness-report.md. + threshold = Float(ENV.fetch('COVERAGE_MINIMUM', '78.35')) + path = 'coverage/.last_run.json' + abort "#{path} is missing -- did the suite run?" unless File.exist?(path) + + result = JSON.parse(File.read(path)).fetch('result') + + # simplecov < 0.18 wrote {"result": {"covered_percent": 75.82}}. From 0.18 that + # key is gone and the shape is {"result": {"line": 75.82}}, plus "branch" once + # enable_coverage :branch is on. Accept either, so the gate keeps working across + # the bump -- and abort on neither, rather than comparing nil to a Float. + actual = result['line'] || result['covered_percent'] + abort "#{path} has no line-coverage key (got #{result.keys.inspect})" if actual.nil? + + # Phase 3 sets the branch floor Phase 2 deferred for want of a measured number. + # 70.83 is that number: a full 4.2 chain on a *cleared* resultset, after the Phase 3 + # diff landed. Phase 2 reported 70.79 on the same instrument, so the phase moved + # branch coverage +0.04 and this records where it now stands. + # + # Set at the measured value, exactly as COVERAGE_MINIMUM was. That leaves no slack, + # which is the point -- a floor with headroom silently absorbs the first regression. + # If it turns out to flap, lower it once with a reason in the commit rather than + # padding it pre-emptively. + # + # Clear coverage/.resultset.json before trusting either number. The five suites merge + # through it with a 3600s timeout and both bundles use the same suite names, so a + # partial or cross-bundle run leaves entries that shift the merged percentage. + branch_threshold = Float(ENV.fetch('COVERAGE_MINIMUM_BRANCH', '70.83')) + branch = result['branch'] + + # Print both figures before aborting, so a run that fails one gate still tells you + # where the other stands. + puts format('Coverage %.2f%% (baseline %.2f%%)', actual, threshold) + puts format('Branch coverage %.2f%% (baseline %.2f%%)', branch, branch_threshold) if branch + + failures = [] + if actual < threshold + failures << format('Coverage %.2f%% is below the %.2f%% baseline.', actual, threshold) + end + if branch && branch < branch_threshold + failures << format('Branch coverage %.2f%% is below the %.2f%% baseline.', branch, branch_threshold) + end + abort failures.join("\n") unless failures.empty? + end +end + # These are tasks for the core browsercms project, and shouldn't be bundled into the distributable gem namespace :project do @@ -48,7 +108,7 @@ namespace :project do #end task :ensure_db_exists do - unless File.exists?("test/dummy/config/database.yml") + unless File.exist?("test/dummy/config/database.yml") fail("Need to create a database.yml file before running tests. Run:\n $ rake project:setup[database] to create a sample database.yml for the project.") end end diff --git a/lib/templates/active_record/model/model.rb b/lib/templates/active_record/model/model.rb index 411365191..8b591385f 100644 --- a/lib/templates/active_record/model/model.rb +++ b/lib/templates/active_record/model/model.rb @@ -1,3 +1,17 @@ +<%# + NOTE (rails upgrade, Phase 3): the bare belongs_to below is the 30th site of the + belongs_to audit, one `rails generate` downstream. It emits an unqualified declaration + into every model scaffolded in every consuming project, which is the same failure mode + the 29 sites in app/ and lib/ were audited for: a host application on + `load_defaults 5.0` gets required-by-default on an association the generator had no way + to judge. + + Deliberately NOT fixed here. The template cannot know whether nil is legitimate for a + generated attribute, so it cannot just gain `required: false` -- and the right spelling + depends on which Rails version the generated code targets, which Phase 5 settles. + Note that `optional: true` would be wrong for a 4.2 target: it is not a valid + belongs_to option there and raises ArgumentError at class-definition time. +-%> <% module_namespacing do -%> class <%= class_name %> < <%= parent_class_name.classify %> <% attributes.select {|attr| attr.reference? }.each do |attribute| -%> diff --git a/script/rails_blockers.rb b/script/rails_blockers.rb new file mode 100755 index 000000000..482603724 --- /dev/null +++ b/script/rails_blockers.rb @@ -0,0 +1,76 @@ +#!/usr/bin/env ruby +# +# Which locked gems declare a Rails-component requirement that excludes the +# target Rails version? +# +# Answers the *resolution* half of the gem-compatibility question offline: no +# resolver, no network, no bundle install. Every gem records its dependency +# constraints in Gemfile.lock, so the set of gems that cannot coexist with a +# given Rails version is already sitting on disk. +# +# It is blind to the other half -- a gem that resolves cleanly and then raises +# at runtime because it calls something that no longer exists. That is what the +# boot smoke test is for. See docs/rails-upgrade/phase-1-implementation-plan.md. +# +# ruby script/rails_blockers.rb # target 5.0.0 +# TARGET=5.1.0 ruby script/rails_blockers.rb # next hop +# LOCKFILE=Gemfile.next.lock ruby script/rails_blockers.rb +# +# Exits 0 when nothing blocks, 1 otherwise, so it can gate a CI step. + +require "rubygems" + +TARGET = Gem::Version.new(ENV.fetch("TARGET", "5.0.0")) +LOCKFILE = ENV.fetch("LOCKFILE", "Gemfile.lock") + +# The components a gem might constrain. +COMPONENTS = %w[ + rails railties activesupport actionpack activerecord activemodel actionview +].freeze + +# Rails' own gems pin each other exactly (activesupport (= 4.2.11.3)), so they +# always "block" and never carry information. Skip them as subjects; still read +# them as dependencies of anything else. +CORE = (COMPONENTS + %w[actionmailer activejob actioncable activestorage]).freeze + +abort "#{LOCKFILE} not found (run from the repository root)" unless File.exist?(LOCKFILE) + +subject = nil +blockers = {} + +File.readlines(LOCKFILE).each do |line| + case line + when /^ ([a-zA-Z0-9_.\-]+) \(([^)]+)\)$/ # a gem being specified + subject = [Regexp.last_match(1), Regexp.last_match(2)] + when /^ (#{COMPONENTS.join('|')}) \((.+)\)$/ # one of its dependencies + next if subject.nil? || CORE.include?(subject.first) + + name = Regexp.last_match(1) + constraint = Regexp.last_match(2) + requirement = + begin + Gem::Requirement.new(constraint.split(",").map(&:strip)) + rescue StandardError + next + end + + next if requirement.satisfied_by?(TARGET) + + (blockers[subject] ||= []) << "#{name} (#{constraint})" + end +end + +puts "#{LOCKFILE}: gems whose Rails requirement excludes #{TARGET}" +puts + +if blockers.empty? + puts " none" +else + blockers.sort.each do |(name, version), deps| + puts format(" %-22s %-10s %s", name, version, deps.join("; ")) + end + puts + puts " #{blockers.size} blocker(s)" +end + +exit(blockers.empty? ? 0 : 1) diff --git a/spec/dummy/product_mini_test.rb b/spec/dummy/product_mini_test.rb index 6d789f892..91ccf99dc 100644 --- a/spec/dummy/product_mini_test.rb +++ b/spec/dummy/product_mini_test.rb @@ -3,7 +3,7 @@ describe Dummy::Product do describe 'Factory' do it 'should ' do - p = FactoryGirl.create(:product) + p = FactoryBot.create(:product) p.wont_be_nil end end diff --git a/spec/minitest_helper.rb b/spec/minitest_helper.rb index e53c609ba..a9907419b 100644 --- a/spec/minitest_helper.rb +++ b/spec/minitest_helper.rb @@ -4,15 +4,22 @@ require File.expand_path("../../test/dummy/config/environment.rb", __FILE__) require "rails/test_help" require "minitest/spec" -require "minitest/unit" -require "mocha/mini_test" +# mocha 1.x's minitest adapter assigns ::MiniTest::Assertion +# (mocha/integration/mini_test/adapter.rb:26). That camelCase alias is defined +# in exactly one place -- the deprecated legacy shim file this phase stopped +# requiring -- and +# which also drags in the deprecated Minitest::Unit::TestCase shim. Alias the +# one constant mocha needs instead of requiring the whole deprecated file. +# Delete this when mocha goes to 2.x, which dropped the legacy reference. +MiniTest = Minitest unless defined?(MiniTest) +require 'mocha/minitest' Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } require File.expand_path("../../test/factories/factories", __FILE__) require File.expand_path("../../test/factories/attachable_factories", __FILE__) require 'minitest/reporters' -MiniTest::Reporters.use! +Minitest::Reporters.use! require 'database_cleaner' DatabaseCleaner.strategy = :truncation @@ -21,6 +28,6 @@ class Minitest::Spec after :each do DatabaseCleaner.clean end - include FactoryGirl::Syntax::Methods + include FactoryBot::Syntax::Methods include FactoryHelpers end diff --git a/test/custom_assertions.rb b/test/custom_assertions.rb index 4c502f81c..af13859a1 100644 --- a/test/custom_assertions.rb +++ b/test/custom_assertions.rb @@ -16,7 +16,7 @@ def assert_column_does_not_exist(table_name, column_name) #end def assert_file_exists(file_name, message=nil) - assert File.exists?(file_name), + assert File.exist?(file_name), (message || "Expected File '#{file_name}' to exist, but it does not") end diff --git a/test/dummy/config/boot.rb b/test/dummy/config/boot.rb index 1e5256736..0cfa76f7f 100644 --- a/test/dummy/config/boot.rb +++ b/test/dummy/config/boot.rb @@ -1,7 +1,19 @@ # Loads the Gemfile from the root of the project itself, rather than a typical rails app. require 'rubygems' -gemfile = File.expand_path('../../../../Gemfile', __FILE__) + +# Dual-boot: honour an explicitly-provided BUNDLE_GEMFILE (Gemfile.next) and +# only fall back to the engine's Gemfile when nothing was set. +# +# This used to assign ENV['BUNDLE_GEMFILE'] unconditionally, which quietly broke +# dual-booting the *test suite*. Rake::TestTask and cucumber each spawn a fresh +# Ruby process, and in a fresh process this file runs before Bundler is set up -- +# so the hardcoded path won and every suite ran on 4.2 no matter what +# BUNDLE_GEMFILE said on the command line. It only looked like it worked from +# `bundle exec ruby -e ...`, where Bundler is already loaded by the time we get +# here. A Gemfile.next CI job on top of that would have reported a false green. +default_gemfile = File.expand_path('../../../../Gemfile', __FILE__) +gemfile = ENV['BUNDLE_GEMFILE'] ? File.expand_path(ENV['BUNDLE_GEMFILE']) : default_gemfile if File.exist?(gemfile) ENV['BUNDLE_GEMFILE'] = gemfile @@ -9,4 +21,10 @@ Bundler.setup end -$:.unshift File.expand_path('../../../../lib', __FILE__) \ No newline at end of file +$:.unshift File.expand_path('../../../../lib', __FILE__) + +# Rails 4.2's active_support/core_ext/object/duplicable.rb calls the removed +# BigDecimal.new at load time, before Bundler.require (and thus browsercms's +# own extensions) ever runs. Load the patch explicitly, ahead of `require +# 'rails/all'` in application.rb. +require 'cms/extensions/big_decimal' \ No newline at end of file diff --git a/test/dummy/config/environments/production.rb b/test/dummy/config/environments/production.rb index 724b874f5..18ca701be 100644 --- a/test/dummy/config/environments/production.rb +++ b/test/dummy/config/environments/production.rb @@ -16,8 +16,12 @@ # Allows the CMS to use a single domain (with no caching or redirects). config.cms.use_single_domain = true - # Disable Rails's static asset server (Apache or nginx will already do this) - config.serve_static_assets = true # We are in 'faux' production mode, so we want Rails to serve the assets. + # The static-asset config key that used to sit here was deleted rather than + # renamed -- it has no cross-version spelling (see test.rb). This is the one + # place serving static assets is load-bearing rather than incidental, because + # the dummy app runs in 'faux' production mode; both 4.2 and 5.0 default it + # to true and nothing in the chain sets it false, so deleting the line + # preserves the behaviour. # Compress JavaScripts and CSS config.assets.compress = true diff --git a/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb index 661e5f444..6b2ef8631 100644 --- a/test/dummy/config/environments/test.rb +++ b/test/dummy/config/environments/test.rb @@ -7,9 +7,12 @@ # and recreated between test runs. Don't rely on the data there! config.cache_classes = true - # Configure static asset server for tests with Cache-Control for performance - config.serve_static_assets = true - config.static_cache_control = "public, max-age=3600" + # The two static-asset config keys that used to sit here were deleted, not + # renamed. 4.2 spells them serve_static_files / static_cache_control; 5.0 + # spells them public_file_server.enabled / .headers and removed the older + # names, so no spelling works on both. But both frameworks already default + # the file server to true, so those lines only ever set the default -- and a + # max-age header on static assets in the *test* environment buys nothing. config.eager_load = false diff --git a/test/dummy/test/controllers/design_controller_test.rb b/test/dummy/test/controllers/design_controller_test.rb index 65019b820..2d4cf9900 100644 --- a/test/dummy/test/controllers/design_controller_test.rb +++ b/test/dummy/test/controllers/design_controller_test.rb @@ -1,8 +1,11 @@ require "test_helper" class DesignControllerTest < ActionController::TestCase + # DesignController#show renders the template named by params[:page], and the + # route (/design/:page) always supplies one. Calling it bare rendered nil, + # which fell through to a design/show template that has never existed. test "should get show" do - get :show + get :show, params: {page: "dashboard"} assert_response :success end diff --git a/test/dummy/test/unit/portlets/find_category_portlet_test.rb b/test/dummy/test/unit/portlets/find_category_portlet_test.rb index 8bcf36ee3..86419a4ef 100644 --- a/test/dummy/test/unit/portlets/find_category_portlet_test.rb +++ b/test/dummy/test/unit/portlets/find_category_portlet_test.rb @@ -1,4 +1,4 @@ -require File.join(File.dirname(__FILE__), '/../../test_helper') +require "test_helper" class FindCategoryTest < ActiveSupport::TestCase diff --git a/test/dummy/test/unit/portlets/uses_helper_portlet_test.rb b/test/dummy/test/unit/portlets/uses_helper_portlet_test.rb index 426b482ac..2fa5e1419 100644 --- a/test/dummy/test/unit/portlets/uses_helper_portlet_test.rb +++ b/test/dummy/test/unit/portlets/uses_helper_portlet_test.rb @@ -1,4 +1,4 @@ -require File.join(File.dirname(__FILE__), '/../../test_helper') +require "test_helper" class UsesHelperTest < ActiveSupport::TestCase diff --git a/test/factories/attachable_factories.rb b/test/factories/attachable_factories.rb index 7a6194c26..1d61df629 100644 --- a/test/factories/attachable_factories.rb +++ b/test/factories/attachable_factories.rb @@ -17,7 +17,7 @@ class HasThumbnail < ActiveRecord::Base has_attachment :document, :styles => {:thumbnail => "50x50"} end -FactoryGirl.define do +FactoryBot.define do # Duplicates :file_block factory :versioned_attachable, :class => VersionedAttachable do |m| @@ -33,7 +33,7 @@ class HasThumbnail < ActiveRecord::Base opts[:data_file_path] = evaluator.attachment_file_path if evaluator.attachment_file_path f.attachments.build(opts) } - m.publish_on_save true + m.publish_on_save { true } end factory :has_many_attachments, :class => HasManyAttachments do |m| @@ -49,40 +49,40 @@ class HasThumbnail < ActiveRecord::Base opts[:data_file_path] = evaluator.attachment_file_path if evaluator.attachment_file_path f.attachments.build(opts) } - m.publish_on_save true + m.publish_on_save { true } end factory :has_many_documents, :class => Cms::Attachment do |m| - m.attachment_name "documents" - m.attachable_type "HasManyAttachments" + m.attachment_name { "documents" } + m.attachable_type { "HasManyAttachments" } m.data { mock_file } m.parent { find_or_create_root_section } - m.attachable_version 1 - m.publish_on_save true + m.attachable_version { 1 } + m.publish_on_save { true } end factory :attachment_document, :class => Cms::Attachment do |m| - m.attachment_name "document" - m.attachable_type "VersionedAttachable" + m.attachment_name { "document" } + m.attachable_type { "VersionedAttachable" } m.data { mock_file } m.parent { find_or_create_root_section } - m.publish_on_save true + m.publish_on_save { true } end factory :thumbnail_attachment, :class => Cms::Attachment do |m| - m.attachment_name "document" - m.attachable_type "HasThumbnail" + m.attachment_name { "document" } + m.attachable_type { "HasThumbnail" } m.data { mock_file } m.parent { find_or_create_root_section } - m.publish_on_save true + m.publish_on_save { true } end factory :catalog_attachment, :class => Cms::Attachment do |m| - m.attachment_name "photos" - m.attachable_type "Dummy::Catalog" + m.attachment_name { "photos" } + m.attachable_type { "Dummy::Catalog" } m.data { mock_text_file } m.parent { find_or_create_root_section } - m.publish_on_save true + m.publish_on_save { true } end end diff --git a/test/factories/factories.rb b/test/factories/factories.rb index 8275686f4..4584b5e73 100644 --- a/test/factories/factories.rb +++ b/test/factories/factories.rb @@ -1,16 +1,26 @@ require File.join(File.dirname(__FILE__), '../support/factory_helpers') include FactoryHelpers -FactoryGirl.define do +# The predecessor gem, at 4.7, always used the :create strategy for +# `association`, whatever +# strategy the parent factory was run with. factory_bot 5.0 flipped the default +# to use_parent_strategy = true, so `build(:page)` started *building* its +# section instead of creating it -- an unsaved Section, which SectionNode#section= +# hands to ancestry's parent=, which raises "No child ancestry for new record". +# 21 unit errors, on both bundles. Restore the 4.7 behaviour: this phase is a +# port, and changing association strategy is a behaviour change, not a rename. +FactoryBot.use_parent_strategy = false + +FactoryBot.define do factory :category, :class => Cms::Category do |m| m.association :category_type m.sequence(:name) { |n| "TestCategory#{n}" } end factory :root_section, :class => Cms::Section do |m| - m.name "My Site" - m.path "/" - m.root true + m.name { "My Site" } + m.path { "/" } + m.root { true } m.groups { Cms::Group.all } end @@ -20,10 +30,10 @@ factory :connector, :class => Cms::Connector do |m| m.association :page - m.page_version 1 - m.container "main" + m.page_version { 1 } + m.container { "main" } m.association :connectable, :factory => :html_block - m.connectable_version 1 + m.connectable_version { 1 } end # Duplication between :file_block and :image_block @@ -42,7 +52,7 @@ ) f.attachments.first.attachable = f } - m.publish_on_save true + m.publish_on_save { true } end factory :file_block, :class => Cms::FileBlock do |m| @@ -57,7 +67,7 @@ f.attachments.first.attachable = f } - m.publish_on_save true + m.publish_on_save { true } end @@ -83,12 +93,12 @@ end factory :cms_group_type, :class => Cms::GroupType do |m| - m.name "CMS User" - m.cms_access true + m.name { "CMS User" } + m.cms_access { true } end factory :portlet, :class => DynamicPortlet do |m| - m.name "Sample Portlet" + m.name { "Sample Portlet" } end # Portlets happen to be Non-versioned right now, but this abstracts that in case it changes later. @@ -96,29 +106,29 @@ end factory :html_block, :class => Cms::HtmlBlock do |m| - m.name "About Us" - m.content "

About Us

\n

Lorem ipsum dolor sit amet...

" + m.name { "About Us" } + m.content { "

About Us

\n

Lorem ipsum dolor sit amet...

" } end factory :link, :class => Cms::Link do |m| m.association :section m.sequence(:name) { |n| "Link #{n}" } - m.publish_on_save true + m.publish_on_save { true } end factory :page, :class => Cms::Page do |m| m.sequence(:name) { |n| "Page #{n}" } m.path { |a| "/#{a.name.gsub(/\s/, '_').downcase}" } - m.template_file_name "default.html.erb" + m.template_file_name { "default.html.erb" } m.association :section end factory :page_partial, :class => Cms::PagePartial do |m| m.sequence(:name) { |n| "_page_partial_#{n}" } - m.format "html" - m.handler "erb" - m.body "Nonblank" + m.format { "html" } + m.handler { "erb" } + m.body { "Nonblank" } end factory :page_route, :class => Cms::PageRoute do |m| @@ -129,9 +139,9 @@ factory :page_template, :class => Cms::PageTemplate do |m| m.sequence(:name) { |n| "page_template_#{n}" } - m.format "html" - m.handler "erb" - m.body %q{ + m.format { "html" } + m.handler { "erb" } + m.body { %q{ <%= page_title %> @@ -141,19 +151,24 @@ <body> <%= container :main %> </body> -</html>} +</html>} } end Cms::Authoring::PERMISSIONS.each do |p| perm_name = "#{p.to_s}_permission".to_sym factory perm_name, :class => Cms::Permission do |m| - m.name p + # `p` is the block parameter of the enclosing PERMISSIONS.each, not + # Kernel#p: inside an attribute block `self` is the evaluator, so the bare + # name resolves to the local only because the local is in lexical scope. + # Each iteration closes over its own binding, so every permission factory + # still gets its own name. + m.name { p } end end factory :section, :class => Cms::Section do |m| - m.name "Test" - m.path "/test" + m.name { "Test" } + m.path { "/test" } m.parent { find_or_create_root_section } end @@ -162,14 +177,14 @@ factory :public_page, :class => Cms::Page do |m| m.sequence(:name) { |n| "Page #{n}" } m.path { |a| "/#{a.name.gsub(/\s/, '_').downcase}" } - m.template_file_name "default.html.erb" + m.template_file_name { "default.html.erb" } m.association :section, :factory => :public_section - m.publish_on_save true + m.publish_on_save { true } end factory :public_section, :class => Cms::Section do |m| - m.name "Test" - m.path "/test" + m.name { "Test" } + m.path { "/test" } m.parent { find_or_create_root_section } m.after(:create) { |section| section.allow_groups = :all @@ -177,13 +192,13 @@ end factory :protected_section, :class => Cms::Section do |m| - m.name "Protected Section" - m.path "/protected-section" + m.name { "Protected Section" } + m.path { "/protected-section" } m.parent { find_or_create_root_section } m.after(:create) { |protected_section| - secret_group = FactoryGirl.create(:group, :name => "Secret") + secret_group = FactoryBot.create(:group, :name => "Secret") secret_group.sections << protected_section - privileged_user = FactoryGirl.create(:user, :login => "privileged") + privileged_user = FactoryBot.create(:user, :login => "privileged") privileged_user.groups << secret_group } @@ -205,11 +220,11 @@ end factory :user, :class => Cms::User do |m| - m.first_name "Test" - m.last_name "User" + m.first_name { "Test" } + m.last_name { "User" } m.sequence(:login) { |n| "test_#{n}" } m.email { |a| "#{a.login}@example.com" } - m.password "password" + m.password { "password" } m.password_confirmation { |a| a.password } end @@ -228,7 +243,7 @@ factory :cms_admin, :parent => :user do |m| m.after(:create) { |user| - group = FactoryGirl.create(:group, :group_type => FactoryGirl.create(:group_type, :cms_access => true)) + group = FactoryBot.create(:group, :group_type => FactoryBot.create(:group_type, :cms_access => true)) Cms::Authoring::PERMISSIONS.each do |p| group.permissions << create_or_find_permission_named(p) end @@ -238,7 +253,7 @@ factory :content_editor, :parent => :user do |m| m.after(:create) { |user| - group = FactoryGirl.create(:group, :group_type => FactoryGirl.create(:group_type, :cms_access => true)) + group = FactoryBot.create(:group, :group_type => FactoryBot.create(:group_type, :cms_access => true)) Cms::Authoring::EDITOR_PERMISSIONS.each do |p| group.permissions << create_or_find_permission_named(p) end @@ -249,23 +264,23 @@ # This is just for CMS testing factory :portlet_with_helper, :class => UsesHelperPortlet do |portlet| transient do - page_path "/random" + page_path { "/random" } end - portlet.name "ProductCatalog" + portlet.name { "ProductCatalog" } portlet.after(:create) do |content, evaluator| - page = FactoryGirl.create(:public_page, path: evaluator.page_path) + page = FactoryBot.create(:public_page, path: evaluator.page_path) page.add_content(content) page.publish! end end factory :product, :class => Dummy::Product do |product| - product.name "Product" + product.name { "Product" } product.sequence(:slug) { |n| "/product-#{n}" } end factory :form, :class => Cms::Form do |form| - form.name "Form" + form.name { "Form" } form.sequence(:slug) { |n| "/form-#{n}" } end end diff --git a/test/functional/cms/content_block_controller_test.rb b/test/functional/cms/content_block_controller_test.rb index 56ddeb1d8..b6ddb8624 100644 --- a/test/functional/cms/content_block_controller_test.rb +++ b/test/functional/cms/content_block_controller_test.rb @@ -45,7 +45,7 @@ def expect_success test "GET show allows any user" do expect_success - get :show, :id => 5 + get :show, params: {:id => 5} end test "GET new allows any user" do @@ -60,12 +60,12 @@ def expect_success test "GET version allows any user" do expect_success - get :version, :id => 5, :version => 3 + get :version, params: {:id => 5, :version => 3} end test "GET versions allows any user" do expect_success - get :versions, :id => 5 + get :versions, params: {:id => 5} end end diff --git a/test/functional/cms/content_controller_test.rb b/test/functional/cms/content_controller_test.rb index c9985f64a..79b67dcfe 100644 --- a/test/functional/cms/content_controller_test.rb +++ b/test/functional/cms/content_controller_test.rb @@ -14,14 +14,14 @@ def setup def test_show_another_page @page = create(:page, :section => root_section, :path => "/about", :name => "Test About", :template_file_name => "default.html.erb", :publish_on_save => true) - get :show, :path => "about" + get :show, params: {:path => "about"} assert_select "title", "Test About" end def test_page_not_found_to_cms_admin skip 'Page routing is not working correctly' login_as_cms_admin - get :show, :path => "foo" + get :show, params: {:path => "foo"} assert_response :not_found assert_select "title", "Page Not Found" assert_select "p", "There is no page at /foo" @@ -32,7 +32,7 @@ def test_show_protected_page_to_privileged_user login_as @privileged_user - get :show, :path => "secret" + get :show, params: {:path => "secret"} assert_response :success assert_select "title", "Shhh... It is a Secret" end @@ -42,7 +42,7 @@ def test_show_archived_page_to_user create_archived_page login_as_cms_admin - get :show, :path => "archived" + get :show, params: {:path => "archived"} assert_response :success assert_select "title", "Archived" end @@ -61,7 +61,7 @@ def test_show_page_route @page.publish! skip "Page routes are not working as expected" - get :show_page_route, :foo => "42", :_page_route_id => @page_route.id + get :show_page_route, params: {:foo => "42", :_page_route_id => @page_route.id} assert_response :success assert_select "h1", "42" end @@ -69,7 +69,7 @@ def test_show_page_route def test_show_page_with_content skip 'Routing isnt working correctly' create_page_with_content - get :show, :path => "page_with_content" + get :show, params: {:path => "page_with_content"} assert_response :success assert_select "h3", "TEST" end @@ -182,7 +182,7 @@ def teardown def test_guest_user_views_page_on_public_site @request.host = "mysite.com" - get :show, :path => "page" + get :show, params: {:path => "page"} assert_response :success assert_select "title", "Test Page" end @@ -192,7 +192,7 @@ def test_registered_user_views_page_on_public_site login_as @registered_user @request.host = "mysite.com" - get :show, :path => "page" + get :show, params: {:path => "page"} assert_response :success assert_select "title", "Test Page" @@ -203,7 +203,7 @@ def test_cms_user_views_page_on_public_site @request.session[:page_mode] = "edit" @request.host = "mysite.com" - get :show, :path => "page" + get :show, params: {:path => "page"} assert_response :success assert_select "title", "Test Page" @@ -215,7 +215,7 @@ def test_cms_user_views_page_on_cms_site_with_caching @request.session[:page_mode] = "edit" @request.host = "cms.mysite.com" - get :show, :path => "page", :use_route => false + get :show, params: {:path => "page", :use_route => false} assert_response :success assert_select "title", "Test Page" @@ -251,14 +251,14 @@ def setup def test_guest_user_views_page_on_public_site @request.host = "mysite.com" - get :show, :path => "page" + get :show, params: {:path => "page"} assert_response :success assert_select "title", "Test Page" end def test_guest_user_views_page_on_cms_site @request.host = "mysite.com" - get :show, :path => "page" + get :show, params: {:path => "page"} assert_response :success assert_select "title", "Test Page" end @@ -267,7 +267,7 @@ def test_registered_user_views_page_on_public_site login_as @registered_user @request.host = "mysite.com" - get :show, :path => "page" + get :show, params: {:path => "page"} assert_response :success assert_select "title", "Test Page" @@ -277,7 +277,7 @@ def test_registered_user_views_page_on_cms_site login_as @registered_user @request.host = "mysite.com" - get :show, :path => "page" + get :show, params: {:path => "page"} assert_response :success assert_select "title", "Test Page" @@ -288,7 +288,7 @@ def test_cms_user_views_page_on_public_site @request.session[:page_mode] = "edit" @request.host = "mysite.com" - get :show, :path => "page" + get :show, params: {:path => "page"} assert_response :success assert_select "title", "Test Page" @@ -299,7 +299,7 @@ def test_cms_user_views_page_on_cms_site @request.session[:page_mode] = "edit" @request.host = "cms.mysite.com" - get :show, :path => "page", :use_route => false + get :show, params: {:path => "page", :use_route => false} assert_response :success assert_select "title", "Test Page" diff --git a/test/functional/cms/file_blocks_controller_test.rb b/test/functional/cms/file_blocks_controller_test.rb index e539a9ce0..21c7dabf6 100644 --- a/test/functional/cms/file_blocks_controller_test.rb +++ b/test/functional/cms/file_blocks_controller_test.rb @@ -19,15 +19,15 @@ def test_file_block_search :publish_on_save => true) @foo_section = create(:section, :name => "Foo", :parent => root_section) - get :index, :section_id => root_section.id + get :index, params: {:section_id => root_section.id} assert_response :success assert_select "td", "Test File" - get :index, :section_id => @foo_section.id + get :index, params: {:section_id => @foo_section.id} assert_response :success assert_select "td", {:count => 0, :text => "Test File"} - get :index, :section_id => 'all' + get :index, params: {:section_id => 'all'} assert_response :success assert_select "td", "Test File" end diff --git a/test/functional/cms/html_blocks_controller_test.rb b/test/functional/cms/html_blocks_controller_test.rb index fd04f0a95..0b9a33da7 100644 --- a/test/functional/cms/html_blocks_controller_test.rb +++ b/test/functional/cms/html_blocks_controller_test.rb @@ -17,7 +17,7 @@ def test_new def test_add_to_page @page = create(:page, :path => "/test", :section => root_section) - get :new, :html_block => {:connect_to_page_id => @page.id, :connect_to_container => "test"} + get :new, params: {:html_block => {:connect_to_page_id => @page.id, :connect_to_container => "test"}} assert_response :success assert_select "input[name=?][value=?]", "html_block[connect_to_page_id]", @page.id.to_s assert_select "input[name=?][value=?]", "html_block[connect_to_container]", "test" @@ -27,8 +27,8 @@ def test_creating_a_block_that_should_be_connected_to_a_page @page = create(:page, :path => "/test", :section => root_section) html_block_count = HtmlBlock.count - post :create, :html_block => FactoryGirl.attributes_for(:html_block).merge( - :connect_to_page_id => @page.id, :connect_to_container => "test") + post :create, params: {:html_block => FactoryBot.attributes_for(:html_block).merge( + :connect_to_page_id => @page.id, :connect_to_container => "test")} assert_incremented html_block_count, HtmlBlock.count assert_equal "test", @page.reload.connectors.first.container @@ -37,17 +37,17 @@ def test_creating_a_block_that_should_be_connected_to_a_page def test_search skip("deeper dive needed on why these indexes are not rendering") - get :index, :search => {:term => 'test'} + get :index, params: {:search => {:term => 'test'}} assert_response :success assert_select "td", "Test" - get :index, :search => {:term => 'worked', :include_body => true} + get :index, params: {:search => {:term => 'worked', :include_body => true}} assert_response :success assert_select "td", "Test" end def test_edit - get :edit, :id => @block.id + get :edit, params: {:id => @block.id} assert_response :success assert_select "input[id=?][value=?]", "html_block_name", "Test" end @@ -57,7 +57,7 @@ def test_update html_block_count = HtmlBlock.count html_block_version_count = HtmlBlock::Version.count - put :update, :id => @block.id, :html_block => {:name => "Test V2"} + put :update, params: {:id => @block.id, :html_block => {:name => "Test V2"}} reset(:block) assert_redirected_to @block @@ -68,7 +68,7 @@ def test_update end def test_versions - get :versions, :id => @block.id + get :versions, params: {:id => @block.id} assert_response :success assert_equal @block, assigns(:block) end @@ -77,7 +77,7 @@ def test_revert_to @block.update_attributes(:name => "Test V2", :publish_on_save => false) reset(:block) - put :revert_to, :id => @block.id, :version => "1" + put :revert_to, params: {:id => @block.id, :version => "1"} reset(:block) assert_equal 3, @block.draft.version @@ -92,7 +92,7 @@ def test_revert_to_with_invalid_version_parameter html_block_version_count = HtmlBlock::Version.count - put :revert_to, :id => @block.id, :version => 99 + put :revert_to, params: {:id => @block.id, :version => 99} reset(:block) assert_equal html_block_version_count, HtmlBlock::Version.count diff --git a/test/functional/cms/inline_controller_test.rb b/test/functional/cms/inline_controller_test.rb index ba21f313c..5e7cb9bac 100644 --- a/test/functional/cms/inline_controller_test.rb +++ b/test/functional/cms/inline_controller_test.rb @@ -4,7 +4,7 @@ module Cms class InlineContentControllerTest < ActionController::TestCase test "filter html from page_title" do - assert_equal "Remove", HTML::FullSanitizer.new.sanitize("<p>Remove</p>") + assert_equal "Remove", Rails::Html::FullSanitizer.new.sanitize("<p>Remove</p>") end end end diff --git a/test/functional/cms/links_controller_test.rb b/test/functional/cms/links_controller_test.rb index f8b6ac841..6c25b3cd4 100644 --- a/test/functional/cms/links_controller_test.rb +++ b/test/functional/cms/links_controller_test.rb @@ -35,10 +35,10 @@ def setup def test_new_permissions login_as(@user) - get :new, :section_id => @editable_section + get :new, params: {:section_id => @editable_section} assert_response :success - get :new, :section_id => @noneditable_section + get :new, params: {:section_id => @noneditable_section} assert_response 403 assert_template "cms/shared/access_denied" end @@ -46,10 +46,10 @@ def test_new_permissions def test_create_permissions login_as(@user) - post :create, :section_id => @editable_section, link: {:name => "Another editable link"} + post :create, params: {:section_id => @editable_section, link: {:name => "Another editable link"}} assert_response :redirect - post :create, :section_id => @noneditable_section, link: {:name => "Another non-editable link"} + post :create, params: {:section_id => @noneditable_section, link: {:name => "Another non-editable link"}} assert_response 403 assert_template "cms/shared/access_denied" end @@ -57,10 +57,10 @@ def test_create_permissions def test_edit_permissions login_as(@user) - get :edit, :id => @editable_link + get :edit, params: {:id => @editable_link} assert_response :success - get :edit, :id => @noneditable_link + get :edit, params: {:id => @noneditable_link} assert_response 403 assert_template "cms/shared/access_denied" end @@ -68,10 +68,10 @@ def test_edit_permissions def test_update_permissions login_as(@user) - put :update, :id => @editable_link, link: {:name => "Modified editable link"} + put :update, params: {:id => @editable_link, link: {:name => "Modified editable link"}} assert_response :redirect - put :update, :id => @noneditable_link, link: {:name => "Modified non-editable link"} + put :update, params: {:id => @noneditable_link, link: {:name => "Modified non-editable link"}} assert_response 403 assert_template "cms/shared/access_denied" end @@ -79,10 +79,10 @@ def test_update_permissions def test_destroy_permissions login_as(@user) - delete :destroy, :id => @editable_link + delete :destroy, params: {:id => @editable_link} assert_response :redirect - delete :destroy, :id => @noneditable_link + delete :destroy, params: {:id => @noneditable_link} assert_response 403 assert_template "cms/shared/access_denied" end diff --git a/test/functional/cms/pages_controller_test.rb b/test/functional/cms/pages_controller_test.rb index 3c367a2de..a4fc1e23b 100644 --- a/test/functional/cms/pages_controller_test.rb +++ b/test/functional/cms/pages_controller_test.rb @@ -11,7 +11,7 @@ def setup end def test_new - get :new, :section_id => root_section.id + get :new, params: {:section_id => root_section.id} assert_response :success assert_equal root_section, assigns(:page).section end @@ -22,7 +22,7 @@ def test_edit # Make a change to the page, unpublished @page.update_attributes(:name => "V2") - get :edit, :id => @page.id + get :edit, params: {:id => @page.id} assert_response :success assert_select "#page_name[value=?]", "V2" end @@ -35,7 +35,7 @@ def test_unhide assert @page.draft.hidden? - put :update, :id => @page.id, :page => {:hidden => false} + put :update, params: {:id => @page.id, :page => {:hidden => false}} assert_redirected_to '/cms/pages/' + @page.id.to_s # RSE Intentional Change @@ -48,7 +48,7 @@ def test_versions @page.update_attributes(:name => "V2") @page.update_attributes(:name => "V3") - get :versions, :id => @page.to_param + get :versions, params: {:id => @page.to_param} #log @response.body (1..3).each do |n| assert_select "tr[id=?]", "revision_#{n}" @@ -58,14 +58,14 @@ def test_versions def test_version create_page @page.update_attributes(:name => "V2") - get :version, :id => @page.to_param, :version => 1 + get :version, params: {:id => @page.to_param, :version => 1} assert_response :success end def test_revert_to create_draft_page_with_multiple_edits - put :revert_to, :id => @page.to_param, :version => 1 + put :revert_to, params: {:id => @page.to_param, :version => 1} reset(:page) assert_redirected_to @page.path @@ -121,10 +121,10 @@ def setup def test_new_permissions login_as(@user) - get :new, :section_id => @editable_section + get :new, params: {:section_id => @editable_section} assert_response :success - get :new, :section_id => @noneditable_section + get :new, params: {:section_id => @noneditable_section} assert_response 403 assert_template "cms/shared/access_denied" end @@ -133,10 +133,10 @@ def test_create_permissions login_as(@user) skip "Work out how page creation has changed" - post :create, :section_id => @editable_section, page: {:name => "Another editable page"} + post :create, params: {:section_id => @editable_section, page: {:name => "Another editable page"}} assert_response 302 - post :create, :section_id => @noneditable_section, page: {:name => "Another non-editable page"} + post :create, params: {:section_id => @noneditable_section, page: {:name => "Another non-editable page"}} assert_response 403 assert_template "cms/shared/access_denied" end @@ -144,10 +144,10 @@ def test_create_permissions def test_edit_permissions login_as(@user) - get :edit, :id => @editable_page + get :edit, params: {:id => @editable_page} assert_response :success - get :edit, :id => @noneditable_page + get :edit, params: {:id => @noneditable_page} assert_response 403 assert_template "cms/shared/access_denied" end @@ -156,7 +156,7 @@ def test_edit_permissions login_as(@user) # Regular update - put :update, :id => @editable_page, :page => {:name => "Modified editable page"} + put :update, params: {:id => @editable_page, :page => {:name => "Modified editable page"}} assert_response :redirect end @@ -164,60 +164,60 @@ def test_update_permissions login_as(@user) - put :update, :id => @noneditable_page, :page => {:name => "Modified non-editable page"} + put :update, params: {:id => @noneditable_page, :page => {:name => "Modified non-editable page"}} assert_response 403 assert_template "cms/shared/access_denied" # archive - put :archive, :id => @editable_page + put :archive, params: {:id => @editable_page} assert_response :redirect - put :archive, :id => @noneditable_page + put :archive, params: {:id => @noneditable_page} assert_response 403 assert_template "cms/shared/access_denied" # hide - put :hide, :id => @editable_page + put :hide, params: {:id => @editable_page} assert_response :redirect - put :hide, :id => @noneditable_page + put :hide, params: {:id => @noneditable_page} assert_response 403 assert_template "cms/shared/access_denied" # publish - put :publish, :id => @editable_page + put :publish, params: {:id => @editable_page} assert_response :redirect - put :publish, :id => @noneditable_page + put :publish, params: {:id => @noneditable_page} assert_response 403 assert_template "cms/shared/access_denied" # publish many - put :publish, :page_ids => [@editable_page.id] + put :publish, params: {:page_ids => [@editable_page.id]} assert_response :redirect - put :publish, :page_ids => [@noneditable_page.id] + put :publish, params: {:page_ids => [@noneditable_page.id]} assert_response 403 - put :publish, :page_ids => [@editable_page.id, @noneditable_page.id] + put :publish, params: {:page_ids => [@editable_page.id, @noneditable_page.id]} assert_response 403 # revert_to # can't find route... - # put :revert_to, :id => @editable_page.id + # put :revert_to, params: {:id => @editable_page.id} # assert_response :redirect - # put :revert_to, :id => @noneditable_page.id + # put :revert_to, params: {:id => @noneditable_page.id} # assert_response :error # shouldn't it be 403? end def test_destroy_permissions login_as(@user) - delete :destroy, :id => @editable_page + delete :destroy, params: {:id => @editable_page} assert_response :redirect - delete :destroy, :id => @noneditable_page + delete :destroy, params: {:id => @noneditable_page} assert_response 403 assert_template "cms/shared/access_denied" end diff --git a/test/functional/cms/sections_controller_test.rb b/test/functional/cms/sections_controller_test.rb index 3c302fb42..3b18a6008 100644 --- a/test/functional/cms/sections_controller_test.rb +++ b/test/functional/cms/sections_controller_test.rb @@ -12,7 +12,7 @@ def setup test "GET new should set the groups to the parent section's groups by default" do @group = create(:group, :name => "Test", :group_type => create(:group_type, :name => "CMS User", :cms_access => true)) - get :new, :section_id => root_section.to_param + get :new, params: {:section_id => root_section.to_param} assert_response :success expected_groups = root_section.groups @@ -24,7 +24,7 @@ def test_update @section = create(:section, :name => "V1", :parent => root_section, :groups => root_section.groups) - put :update, :id => @section.to_param, :section => {:name => "V2"} + put :update, params: {:id => @section.to_param, :section => {:name => "V2"}} reset(:section) assert_redirected_to @section @@ -70,10 +70,10 @@ def setup def test_new_permissions login_as(@user) - get :new, :section_id => @editable_section + get :new, params: {:section_id => @editable_section} assert_response :success - get :new, :section_id => @noneditable_section + get :new, params: {:section_id => @noneditable_section} assert_response 403 assert_template "cms/shared/access_denied" end @@ -81,7 +81,7 @@ def test_new_permissions test "POST create should set the groups to the parent section's groups for non-admin user" do @group = create(:group, :name => "Test", :group_type => create(:group_type, :name => "CMS User", :cms_access => true)) login_as(@user) - get :new, :section_id => @editable_section + get :new, params: {:section_id => @editable_section} assert_equal @editable_section.groups, assigns(:section).groups assert !assigns(:section).groups.include?(@group) end @@ -89,10 +89,10 @@ def test_new_permissions def test_create_permissions login_as(@user) - post :create, :section_id => @editable_section, section: {:name => "Another editable subsection"} + post :create, params: {:section_id => @editable_section, section: {:name => "Another editable subsection"}} assert_response :success - post :create, :section_id => @noneditable_section, section: {:name => "Another non-editable subsection"} + post :create, params: {:section_id => @noneditable_section, section: {:name => "Another non-editable subsection"}} assert_response 403 assert_template "cms/shared/access_denied" end @@ -100,10 +100,10 @@ def test_create_permissions def test_edit_permissions login_as(@user) - get :edit, :id => @editable_section + get :edit, params: {:id => @editable_section} assert_response :success - get :edit, :id => @noneditable_section + get :edit, params: {:id => @noneditable_section} assert_response 403 assert_template "cms/shared/access_denied" end @@ -111,10 +111,10 @@ def test_edit_permissions def test_update_permissions login_as(@user) - put :update, :id => @editable_section, :name => "Modified editable subsection", section:{name: "Anything"} + put :update, params: {:id => @editable_section, :name => "Modified editable subsection", section:{name: "Anything"}} assert_response :redirect - put :update, :id => @noneditable_section, :name => "Modified non-editable subsection" , section:{name: "Anything"} + put :update, params: {:id => @noneditable_section, :name => "Modified non-editable subsection" , section:{name: "Anything"}} assert_response 403 assert_template "cms/shared/access_denied" end @@ -122,10 +122,10 @@ def test_update_permissions def test_update_permissions_of_subsection login_as(@user) - put :update, :id => @editable_section, :name => "Modified editable subsection", section:{name: "Anything"} + put :update, params: {:id => @editable_section, :name => "Modified editable subsection", section:{name: "Anything"}} assert_response :redirect - put :update, :id => @editable_subsection, :name => "Section below editable section", section:{name: "Anything"} + put :update, params: {:id => @editable_subsection, :name => "Section below editable section", section:{name: "Anything"}} assert_response 403 assert_template "cms/shared/access_denied" end @@ -134,7 +134,7 @@ def test_update_permissions_of_subsection @group2 = create(:group, :name => "Test", :group_type => create(:group_type, :name => "CMS User", :cms_access => true)) expected_groups = @editable_section.groups login_as(@user) - put :update, :id => @editable_section, section: {:name => "V2"} + put :update, params: {:id => @editable_section, section: {:name => "V2"}} assert_response :redirect assert_equal expected_groups, assigns(:section).groups assert !assigns(:section).groups.include?(@group2) @@ -144,7 +144,7 @@ def test_update_permissions_of_subsection @group2 = create(:group, :name => "Test", :group_type => create(:group_type, :name => "CMS User", :cms_access => true)) expected_groups = @editable_section.groups login_as(@user) - put :update, :id => @editable_section, :section => {:name => "new name", :group_ids => [@group.id, @group2.id]} + put :update, params: {:id => @editable_section, :section => {:name => "new name", :group_ids => [@group.id, @group2.id]}} assert_response :redirect assert_equal expected_groups, assigns(:section).groups @@ -160,17 +160,17 @@ def test_update_permissions_of_subsection @group2 = create(:cms_user_group) expected_groups = [@group, @group2] login_as_cms_admin - put :update, :id => @editable_subsection, :section => {:name => "new name", :group_ids => [@group.id, @group2.id]} + put :update, params: {:id => @editable_subsection, :section => {:name => "new name", :group_ids => [@group.id, @group2.id]}} assert_response :redirect end def test_destroy_permissions login_as(@user) - delete :destroy, :id => @editable_section + delete :destroy, params: {:id => @editable_section} assert_response :redirect - delete :destroy, :id => @noneditable_section + delete :destroy, params: {:id => @noneditable_section} assert_response 403 assert_template "cms/shared/access_denied" end diff --git a/test/functional/cms/tasks_controller_test.rb b/test/functional/cms/tasks_controller_test.rb index 7cd39ce94..da05a8b64 100644 --- a/test/functional/cms/tasks_controller_test.rb +++ b/test/functional/cms/tasks_controller_test.rb @@ -14,7 +14,7 @@ def test_complete_task assert_instance_of Cms::Task, task, "This test depends on there being a task to complete" assert !task.completed? - put :complete, :id => @task.id + put :complete, params: {:id => @task.id} assert_response :redirect assert_redirected_to task.page.path assert_equal "Task was marked as complete", flash[:notice] @@ -32,7 +32,7 @@ def test_complete_multiple_tasks assert !tasks.detect {|t| t.completed?} # should update all tasks in the ids list - put :complete, :task_ids => ids + put :complete, params: {:task_ids => ids} assert_response :redirect assert_redirected_to dashboard_path assert_equal "Tasks marked as complete", flash[:notice] @@ -43,14 +43,14 @@ def test_complete_multiple_tasks end # if empty list is passed, should gracefully claim to have completed them all - put :complete, :task_ids => [] + put :complete, params: {:task_ids => []} assert_response :redirect assert_redirected_to dashboard_path assert_equal "Tasks marked as complete", flash[:notice] end def test_complete_no_tasks - put :complete, :task_ids => nil + put :complete, params: {:task_ids => nil} assert_response :redirect assert_redirected_to dashboard_path assert_equal "No tasks were marked for completion", flash[:error] diff --git a/test/helpers/cms/content_types_helper_test.rb b/test/helpers/cms/content_types_helper_test.rb deleted file mode 100644 index f8020f2b3..000000000 --- a/test/helpers/cms/content_types_helper_test.rb +++ /dev/null @@ -1,9 +0,0 @@ -require "test_helper" - -describe ContentTypesHelper do - - it "must be a real test" do - flunk "Need real tests" - end - -end diff --git a/test/minitest_helper.rb b/test/minitest_helper.rb index a7bde94ee..1f0304ff7 100644 --- a/test/minitest_helper.rb +++ b/test/minitest_helper.rb @@ -3,7 +3,6 @@ require "rails/test_help" require "minitest/spec" #require "minitest/autorun" -require "minitest/unit" #ENV["RAILS_ENV"] = "test" #require File.expand_path("../dummy/config/environment.rb", __FILE__) @@ -16,7 +15,7 @@ require 'factories/attachable_factories' require 'minitest/reporters' -MiniTest::Reporters.use! +Minitest::Reporters.use! # Uncomment if you want Capybara in accceptance/integration tests # require "minitest/rails/capybara" @@ -31,7 +30,7 @@ class Minitest::Spec after :each do DatabaseCleaner.clean end - include FactoryGirl::Syntax::Methods + include FactoryBot::Syntax::Methods include FactoryHelpers end diff --git a/test/support/engine_controller_hacks.rb b/test/support/engine_controller_hacks.rb index 0565924b3..e0fd10625 100644 --- a/test/support/engine_controller_hacks.rb +++ b/test/support/engine_controller_hacks.rb @@ -1,37 +1,40 @@ # Rewrite test methods to avoid need to repeat :use_route => :cms in EVERY functional test call # See http://edgeguides.rubyonrails.org/engines.html#testing-an-engine for why this would be necessary. +# +# The call sites are written in the Rails 5 keyword form, so these wrappers take +# kwargs and hand them straight to the framework. On the 4.2 bundle the +# KeywordControllerArgs shim in test_helper.rb is what turns that back into the +# three positional slots -- this module deliberately does not do that itself, so +# there is exactly one place that knows about the 4.2/5.0 calling-convention +# difference. +# +# NOTE: `use_route` is deprecated on 4.2 and *removed* on 5.0, where it would +# reach the controller as an ordinary request parameter instead of being +# consumed. It is a route *name* hint into the application's route set +# (@routes.path_for(options, :cms)), not a route-set swap, so the documented +# replacement -- @routes = Cms::Engine.routes -- is not equivalent: the +# dummy app's own controllers (dummy/sample_blocks) are not in the engine's +# route set and stop resolving. Untangling that is a Rails 5 blocker in its own +# right and is out of scope for the harness migration. See +# docs/rails-upgrade/phase-2-harness-report.md. module EngineControllerHacks - def get(action, parameters = nil, session = nil, flash = nil) - process_action(action, parameters, session, flash, "GET") - end - - # Executes a request simulating POST HTTP method and set/volley the response - def post(action, parameters = nil, session = nil, flash = nil) - process_action(action, parameters, session, flash, "POST") - end - - # Executes a request simulating PUT HTTP method and set/volley the response - def put(action, parameters = nil, session = nil, flash = nil) - process_action(action, parameters, session, flash, "PUT") - end - - # Executes a request simulating DELETE HTTP method and set/volley the response - def delete(action, parameters = nil, session = nil, flash = nil) - process_action(action, parameters, session, flash, "DELETE") + %w(get post put patch delete head).each do |verb| + define_method(verb) do |action, **kwargs| + super(action, **with_engine_route(kwargs)) + end end private - def process_action(action, parameters = nil, session = nil, flash = nil, method = "GET") - parameters ||= {} - merge = { :use_route => :cms } - if parameters[:use_route] == false - parameters.delete(:use_route) - merge = {} + def with_engine_route(kwargs) + params = (kwargs[:params] || {}).dup + if params[:use_route] == false + params.delete(:use_route) + else + params[:use_route] = :cms end - - process(action, method, parameters.merge!(merge), session, flash) + kwargs.merge(params: params) end end -ActionController::TestCase.send(:include, EngineControllerHacks) \ No newline at end of file +ActionController::TestCase.send(:include, EngineControllerHacks) diff --git a/test/support/factory_helpers.rb b/test/support/factory_helpers.rb index a33f4c7c1..4436b1b3b 100644 --- a/test/support/factory_helpers.rb +++ b/test/support/factory_helpers.rb @@ -8,7 +8,7 @@ def new_attachment(name="spreadsheet", path=nil) :attachment_name => name}} end - # Nested Attributes are pretty messy to build directly in code, and FactoryGirl isn't really appropriate since we need to + # Nested Attributes are pretty messy to build directly in code, and FactoryBot isn't really appropriate since we need to # test mass assignment. # Create a single Attachment with some default values. # @@ -35,7 +35,7 @@ def find_or_create_root_section root = Cms::Section.root.first unless root # This constructor matches how seed data is set up. - root = FactoryGirl.create(:root_section) + root = FactoryBot.create(:root_section) end root end @@ -74,7 +74,7 @@ def mock_text_file(options = {}) end def create_or_find_permission_named(name) - Cms::Permission.named(name).first || FactoryGirl.create(:permission, :name => name) + Cms::Permission.named(name).first || FactoryBot.create(:permission, :name => name) end # Creates a TempFile attached to an uploaded file. Used to test attachments @@ -84,7 +84,7 @@ def file_upload_object(options) def create_admin_user(attrs={}) unless Cms::User.find_by_login('cmsadmin') - FactoryGirl.create(:cms_admin, {:login => "cmsadmin"}.merge(attrs)) + FactoryBot.create(:cms_admin, {:login => "cmsadmin"}.merge(attrs)) end end diff --git a/test/support/mini_test_matchers.rb b/test/support/mini_test_matchers.rb index 190995bd0..72f1c81a9 100644 --- a/test/support/mini_test_matchers.rb +++ b/test/support/mini_test_matchers.rb @@ -1,5 +1,5 @@ require 'minitest/autorun' -module MiniTest::Assertions +module Minitest::Assertions def assert_is_published(block) assert block.published?, "Expected #{block} to be published." klass = block.class diff --git a/test/test_helper.rb b/test/test_helper.rb index c75c74a8b..b4af6c142 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -6,11 +6,18 @@ Rails.backtrace_cleaner.remove_silencers! -require 'minitest/unit' # Load support files Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } -require 'mocha/setup' +# mocha 1.x's minitest adapter assigns ::MiniTest::Assertion +# (mocha/integration/mini_test/adapter.rb:26). That camelCase alias is defined +# in exactly one place -- the deprecated legacy shim file this phase stopped +# requiring -- and +# which also drags in the deprecated Minitest::Unit::TestCase shim. Alias the +# one constant mocha needs instead of requiring the whole deprecated file. +# Delete this when mocha goes to 2.x, which dropped the legacy reference. +MiniTest = Minitest unless defined?(MiniTest) +require 'mocha/minitest' require 'action_view/test_case' # Allows Generators to be unit tested @@ -20,19 +27,36 @@ require 'support/factory_helpers' require 'support/database_helpers' -# I'm not sure why ANY of these FactoryGirl requires are necessary at all. -require 'factory_girl' +# I'm not sure why ANY of these FactoryBot requires are necessary at all. +require 'factory_bot' require 'factories/factories' require 'factories/attachable_factories' -# Silence warnings (hopefully) primarily from HTML parsing in functional tests. -$VERBOSE = nil +# A global warning suppression used to sit here, to quiet HTML-parsing noise in +# the functional tests. It also silenced every Ruby deprecation warning in the +# suite -- and those are the roadmap for the Rails upgrade, so they are worth +# the noise. Do not reinstate a blanket suppression: silence a specific warning +# at a specific call site, with a comment saying why. require 'support/engine_controller_hacks' +# The suite runs two cleaning strategies side by side: ActiveSupport::TestCase +# rolls each test back in a transaction, while Minitest::Spec truncates via +# DatabaseCleaner after every example. db:install seeds a Home page and a +# /system section, and the first truncation permanently removes them -- so a +# transactional test saw seeded data or an empty table depending purely on +# where the random test order happened to put the first spec. Tests that assert +# the exact contents of the root section (Section#master_section_list, +# #sitemap, #visible_child_nodes) passed or failed on that coin flip. +# +# Start every run from the same empty database. The helpers in +# support/factory_helpers.rb all find_or_create what they need. +require 'database_cleaner' +DatabaseCleaner.clean_with(:truncation) + class ActiveSupport::TestCase - include FactoryGirl::Syntax::Methods + include FactoryBot::Syntax::Methods include FactoryHelpers # Add more helper methods to be used by all tests here... @@ -48,7 +72,7 @@ def should_validate_presence_of(options) fields = options[factory_name] fields.each do |f| define_method("test_validates_presence_of_#{f}") do - model = FactoryGirl.build(factory_name, f => nil) + model = FactoryBot.build(factory_name, f => nil) assert !model.valid? assert_has_error_on model, f, "can't be blank" end @@ -60,8 +84,8 @@ def should_validate_uniqueness_of(options) fields = options[class_name] fields.each do |f| define_method("test_validates_uniqueness_of_#{f}") do - existing_model = FactoryGirl.create(class_name) - model = FactoryGirl.build(class_name, f => existing_model.send(f)) + existing_model = FactoryBot.create(class_name) + model = FactoryBot.build(class_name, f => existing_model.send(f)) assert !model.valid? assert_has_error_on model, f, "has already been taken" end @@ -182,14 +206,23 @@ def output.write(contents) end class ActionController::TestCase - include Devise::TestHelpers + include Devise::Test::ControllerHelpers end +# Defined here and included nowhere -- and login_as asserts 403 immediately +# after a successful login, so it could not have passed in years. Converted +# rather than deleted: this phase is a port, and "no tests were lost" is easier +# to defend if nothing was removed. Flagged for Phase 3's dead-code item. +# +# Note this call is NOT covered by the KeywordControllerArgs shim above: that +# prepends to ActionController::TestCase, and integration tests go through +# ActionDispatch::IntegrationTest#process, which has a different signature +# entirely. If this module is ever revived it will break on the 4.2 bundle. module Cms::IntegrationTestHelper def login_as(user, password = "password") get login_url assert_response :success - post login_url, :login => user.login, :password => password + post login_url, params: {:login => user.login, :password => password} assert_response 403 assert_equal "", @response.body, "Checking post login" assert flash[:notice] @@ -230,6 +263,45 @@ def recycle! end end +# Rails 4.2's ActionController::TestCase#process has three positional slots and +# no keyword handling: `def process(action, http_method = 'GET', *args)` then +# `parameters, session, flash = args` (actionpack-4.2.11.3 test_case.rb:595). +# So `get :show, params: {id: 5}` arrives as params[:params][:id] and the +# controller never sees :id -- a silently wrong answer, not an error. Rails 5.0 +# accepts both forms; 5.1 accepts only the keyword form. No single form works on +# both, so the call sites are written the 5.x way and translated back here, once, +# for the 4.2 bundle only. Delete this whole block in Phase 5. +if Gem::Version.new(Rails.version) < Gem::Version.new('5.0.0') + module KeywordControllerArgs + TRANSLATABLE = [:params, :session, :flash].freeze + + # 4.2 has no positional slot for any of these. Zero call sites use one today + # (no xhr / xml_http_request / as: / format: anywhere in test/functional). + # Raise rather than drop: a dropped keyword is a test that passes for the + # wrong reason, which is the one failure mode this shim must not have. + UNTRANSLATABLE = [:xhr, :as, :format, :body, :env, :headers].freeze + + def process(action, http_method = 'GET', *args) + kwargs = args.first + keyword_form = args.length == 1 && kwargs.is_a?(Hash) && kwargs.any? && + kwargs.keys.all? { |k| TRANSLATABLE.include?(k) || UNTRANSLATABLE.include?(k) } + return super unless keyword_form + + unsupported = kwargs.keys & UNTRANSLATABLE + unless unsupported.empty? + raise ArgumentError, "Rails 4.2 cannot express #{unsupported.inspect} in a " \ + "controller test. Rewrite the call, or extend the shim " \ + "in test/test_helper.rb -- do not drop the keyword." + end + + super(action, http_method, kwargs[:params], kwargs[:session], kwargs[:flash]) + end + end + + ActionController::TestCase.prepend(KeywordControllerArgs) + puts 'Translating keyword controller-test args back to Rails 4.2 positional form' +end + # Disable url encoding for Paperclip, it erroneously encodes the '?' # between the path and the query string. Paperclip::Attachment.default_options[:escape_url] = false diff --git a/test/unit/behaviors/attaching_test.rb b/test/unit/behaviors/attaching_test.rb index 04c4d7f16..458bb3f38 100644 --- a/test/unit/behaviors/attaching_test.rb +++ b/test/unit/behaviors/attaching_test.rb @@ -244,7 +244,7 @@ class SendFileStrategyTest < ActiveSupport::TestCase def given_an_attachment_with_file_path(expected_path, options={}) @attachment = stub(:file_name => "NAME", :file_type => "TYPE") expect = @attachment.expects(:path).with(options[:style]).returns(expected_path) - File.expects(:exists?).with(expected_path).returns(true) + File.expects(:exist?).with(expected_path).returns(true) end def then_controller_should_send_file(expected_path, options={}) diff --git a/test/unit/behaviors/cms_user_test.rb b/test/unit/behaviors/cms_user_test.rb index 688a516b8..00e848219 100644 --- a/test/unit/behaviors/cms_user_test.rb +++ b/test/unit/behaviors/cms_user_test.rb @@ -9,7 +9,7 @@ end -EXTERNAL_USER_GROUPS = Hash.new {|hash,key| hash[key] = FactoryGirl.build(:group)} +EXTERNAL_USER_GROUPS = Hash.new {|hash,key| hash[key] = FactoryBot.build(:group)} class ExternalUser < ActiveRecord::Base #attr_accessible :name diff --git a/test/unit/belongs_to_optionality_test.rb b/test/unit/belongs_to_optionality_test.rb new file mode 100644 index 000000000..6068f8f67 --- /dev/null +++ b/test/unit/belongs_to_optionality_test.rb @@ -0,0 +1,224 @@ +require 'test_helper' + +# Phase 3, stage B -- the oracle for the belongs_to audit. +# +# WHAT `belongs_to_required_by_default` ACTUALLY DOES +# +# The flag is read inside ActiveRecord::Associations::Builder::BelongsTo.define_validations +# (activerecord-5.0.7.2/lib/active_record/associations/builder/belongs_to.rb:122), and its +# entire effect is one line: +# +# model.validates_presence_of reflection.name, message: :required +# +# That runs when `belongs_to` is *called* -- at class-definition time. Two consequences, +# both of which rule out the "force the flag on around a model sweep" design this stage +# was originally scoped with: +# +# 1. Flipping the flag in a setup block cannot retroactively add validations to +# associations that are already defined. It would be a no-op even on 5.0. +# 2. The accessor does not exist on 4.2 at all -- it arrives at activerecord-5.0.7.2 +# core.rb:117 -- so touching it raises NoMethodError on the bundle in production. +# +# WHY THE DECLARATION IS `required: false`, NOT `optional: true` +# +# `:optional` is not in 4.2's valid_options. 4.2 raises, at class-definition time, from +# Builder::Association#validate_options: +# +# ArgumentError: Unknown key: :optional. Valid keys are: :class_name, :anonymous_class, +# :foreign_key, :validate, :autosave, :dependent, :primary_key, :inverse_of, :required, +# :foreign_type, :polymorphic, :touch, :counter_cache +# +# which takes the whole engine down on the default bundle. `:required` is valid on both, +# and 5.0 normalises it (belongs_to.rb:123): `options[:optional] = !options.delete(:required)`. +# Measured on both bundles: +# +# 4.2 belongs_to :x, required: false -> options={:required=>false}, no validator +# 5.0 belongs_to :x, required: false -> options={:optional=>true}, no validator +# +# So `required: false` is the only spelling that parses on both, means "nil is allowed" +# on 5.0, and is a provable no-op on 4.2. +# +# THE INVARIANT THIS TEST ENFORCES +# +# Nothing in either bundle turns the flag on (there is no `load_defaults` in this repo; +# the flag is the host application's to set), so the audit cannot be checked by observing +# a behaviour change. What it can be checked against is the claim each site is making: +# +# :required -- the model already rejects nil here through its own presence validation. +# Required-by-default would be redundant, so the declaration is left bare +# and a host app on `load_defaults 5.0` gets behaviour matching the +# model's stated intent. +# +# :optional -- nothing rejects nil here today, on either bundle. `required: false` pins +# that, so a host app on `load_defaults 5.0` keeps it. +# +# Both halves are assertions about the loaded class, so both run on both bundles. The +# test is what makes the audit falsifiable: adding a belongs_to without a verdict fails +# `test_every_belongs_to_is_audited`, and contradicting a verdict fails one of the other +# two. +# +# Phase 4 owns the permanent version of this. +class BelongsToOptionalityTest < ActiveSupport::TestCase + + # Verdict per site. :required means "left bare, backed by an existing presence + # validation"; :optional means "carries required: false". + # + # Keys are "Class#association". The evidence for each is in the comment. + AUDIT = { + # --- backed by an existing presence validation: leave bare ----------------- + 'Cms::Category#category_type' => :required, # category.rb:12 validates_presence_of :category_type_id + 'Cms::Connector#page' => :required, # connector.rb:44 validates_presence_of :page_id + 'Cms::Connector#connectable' => :required, # connector.rb:44 validates_presence_of :connectable_id, :connectable_type + 'Cms::PageRoute#page' => :required, # page_route.rb:26 validates_presence_of :page_id + 'Cms::Task#assigned_by' => :required, # task.rb:27 validates_presence_of :assigned_by_id + 'Cms::Task#assigned_to' => :required, # task.rb:28 validates_presence_of :assigned_to_id + 'Cms::Task#page' => :required, # task.rb:29 validates_presence_of :page_id + + # --- nothing rejects nil today: pin it with required: false --------------- + 'Cms::Category#parent' => :optional, # self-referential; every root category has a nil parent + 'Cms::Group#group_type' => :optional, # factory :group (factories.rb:74) creates one with no group_type + 'Cms::Attachment#attachable' => :optional, # attachment.rb:23 validates attachable_type but NOT attachable_id; + # the block factories build the attachment first and assign + # attachable afterwards (factories.rb:52, :68) + 'Cms::Tagging#tag' => :optional, + 'Cms::Tagging#taggable' => :optional, # polymorphic, unvalidated + 'Cms::SectionNode#node' => :optional, # polymorphic, unvalidated; a node is built before it is linked + 'Cms::FormEntry#form' => :optional, + 'Cms::FormField#form' => :optional, + 'Cms::PageRouteOption#page_route' => :optional, + 'Cms::GroupSection#group' => :optional, + 'Cms::GroupSection#section' => :optional, + 'Cms::UserGroupMembership#user' => :optional, + 'Cms::UserGroupMembership#group' => :optional, + 'Cms::GroupTypePermission#group_type' => :optional, + 'Cms::GroupTypePermission#permission' => :optional, + 'Cms::GroupPermission#group' => :optional, + 'Cms::GroupPermission#permission' => :optional, + }.freeze + + # The five behavior-injected declarations. These are not on one class -- they are + # applied to every model that uses the behavior, in this engine and in every + # downstream project, which is why they carry the blast radius. Verified here against + # one consumer each; the enumeration test below also checks them everywhere they land. + BEHAVIOR_AUDIT = { + 'created_by' => :optional, # userstamping.rb:16 -- nil for anything created outside a + 'updated_by' => :optional, # userstamping.rb:17 request: seeds, rake tasks, migrations + 'category' => :optional, # categorizing.rb:16 -- categorising is opt-in per instance + }.freeze + + # Classes carrying the literal declarations audited above. + AUDITED_CLASSES = %w[ + Cms::Category Cms::Connector Cms::PageRoute Cms::Task Cms::Group Cms::Attachment + Cms::Tagging Cms::SectionNode Cms::FormEntry Cms::FormField Cms::PageRouteOption + Cms::GroupSection Cms::UserGroupMembership Cms::GroupTypePermission Cms::GroupPermission + ].freeze + + # `required: false` reads back differently per bundle -- 4.2 keeps the key it was given, + # 5.0 rewrites it to :optional. Either spelling means the same thing. + def optional_declared?(reflection) + reflection.options[:optional] == true || reflection.options[:required] == false + end + + # Required-by-default validates the *loaded object*, not the foreign key. A model that + # validates the FK instead is making the same claim by a different route, so both count. + def rejects_nil?(klass, name) + [name, :"#{name}_id"].any? do |attribute| + klass.validators_on(attribute).any? { |v| v.kind == :presence } + end + end + + def audited_reflections + AUDITED_CLASSES.flat_map do |class_name| + klass = class_name.constantize + klass.reflect_on_all_associations(:belongs_to).map { |r| [klass, r] } + end + end + + def verdict_for(klass, reflection) + AUDIT["#{klass.name}##{reflection.name}"] || BEHAVIOR_AUDIT[reflection.name.to_s] + end + + test "every belongs_to on an audited class has a verdict" do + unaudited = audited_reflections.reject { |klass, r| verdict_for(klass, r) } + assert unaudited.empty?, + "belongs_to declarations with no verdict in AUDIT/BEHAVIOR_AUDIT: " + + unaudited.map { |klass, r| "#{klass.name}##{r.name}" }.join(', ') + end + + test "every audited site is still declared" do + live = audited_reflections.map { |klass, r| "#{klass.name}##{r.name}" } + missing = AUDIT.keys - live + assert missing.empty?, "AUDIT names associations that no longer exist: #{missing.join(', ')}" + end + + test "associations judged required are backed by a presence validation" do + offenders = audited_reflections.select { |klass, r| verdict_for(klass, r) == :required } + .reject { |klass, r| rejects_nil?(klass, r.name) } + assert offenders.empty?, + "judged :required but nothing rejects nil -- either add a presence validation " + + "or change the verdict to :optional: " + + offenders.map { |klass, r| "#{klass.name}##{r.name}" }.join(', ') + end + + test "associations judged required are left bare" do + offenders = audited_reflections.select { |klass, r| verdict_for(klass, r) == :required } + .select { |klass, r| optional_declared?(r) } + assert offenders.empty?, + "judged :required but declared required: false: " + + offenders.map { |klass, r| "#{klass.name}##{r.name}" }.join(', ') + end + + test "associations judged optional declare required: false" do + offenders = audited_reflections.select { |klass, r| verdict_for(klass, r) == :optional } + .reject { |klass, r| optional_declared?(r) } + assert offenders.empty?, + "judged :optional but not declared required: false -- a host app on " + + "load_defaults 5.0 would start rejecting nil here: " + + offenders.map { |klass, r| "#{klass.name}##{r.name}" }.join(', ') + end + + test "associations judged optional are not contradicted by a presence validation" do + offenders = audited_reflections.select { |klass, r| verdict_for(klass, r) == :optional } + .select { |klass, r| rejects_nil?(klass, r.name) } + assert offenders.empty?, + "declared required: false but the model validates presence anyway -- " + + "the verdict is wrong: " + + offenders.map { |klass, r| "#{klass.name}##{r.name}" }.join(', ') + end + + # The behavior declarations are the ones a reviewer needs to look at properly: they + # apply to every model using the behavior, here and downstream. Check them where they + # actually land rather than only on the behavior module. + test "userstamping and categorizing inject required: false everywhere they apply" do + consumers = [Cms::Page, Cms::HtmlBlock, Cms::Section] + offenders = consumers.flat_map { |klass| + klass.reflect_on_all_associations(:belongs_to) + .select { |r| BEHAVIOR_AUDIT.key?(r.name.to_s) } + .reject { |r| optional_declared?(r) } + .map { |r| "#{klass.name}##{r.name}" } + } + assert offenders.empty?, "behavior-injected belongs_to missing required: false: #{offenders.join(', ')}" + end + + # versioning.rb:115 and dynamic_attributes.rb:168 declare belongs_to dynamically, so + # `grep -rn "required: false" app/ lib/` finds them in a shape no criterion expects. + # Assert them through the reflection instead, which is the only place they are visible. + test "the versioning behavior's version-to-parent association declares required: false" do + reflection = Cms::HtmlBlock::Version.reflect_on_association(:html_block) + assert reflection, "Cms::HtmlBlock::Version should belong_to :html_block" + assert optional_declared?(reflection), + "versioning.rb:115 must pass required: false into version_class.belongs_to" + end + + # `required: false` must remain a no-op on 4.2 and must mean optional on 5.0. If this + # ever stops holding, every verdict above is wrong at once. + test "required: false adds no presence validation on either bundle" do + probe = Class.new(ActiveRecord::Base) do + self.table_name = 'cms_categories' + belongs_to :parent, class_name: 'Cms::Category', required: false + end + assert_equal [], probe.validators_on(:parent).select { |v| v.kind == :presence }, + "required: false must not add a presence validation" + assert optional_declared?(probe.reflect_on_association(:parent)) + end +end diff --git a/test/unit/extensions/active_record/base_test.rb b/test/unit/extensions/active_record/base_test.rb index b6d20e949..e076239ac 100644 --- a/test/unit/extensions/active_record/base_test.rb +++ b/test/unit/extensions/active_record/base_test.rb @@ -9,8 +9,15 @@ def test_updated_on_string end end -# Must use vanilla TestCase to avoid ActiveRecord setup conflicts -class TestExtensions < MiniTest::Unit +# Must use vanilla TestCase to avoid ActiveRecord setup conflicts. +# +# This said `MiniTest::Unit`, which is not a TestCase at all -- it is the +# deprecated compatibility shim class from minitest's legacy unit file, so +# minitest never +# collected these two tests and they have not run in years. The 4.x name the +# author wanted, MiniTest::Unit::TestCase, is Minitest::Test on minitest 5. +# Naming it correctly is what makes them run. See phase-2-harness-report.md. +class TestExtensions < Minitest::Test #"If a connection throws an error when established, then we consider the database to not exist." def test_throws_error diff --git a/test/unit/lib/acts_as_content_page_test.rb b/test/unit/lib/acts_as_content_page_test.rb index ebcf56c47..dc019d275 100644 --- a/test/unit/lib/acts_as_content_page_test.rb +++ b/test/unit/lib/acts_as_content_page_test.rb @@ -48,21 +48,21 @@ class NewController < ActionController::Base include Cms::Acts::ContentPage end - test "placing in a section should create a before_filter for that section for all actions" do - NewController.expects(:before_filter).with(:check_access_to_section, {}) + test "placing in a section should create a before_action for that section for all actions" do + NewController.expects(:before_action).with(:check_access_to_section, {}) NewController.send :requires_permission_for_section, EXPECTED_SECTION end test "can put only conditions on filters" do - NewController.expects(:before_filter).with(:check_access_to_section, :only=>[:create]) + NewController.expects(:before_action).with(:check_access_to_section, :only=>[:create]) NewController.send :requires_permission_for_section, EXPECTED_SECTION, :only=>[:create] end test "can put except conditions on filters" do - NewController.expects(:before_filter).with(:check_access_to_section, :except=>[:create]) + NewController.expects(:before_action).with(:check_access_to_section, :except=>[:create]) NewController.send :requires_permission_for_section, EXPECTED_SECTION, :except=>[:create] end diff --git a/test/unit/lib/cms/domain_support_test.rb b/test/unit/lib/cms/domain_support_test.rb index 036955657..277f26b81 100644 --- a/test/unit/lib/cms/domain_support_test.rb +++ b/test/unit/lib/cms/domain_support_test.rb @@ -1,6 +1,38 @@ require "test_helper" module Cms + + # Merged here from test/unit/lib/cms_domain_support_test.rb, which tested the + # same file through Cms::ApplicationController rather than through the module. + class CmsSiteDetectionTest < ActiveSupport::TestCase + + test "cms_site? determines if the first subdomain is 'cms'" do + c = Cms::ApplicationController.new + request = mock + + c.expects(:request).returns(request) + request.expects(:subdomains).returns(["cms"]) + + assert c.send :cms_site? + end + + test "A url that isn't a cms domain" do + c = Cms::ApplicationController.new + request = mock + + c.expects(:request).returns(request) + request.expects(:subdomains).returns(["www"]) + + assert_equal false, c.send(:cms_site?) + end + + test "default cms domain" do + c = Cms::ApplicationController.new + + assert_equal "cms", c.send(:cms_domain_prefix) + end + end + class DomainSupportTest < ActiveSupport::TestCase include DomainSupport diff --git a/test/unit/lib/cms_domain_support_test.rb b/test/unit/lib/cms_domain_support_test.rb deleted file mode 100644 index 2eb7f6926..000000000 --- a/test/unit/lib/cms_domain_support_test.rb +++ /dev/null @@ -1,42 +0,0 @@ -require 'test_helper' - -class CmsDomainSupportTest < ActiveSupport::TestCase - - def setup - - end - - def teardown - - end - - test "cms_site? determines if the first subdomain is 'cms'" do - c = Cms::ApplicationController.new - request = mock - - c.expects(:request).returns(request) - request.expects(:subdomains).returns(["cms"]) - - assert c.send :cms_site? - end - - test "A url that isn't a cms domain" do - c = Cms::ApplicationController.new - request = mock - - c.expects(:request).returns(request) - request.expects(:subdomains).returns(["www"]) - - assert_equal false, c.send(:cms_site?) - end - - test "default cms domain" do - c = Cms::ApplicationController.new - - assert_equal "cms", c.send(:cms_domain_prefix) - end - - test "prepare_for_rendererable" do - - end -end \ No newline at end of file diff --git a/test/unit/models/namespaces_test.rb b/test/unit/models/namespaces_test.rb index 86d8615e7..dbc3ec420 100644 --- a/test/unit/models/namespaces_test.rb +++ b/test/unit/models/namespaces_test.rb @@ -20,7 +20,12 @@ def self.subclasses_from_module(module_name) klass = klass_name.constantize if klass.class == Class subclasses << klass - subclasses += klass.send(:descendants).collect{|x| x.respond_to?(:constantize) ? x.constantize : x} + # Rails 4.2's Class#descendants also yields singleton classes (the + # guard for that wasn't added until Rails 5). They are never valid + # targets here, and calling respond_to? on the singleton class of a + # Railtie instance raises TypeError, so drop them first. + descendants = klass.send(:descendants).reject { |x| x.singleton_class? } + subclasses += descendants.collect{|x| x.respond_to?(:constantize) ? x.constantize : x} else subclasses += subclasses_from_module(klass_name) end diff --git a/test/unit/models/site_test.rb b/test/unit/models/site_test.rb index 400628f1f..15682675c 100644 --- a/test/unit/models/site_test.rb +++ b/test/unit/models/site_test.rb @@ -25,6 +25,10 @@ def test_should_not_remove_sub_domain_from_domain_when_saving end def test_should_make_the_first_the_default + # Site#set_default only promotes a site when no default exists yet, so this + # test is only meaningful against an empty table. Assert that precondition + # rather than inheriting whatever earlier tests happened to leave behind. + Cms::Site.delete_all @first = create(:site) @second = create(:site) assert @first.the_default? diff --git a/test/unit/models/user_test.rb b/test/unit/models/user_test.rb index bab341d0f..14273754e 100644 --- a/test/unit/models/user_test.rb +++ b/test/unit/models/user_test.rb @@ -317,6 +317,47 @@ def test_guest assert @guest_user.able_to_view?(@protected_section) end + # Cms::GuestUser blocks writes by overriding update_attribute, update_attributes and + # save (guest_user.rb:63-73). But `update_attributes` is an alias, not the method: + # ActiveRecord::Persistence defines `def update(attributes)` and then + # `alias update_attributes update` -- 4.2 at :247/:256, 5.0 at :270/:279. Overriding + # the alias name in a subclass leaves `update` bound to the original implementation, + # so this call goes to ActiveRecord::Persistence#update and never sees the guard. + # + # The write still fails, because `save` is separately overridden to return false. So + # the hole is not currently exploitable -- but it fails at the wrong place, for the + # wrong reason, and only by luck. Anything that changes the save override, or any + # subclass that does not inherit it, reopens it. + # + # Fixing it means renaming the definition to `update` and keeping + # `alias update_attributes update` for downstream callers. That is a behaviour change, + # and Phase 3's contract is that it contains none, so it was recorded rather than + # applied (phase-3-implementation-plan.md D3). Unskip this when the fix lands. + test "GuestUser#update should be blocked by the same guard as update_attributes" do + skip "Known defect, deliberately not fixed in Phase 3 -- see the comment above and " \ + "app/models/cms/guest_user.rb. update_attributes is an alias of update, so " \ + "overriding only the alias leaves update reachable." + + guest = Cms::User.guest + assert_equal false, guest.update(:first_name => "Malcolm"), + "GuestUser#update should be refused by the guard, as update_attributes is" + end + + # Demonstrates the defect above as it actually stands today, so the skipped test is + # not the only record of it. This passes; it is the mechanism that is wrong. + test "GuestUser#update currently bypasses the guard and is stopped by save instead" do + guest = Cms::User.guest + assert_equal false, guest.update_attributes(:first_name => "Malcolm"), + "update_attributes is overridden and returns false at the guard" + + guard = Cms::GuestUser.instance_method(:update_attributes) + inherited_update = Cms::GuestUser.instance_method(:update) + refute_equal Cms::GuestUser, inherited_update.owner, + "if update is ever defined on GuestUser, the guard is complete and the " \ + "skipped test above should be unskipped" + assert_equal Cms::GuestUser, guard.owner + end + test "GuestUser can't view a nil section" do user = Cms::GuestUser.new