From 6f4af61d06995df3ea99246e75ae797dbd33a3b9 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Fri, 18 Sep 2026 20:21:36 +0100 Subject: [PATCH 01/14] Add an e2e host application builder Generates a real Rails + ActiveAdmin application with the gem installed into it, cached at tmp/e2e_app between runs, driven by a new 'rake e2e' task kept out of the default suite. Co-Authored-By: Claude Opus 5 (1M context) --- .rspec | 1 + Rakefile | 6 + spec/e2e/app_install_spec.rb | 37 +++++ spec/e2e/e2e_helper.rb | 18 +++ spec/e2e/support/app_builder.rb | 246 ++++++++++++++++++++++++++++++++ 5 files changed, 308 insertions(+) create mode 100644 spec/e2e/app_install_spec.rb create mode 100644 spec/e2e/e2e_helper.rb create mode 100644 spec/e2e/support/app_builder.rb diff --git a/.rspec b/.rspec index 5be63fc..c2a69fa 100644 --- a/.rspec +++ b/.rspec @@ -1,2 +1,3 @@ --require spec_helper --format documentation +--exclude-pattern "spec/e2e/**/*_spec.rb" diff --git a/Rakefile b/Rakefile index b6ae734..c1ba162 100644 --- a/Rakefile +++ b/Rakefile @@ -5,4 +5,10 @@ require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) +desc "Run the end-to-end suite against a generated ActiveAdmin application" +RSpec::Core::RakeTask.new(:e2e) do |t| + t.pattern = "spec/e2e/**/*_spec.rb" + t.rspec_opts = "--require ./spec/e2e/e2e_helper.rb --format documentation --exclude-pattern ''" +end + task default: :spec diff --git a/spec/e2e/app_install_spec.rb b/spec/e2e/app_install_spec.rb new file mode 100644 index 0000000..a97227f --- /dev/null +++ b/spec/e2e/app_install_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe "the generated host application" do + let(:app_path) { E2E::AppBuilder::APP_PATH } + + it "has ActiveAdmin installed" do + expect(File).to exist(File.join(app_path, "config/initializers/active_admin.rb")) + expect(File).to exist(File.join(app_path, "app/admin/dashboard.rb")) + end + + it "has the gem's initializer, with the user class pointed at AdminUser" do + initializer = File.read(File.join(app_path, "config/initializers/activeadmin_mcp.rb")) + + expect(initializer).to include("config.authentication_method = :devise_token") + expect(initializer).to include('config.user_class = "AdminUser"') + end + + it "has the token migration and the MCP Tokens admin page from the installer" do + migrations = Dir[File.join(app_path, "db/migrate/*_create_mcp_api_tokens.rb")] + + expect(migrations).not_to be_empty + expect(File).to exist(File.join(app_path, "app/admin/mcp_api_tokens.rb")) + end + + it "has the fixture resources registered" do + expect(File).to exist(File.join(app_path, "app/admin/posts.rb")) + expect(File).to exist(File.join(app_path, "app/admin/authors.rb")) + end + + it "has migrated the fixture tables" do + schema = File.read(File.join(app_path, "db/schema.rb")) + + expect(schema).to include('create_table "posts"') + expect(schema).to include('create_table "authors"') + expect(schema).to include('create_table "mcp_api_tokens"') + end +end diff --git a/spec/e2e/e2e_helper.rb b/spec/e2e/e2e_helper.rb new file mode 100644 index 0000000..e9d4ec0 --- /dev/null +++ b/spec/e2e/e2e_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rspec" + +require_relative "support/app_builder" + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.disable_monkey_patching! + config.order = :defined + + config.before(:suite) do + E2E::AppBuilder.build! + end +end diff --git a/spec/e2e/support/app_builder.rb b/spec/e2e/support/app_builder.rb new file mode 100644 index 0000000..9bc160f --- /dev/null +++ b/spec/e2e/support/app_builder.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +require "digest" +require "fileutils" +require "open3" + +module E2E + # Generates a real Rails + ActiveAdmin application with the gem under test + # installed into it, and caches the result between runs. + # + # The application is cached on a fingerprint of this file, so editing the + # build recipe forces a rebuild. Editing the gem's own library code does + # not, and must not: the application references the gem with `path:`, so it + # always loads the current working tree. + class AppBuilder + RAILS_VERSION = "7.2.2.2" + REPO_ROOT = File.expand_path("../../..", __dir__) + APP_PATH = File.join(REPO_ROOT, "tmp", "e2e_app") + FINGERPRINT_PATH = File.join(APP_PATH, ".e2e_fingerprint") + + ADMIN_EMAIL = "admin@example.com" + ADMIN_PASSWORD = "password" + + GEMFILE_ADDITIONS = <<~RUBY + gem "activeadmin", "~> 3.2" + gem "devise" + gem "sassc-rails" + gem "activeadmin_mcp", path: "GEM_PATH" + RUBY + + class BuildError < StandardError; end + + class << self + def build! + new.build! + end + + # Runs a command with the gem's own bundler environment stripped out, so + # the generated application resolves against its own Gemfile. Raises with + # the combined output on failure: a silent build failure here surfaces + # much later as an inscrutable boot error. + def run!(command, chdir: APP_PATH) + output = nil + status = nil + + Bundler.with_unbundled_env do + output, status = Open3.capture2e(*command, chdir: chdir) + end + + return output if status.success? + + raise BuildError, "Command failed: #{command.join(' ')}\n\n#{output}" + end + end + + def build! + return APP_PATH if cached? + + FileUtils.rm_rf(APP_PATH) + FileUtils.mkdir_p(File.dirname(APP_PATH)) + + generate_app + write_gemfile + bundle_install + install_active_admin + generate_fixture_models + write_model_overrides + write_admin_registrations + install_mcp + configure_mcp + migrate + seed + + File.write(FINGERPRINT_PATH, fingerprint) + APP_PATH + end + + private + + def cached? + return false if ENV["E2E_REBUILD"] + return false unless File.exist?(FINGERPRINT_PATH) + + File.read(FINGERPRINT_PATH).strip == fingerprint + end + + def fingerprint + Digest::SHA256.hexdigest([RAILS_VERSION, File.read(__FILE__)].join("\n")) + end + + def run!(command, chdir: APP_PATH) + self.class.run!(command, chdir: chdir) + end + + def generate_app + ensure_rails_installed + + run!( + [ + "rails", "_#{RAILS_VERSION}_", "new", APP_PATH, + "--database=sqlite3", + "--asset-pipeline=sprockets", + "--skip-git", + "--skip-test", + "--skip-system-test", + "--skip-javascript", + "--skip-hotwire", + "--skip-action-cable", + "--skip-action-mailbox", + "--skip-action-text", + "--skip-active-storage", + "--skip-jbuilder", + "--skip-bootsnap", + ], + chdir: REPO_ROOT + ) + end + + def ensure_rails_installed + _, status = Bundler.with_unbundled_env do + Open3.capture2e("gem", "list", "-i", "rails", "-v", RAILS_VERSION) + end + return if status.success? + + self.class.run!(["gem", "install", "rails", "-v", RAILS_VERSION, "--no-document"], chdir: REPO_ROOT) + end + + def write_gemfile + File.open(File.join(APP_PATH, "Gemfile"), "a") do |f| + f.puts + f.puts GEMFILE_ADDITIONS.gsub("GEM_PATH", REPO_ROOT) + end + end + + def bundle_install + run!(["bundle", "install"]) + end + + def install_active_admin + run!(["bin/rails", "generate", "active_admin:install"]) + end + + def generate_fixture_models + run!(["bin/rails", "generate", "model", "Author", "name:string", "email:string"]) + run!(["bin/rails", "generate", "model", "Post", "title:string", "body:text", "slug:string"]) + end + + # Ransack 4 (used by ActiveAdmin 3.x, and called directly by the gem's + # `query` tool) refuses to filter on any attribute not listed in a + # model's ransackable_attributes allowlist. Without this override every + # `query` example against these fixtures would fail with a Ransack + # error rather than exercising the gem. + def write_model_overrides + File.write(File.join(APP_PATH, "app/models/post.rb"), <<~RUBY) + # frozen_string_literal: true + + class Post < ApplicationRecord + def self.ransackable_attributes(_auth_object = nil) + column_names + end + end + RUBY + + File.write(File.join(APP_PATH, "app/models/author.rb"), <<~RUBY) + # frozen_string_literal: true + + class Author < ApplicationRecord + def self.ransackable_attributes(_auth_object = nil) + column_names + end + end + RUBY + end + + # Post is fully editable but permits only title and body, so the suite can + # prove an unpermitted attribute (slug) is dropped rather than written. + # Author registers no update action, so the suite can prove update refuses + # a resource the admin UI would not let you edit either. + def write_admin_registrations + File.write(File.join(APP_PATH, "app/admin/posts.rb"), <<~RUBY) + # frozen_string_literal: true + + ActiveAdmin.register Post do + permit_params :title, :body + end + RUBY + + File.write(File.join(APP_PATH, "app/admin/authors.rb"), <<~RUBY) + # frozen_string_literal: true + + ActiveAdmin.register Author do + actions :index, :show + end + RUBY + end + + def install_mcp + run!(["bin/rails", "generate", "activeadmin_mcp:install", "--auth", "devise_token"]) + end + + # ActiveAdmin's installer creates AdminUser, but the gem defaults + # user_class to "User"; without this the ApiToken association never + # resolves and every authenticated request fails. + def configure_mcp + path = File.join(APP_PATH, "config/initializers/activeadmin_mcp.rb") + contents = File.read(path).sub('# config.user_class = "User"', 'config.user_class = "AdminUser"') + + unless contents.include?('config.user_class = "AdminUser"') + raise BuildError, "Could not set user_class in #{path}" + end + + File.write(path, contents) + end + + def migrate + run!(["bin/rails", "db:migrate"]) + end + + def seed + run!(["bin/rails", "runner", SEED_SCRIPT]) + end + + SEED_SCRIPT = <<~RUBY + AdminUser.find_or_create_by!(email: "#{ADMIN_EMAIL}") do |user| + user.password = "#{ADMIN_PASSWORD}" + user.password_confirmation = "#{ADMIN_PASSWORD}" + end + + Author.find_or_create_by!(email: "ursula@example.com") { |a| a.name = "Ursula" } + Author.find_or_create_by!(email: "terry@example.com") { |a| a.name = "Terry" } + + Post.find_or_create_by!(slug: "a-wizard-of-earthsea") do |p| + p.title = "A Wizard of Earthsea" + p.body = "The first." + end + Post.find_or_create_by!(slug: "the-tombs-of-atuan") do |p| + p.title = "The Tombs of Atuan" + p.body = "The second." + end + Post.find_or_create_by!(slug: "small-gods") do |p| + p.title = "Small Gods" + p.body = "Unrelated." + end + RUBY + end +end From e86519aad2d4c66dc847584c4a3eb09a47a3cd00 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Fri, 18 Sep 2026 20:31:29 +0100 Subject: [PATCH 02/14] Fix e2e rake task to not load the unit suite's spec_helper RSpec's --require option is additive, so the e2e task's own --require was merging with the project .rspec's --require spec_helper rather than replacing it, silently loading the unit suite's in-memory SQLite setup and after-hook during every e2e run. Switch to --options spec/e2e/e2e.opts, which replaces the project .rspec wholesale instead of merging with it, and drop the now-unneeded --exclude-pattern override. Co-Authored-By: Claude Opus 5 (1M context) --- Rakefile | 5 ++++- spec/e2e/e2e.opts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 spec/e2e/e2e.opts diff --git a/Rakefile b/Rakefile index c1ba162..ffa819b 100644 --- a/Rakefile +++ b/Rakefile @@ -8,7 +8,10 @@ RSpec::Core::RakeTask.new(:spec) desc "Run the end-to-end suite against a generated ActiveAdmin application" RSpec::Core::RakeTask.new(:e2e) do |t| t.pattern = "spec/e2e/**/*_spec.rb" - t.rspec_opts = "--require ./spec/e2e/e2e_helper.rb --format documentation --exclude-pattern ''" + # --options replaces the project .rspec wholesale rather than merging with + # it, so the project's --require spec_helper and --exclude-pattern never + # apply here: this suite loads only spec/e2e/e2e_helper.rb. + t.rspec_opts = "--options spec/e2e/e2e.opts" end task default: :spec diff --git a/spec/e2e/e2e.opts b/spec/e2e/e2e.opts new file mode 100644 index 0000000..56d5410 --- /dev/null +++ b/spec/e2e/e2e.opts @@ -0,0 +1,2 @@ +--require ./spec/e2e/e2e_helper.rb +--format documentation From 6f208f5194d2a83887969cb30ea98b5672d4229f Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Fri, 18 Sep 2026 20:38:06 +0100 Subject: [PATCH 03/14] Boot the e2e application and speak MCP to it Adds a Puma harness, a Net::HTTP JSON-RPC client, and specs covering the initialize handshake, the advertised tool list, and Bearer token authentication against a live server. Also pins the json gem below 2.9 in the generated app's Gemfile: Rails 7.2's ActiveSupport::JSON.decode still calls JSON.parse with the quirks_mode keyword, which json >= 2.9.0 removed, so every request with a JSON body 500'd until this was pinned back. Co-Authored-By: Claude Opus 5 (1M context) --- spec/e2e/e2e_helper.rb | 15 ++++ spec/e2e/mcp_server_spec.rb | 43 ++++++++++++ spec/e2e/support/app_builder.rb | 4 ++ spec/e2e/support/app_server.rb | 120 ++++++++++++++++++++++++++++++++ spec/e2e/support/mcp_client.rb | 59 ++++++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 spec/e2e/mcp_server_spec.rb create mode 100644 spec/e2e/support/app_server.rb create mode 100644 spec/e2e/support/mcp_client.rb diff --git a/spec/e2e/e2e_helper.rb b/spec/e2e/e2e_helper.rb index e9d4ec0..4d4d174 100644 --- a/spec/e2e/e2e_helper.rb +++ b/spec/e2e/e2e_helper.rb @@ -2,7 +2,16 @@ require "rspec" +require_relative "../../lib/activeadmin_mcp/version" require_relative "support/app_builder" +require_relative "support/app_server" +require_relative "support/mcp_client" + +module E2E + class << self + attr_accessor :token + end +end RSpec.configure do |config| config.expect_with :rspec do |expectations| @@ -14,5 +23,11 @@ config.before(:suite) do E2E::AppBuilder.build! + E2E::AppServer.start! + E2E.token = E2E::AppServer.mint_token! + end + + config.after(:suite) do + E2E::AppServer.stop! end end diff --git a/spec/e2e/mcp_server_spec.rb b/spec/e2e/mcp_server_spec.rb new file mode 100644 index 0000000..8556d54 --- /dev/null +++ b/spec/e2e/mcp_server_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSpec.describe "the MCP server" do + let(:url) { E2E::AppServer.instance.mcp_url } + let(:client) { E2E::McpClient.new(url: url, token: E2E.token) } + + describe "the handshake" do + it "reports the protocol version and server info" do + result = client.initialize_session + + expect(result["protocolVersion"]).to eq("2025-06-18") + expect(result["serverInfo"]["name"]).to eq("activeadmin-mcp") + expect(result["serverInfo"]["version"]).to eq(ActiveadminMcp::VERSION) + expect(result["capabilities"]).to have_key("tools") + end + + it "advertises the three tools" do + names = client.tools_list["tools"].map { |tool| tool["name"] } + + expect(names).to contain_exactly("list_resources", "query", "update") + end + end + + describe "authentication" do + it "rejects a request with no token" do + response = E2E::McpClient.new(url: url).post("initialize") + + expect(response.code).to eq("401") + expect(JSON.parse(response.body).dig("error", "code")).to eq(-32_000) + end + + it "rejects a request with an unrecognised token" do + response = E2E::McpClient.new(url: url, token: "aamcp_not_a_real_token").post("initialize") + + expect(response.code).to eq("401") + expect(JSON.parse(response.body).dig("error", "code")).to eq(-32_000) + end + + it "accepts a request with the minted token" do + expect(client.post("initialize").code).to eq("200") + end + end +end diff --git a/spec/e2e/support/app_builder.rb b/spec/e2e/support/app_builder.rb index 9bc160f..68c65f4 100644 --- a/spec/e2e/support/app_builder.rb +++ b/spec/e2e/support/app_builder.rb @@ -21,10 +21,14 @@ class AppBuilder ADMIN_EMAIL = "admin@example.com" ADMIN_PASSWORD = "password" + # Rails 7.2's ActiveSupport::JSON.decode still calls JSON.parse with the + # quirks_mode keyword, which the json gem dropped in 2.9.0. Any request + # with a JSON body 500s until this is pinned back below that line. GEMFILE_ADDITIONS = <<~RUBY gem "activeadmin", "~> 3.2" gem "devise" gem "sassc-rails" + gem "json", "< 2.9" gem "activeadmin_mcp", path: "GEM_PATH" RUBY diff --git a/spec/e2e/support/app_server.rb b/spec/e2e/support/app_server.rb new file mode 100644 index 0000000..1ecb1a0 --- /dev/null +++ b/spec/e2e/support/app_server.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "fileutils" +require "net/http" +require "socket" +require "timeout" + +module E2E + # Boots the generated application under Puma and tears it down again. + class AppServer + BOOT_TIMEOUT = 60 + LOG_PATH = File.join(AppBuilder::APP_PATH, "log", "e2e.log") + + MINT_SCRIPT = <<~RUBY + user = AdminUser.find_by!(email: "#{AppBuilder::ADMIN_EMAIL}") + puts ActiveadminMcp::ApiToken.create!(user: user, name: "e2e").raw_token + RUBY + + class BootError < StandardError; end + + attr_reader :port, :pid + + class << self + attr_reader :instance + + def start! + @instance ||= new.tap(&:start!) + end + + def stop! + @instance&.stop! + @instance = nil + end + + # Creates an API token for the seeded admin user and returns the raw + # value, which the model only exposes at creation time. + def mint_token! + output = AppBuilder.run!(["bin/rails", "runner", MINT_SCRIPT]) + token = output[/aamcp_[0-9a-f]{64}/] + + raise BootError, "Could not mint an API token:\n\n#{output}" unless token + + token + end + end + + def initialize + @port = free_port + end + + def start! + FileUtils.mkdir_p(File.dirname(LOG_PATH)) + FileUtils.rm_f(LOG_PATH) + + Bundler.with_unbundled_env do + @pid = Process.spawn( + { "RAILS_ENV" => "development" }, + "bin/rails", "server", "-p", port.to_s, "-b", "127.0.0.1", + chdir: AppBuilder::APP_PATH, + out: LOG_PATH, + err: [LOG_PATH, "a"] + ) + end + + at_exit { stop! } + wait_for_boot! + self + end + + def stop! + return unless @pid + + Process.kill("TERM", @pid) + Process.wait(@pid) + rescue Errno::ESRCH, Errno::ECHILD + nil # Already gone. + ensure + @pid = nil + end + + def base_url + "http://127.0.0.1:#{port}" + end + + def mcp_url + "#{base_url}/mcp" + end + + private + + def free_port + server = TCPServer.new("127.0.0.1", 0) + server.addr[1].tap { server.close } + end + + # Polls the mount point rather than the root path: a 401 or a JSON-RPC + # error both mean the engine is mounted and answering, which is all the + # suite needs to know before it starts. + def wait_for_boot! + Timeout.timeout(BOOT_TIMEOUT) do + loop do + begin + Net::HTTP.post(URI(mcp_url), "{}", "Content-Type" => "application/json") + return + rescue Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError + sleep 0.5 + end + end + end + rescue Timeout::Error + raise BootError, "Server did not boot within #{BOOT_TIMEOUT}s. Log tail:\n\n#{log_tail}" + end + + def log_tail + return "(no log at #{LOG_PATH})" unless File.exist?(LOG_PATH) + + File.readlines(LOG_PATH).last(40).join + end + end +end diff --git a/spec/e2e/support/mcp_client.rb b/spec/e2e/support/mcp_client.rb new file mode 100644 index 0000000..3c3168d --- /dev/null +++ b/spec/e2e/support/mcp_client.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "uri" + +module E2E + # A minimal MCP client: JSON-RPC 2.0 over HTTP, the way a real client such + # as Claude Code talks to the mounted engine. + class McpClient + class ProtocolError < StandardError; end + + def initialize(url:, token: nil) + @uri = URI(url) + @token = token + @id = 0 + end + + def initialize_session + result_of("initialize", protocolVersion: "2025-06-18", clientInfo: { name: "e2e", version: "1.0" }) + end + + def tools_list + result_of("tools/list") + end + + # Tool results arrive as a pretty-printed JSON document inside a text + # content block, so unwrap both layers and hand back the payload itself. + def call_tool(name, arguments = {}) + result = result_of("tools/call", name: name, arguments: arguments) + text = result.dig("content", 0, "text") + + raise ProtocolError, "No text content in tool result: #{result.inspect}" unless text + + JSON.parse(text) + end + + def post(method, params = {}) + @id += 1 + request = Net::HTTP::Post.new(@uri) + request["Content-Type"] = "application/json" + request["Authorization"] = "Bearer #{@token}" if @token + request.body = JSON.generate(jsonrpc: "2.0", id: @id, method: method, params: params) + + Net::HTTP.start(@uri.hostname, @uri.port) { |http| http.request(request) } + end + + private + + def result_of(method, params = {}) + response = post(method, params) + body = JSON.parse(response.body) + + raise ProtocolError, "JSON-RPC error from #{method}: #{body['error'].inspect}" if body["error"] + + body.fetch("result") + end + end +end From 7f67b5f7e29c32ea4f5264954c114ef5f87b25f2 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Fri, 18 Sep 2026 20:50:33 +0100 Subject: [PATCH 04/14] Cover the MCP tools end to end Exercises list_resources, query and update against the live server, including that unpermitted attributes are dropped and that a resource without an update action is refused. Co-Authored-By: Claude Opus 5 (1M context) --- spec/e2e/mcp_tools_spec.rb | 97 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 spec/e2e/mcp_tools_spec.rb diff --git a/spec/e2e/mcp_tools_spec.rb b/spec/e2e/mcp_tools_spec.rb new file mode 100644 index 0000000..ebf5db4 --- /dev/null +++ b/spec/e2e/mcp_tools_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +RSpec.describe "the MCP tools" do + let(:client) { E2E::McpClient.new(url: E2E::AppServer.instance.mcp_url, token: E2E.token) } + + describe "list_resources" do + it "lists the registered resources with their attributes" do + resources = client.call_tool("list_resources")["resources"] + + expect(resources.map { |resource| resource["name"] }).to include("Post", "Author") + + post = resources.find { |resource| resource["name"] == "Post" } + expect(post["table"]).to eq("posts") + expect(post["attributes"]).to include("title", "body", "slug") + end + + it "omits sensitive attributes from the admin user" do + resources = client.call_tool("list_resources")["resources"] + admin_user = resources.find { |resource| resource["name"] == "AdminUser" } + + expect(admin_user["attributes"]).not_to include("encrypted_password") + expect(admin_user["attributes"]).not_to include("reset_password_token") + end + end + + describe "query" do + it "filters with Ransack syntax" do + result = client.call_tool("query", resource: "Post", q: { title_cont: "Earthsea" }) + + expect(result["count"]).to eq(1) + expect(result["records"].first["title"]).to eq("A Wizard of Earthsea") + end + + it "returns every record when no query is given" do + result = client.call_tool("query", resource: "Post") + + expect(result["count"]).to eq(3) + end + + it "honours the limit" do + result = client.call_tool("query", resource: "Post", limit: 1) + + expect(result["count"]).to eq(1) + end + + it "reports an unregistered resource rather than raising" do + result = client.call_tool("query", resource: "Nonexistent") + + expect(result["error"]).to eq("Resource not found: Nonexistent") + end + end + + describe "update" do + let(:post_id) do + client.call_tool("query", resource: "Post", q: { slug_eq: "small-gods" })["records"].first["id"] + end + + it "updates a permitted attribute and persists it" do + result = client.call_tool("update", resource: "Post", id: post_id, attributes: { title: "Pyramids" }) + + expect(result["error"]).to be_nil + expect(result["updated"]).to eq(["title"]) + + reread = client.call_tool("query", resource: "Post", q: { id_eq: post_id })["records"].first + expect(reread["title"]).to eq("Pyramids") + end + + it "drops an attribute the resource does not permit" do + result = client.call_tool( + "update", + resource: "Post", + id: post_id, + attributes: { title: "Hogfather", slug: "tampered" } + ) + + expect(result["updated"]).to eq(["title"]) + + reread = client.call_tool("query", resource: "Post", q: { id_eq: post_id })["records"].first + expect(reread["title"]).to eq("Hogfather") + expect(reread["slug"]).to eq("small-gods") + end + + it "refuses a resource that does not register the update action" do + author_id = client.call_tool("query", resource: "Author", q: { name_eq: "Terry" })["records"].first["id"] + + result = client.call_tool("update", resource: "Author", id: author_id, attributes: { name: "Terence" }) + + expect(result["error"]).to eq("Resource is not editable: Author") + end + + it "reports a missing record rather than raising" do + result = client.call_tool("update", resource: "Post", id: 999_999, attributes: { title: "Nope" }) + + expect(result["error"]).to eq("Record not found: Post#999999") + end + end +end From ebd6355ed55c4baba18f437581228c0208128903 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Fri, 18 Sep 2026 20:54:25 +0100 Subject: [PATCH 05/14] Run the e2e suite in CI Adds an e2e job alongside rspec and mcpb, caching the generated host application on the builder's hash and uploading the Rails log when the suite fails. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7bd51e..5a78ecb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,3 +45,38 @@ jobs: with: name: activeadmin-mcp-mcpb path: activeadmin-mcp.mcpb + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + # The generated host application is expensive to build (a full + # `bundle install` against rubygems.org) and its contents are decided + # by the builder source, so that is what the key hashes. Bump the v1 + # prefix by hand when a new Rails or ActiveAdmin release changes what + # the generators produce without the builder itself changing. + - name: Cache the generated host application + uses: actions/cache@v4 + with: + path: tmp/e2e_app + key: e2e-app-v1-${{ runner.os }}-${{ hashFiles('spec/e2e/support/app_builder.rb') }} + + - name: Run the end-to-end suite + run: bundle exec rake e2e + + - name: Upload the application log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-rails-log + path: tmp/e2e_app/log/e2e.log + if-no-files-found: ignore From 5a5fb6cb884e06778bc21e7762a2912baae34a69 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Fri, 18 Sep 2026 21:08:53 +0100 Subject: [PATCH 06/14] Fix e2e suite review findings: cache correctness, seed durability, log integrity - Vendor the generated app's bundle inside tmp/e2e_app and run bundle install/seed on every build (including the cached path), so a CI cache hit restores a runnable app instead of one missing its gems. - Make seed data restorative (find_or_initialize_by + save!) and run it on every invocation, so mutations from the update examples don't leak between runs via the local or CI cache. - Stop stdout/stderr from clobbering each other in the Puma log, since the log tail is the only diagnostic for a boot failure. - Validate wait_for_boot!'s response (401, or 200 JSON) instead of accepting any HTTP answer, and widen its rescued exceptions. - Require "bundler" explicitly; drop two assertions in app_install_spec.rb that couldn't fail independently of the builder; assert records.length alongside count in the limit example; document the Ransack allowlist requirement and the e2e suite itself in the README. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 36 +++++++++++++++- spec/e2e/app_install_spec.rb | 13 +++--- spec/e2e/mcp_tools_spec.rb | 10 +++++ spec/e2e/support/app_builder.rb | 74 +++++++++++++++++++++++---------- spec/e2e/support/app_server.rb | 23 +++++++--- 5 files changed, 121 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 46b1301..51cd696 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,11 @@ The server is a Rails engine mounted inside your application (by default at are the ones you have already configured. - **Queries use Ransack.** The `query` tool passes its arguments straight to [Ransack](https://activerecord-hackery.github.io/ransack/), the same search - library ActiveAdmin uses for filtering. + library ActiveAdmin uses for filtering. The tool calls `ransack` on the + model directly rather than going through an ActiveAdmin filter form, so on + Ransack 4 the model must allowlist the attributes it wants queryable via + `ransackable_attributes`; without that allowlist, `query` against that + resource will raise instead of returning an empty result. - **Reads go through ActiveAdmin too.** `list_resources` and `query` run through the same authorization adapter (CanCanCan, Pundit, etc.) as the authenticated MCP user: resources the user cannot read are hidden from the listing and @@ -344,6 +348,36 @@ bundle install bundle exec rspec ``` +### Running the tests + +`bundle exec rspec` (or `rake spec`, the default rake task) runs the unit +suite only — it's fast, and it's what CI's `rspec` job runs. It never touches +`spec/e2e`; that directory is excluded via `.rspec`. + +The end-to-end suite lives in `spec/e2e` and runs separately: + +```bash +bundle exec rake e2e +``` + +This generates a real Rails 7.2 + ActiveAdmin + Devise application under +`tmp/e2e_app`, installs the gem into it with `path:`, boots it under Puma, +and drives it over real HTTP with a minimal JSON-RPC client — the same way +Claude Code or any other MCP client would. It's how the gem is tested +against ActiveAdmin's and Ransack's actual behaviour rather than mocks. + +The generated application is expensive to build (a full `bundle install` +against rubygems.org, and a `gem install rails` if Rails 7.2.2.2 isn't +already on your system) so it's cached in `tmp/e2e_app` between runs, keyed +on the contents of `spec/e2e/support/app_builder.rb`. The first run costs a +few minutes and needs network access; edit that file and the next run +rebuilds from scratch, otherwise reruns are quick. Force a rebuild without +editing anything by setting `E2E_REBUILD=1`: + +```bash +E2E_REBUILD=1 bundle exec rake e2e +``` + ## Contributing Bug reports and pull requests are welcome on GitHub. diff --git a/spec/e2e/app_install_spec.rb b/spec/e2e/app_install_spec.rb index a97227f..89a2fab 100644 --- a/spec/e2e/app_install_spec.rb +++ b/spec/e2e/app_install_spec.rb @@ -8,11 +8,15 @@ expect(File).to exist(File.join(app_path, "app/admin/dashboard.rb")) end - it "has the gem's initializer, with the user class pointed at AdminUser" do + # The user_class substitution isn't asserted here: AppBuilder#configure_mcp + # already raises BuildError at build time if it fails, so an assertion on + # its result here could never fail independently of the builder. The + # authentication_method line below is a genuine assertion on the + # installer's own `gsub_file` behaviour, so it stays. + it "has the gem's initializer, with devise_token authentication enabled" do initializer = File.read(File.join(app_path, "config/initializers/activeadmin_mcp.rb")) expect(initializer).to include("config.authentication_method = :devise_token") - expect(initializer).to include('config.user_class = "AdminUser"') end it "has the token migration and the MCP Tokens admin page from the installer" do @@ -22,11 +26,6 @@ expect(File).to exist(File.join(app_path, "app/admin/mcp_api_tokens.rb")) end - it "has the fixture resources registered" do - expect(File).to exist(File.join(app_path, "app/admin/posts.rb")) - expect(File).to exist(File.join(app_path, "app/admin/authors.rb")) - end - it "has migrated the fixture tables" do schema = File.read(File.join(app_path, "db/schema.rb")) diff --git a/spec/e2e/mcp_tools_spec.rb b/spec/e2e/mcp_tools_spec.rb index ebf5db4..0dade81 100644 --- a/spec/e2e/mcp_tools_spec.rb +++ b/spec/e2e/mcp_tools_spec.rb @@ -41,8 +41,18 @@ result = client.call_tool("query", resource: "Post", limit: 1) expect(result["count"]).to eq(1) + expect(result["records"].length).to eq(1) end + # No example asserts the documented 100-record cap (README: "capped at + # 100"). With only three Posts seeded, any assertion on the result of a + # limit above 100 (count, records.length, or absence of an error) would + # be identical whether the clamp fired or not, so it would pass whether + # or not the clamp exists - see the final-fix-report for the fuller + # reasoning. Demonstrating the clamp for real needs >100 seeded records, + # which is a real cost (seed script size, migrate/seed time on every + # run) we're not paying just for this. + it "reports an unregistered resource rather than raising" do result = client.call_tool("query", resource: "Nonexistent") diff --git a/spec/e2e/support/app_builder.rb b/spec/e2e/support/app_builder.rb index 68c65f4..a65330b 100644 --- a/spec/e2e/support/app_builder.rb +++ b/spec/e2e/support/app_builder.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "bundler" require "digest" require "fileutils" require "open3" @@ -58,13 +59,22 @@ def run!(command, chdir: APP_PATH) end def build! - return APP_PATH if cached? + if cached? + # The bundle lives inside the cached directory (vendor/bundle), but a + # cache restore does not guarantee it is satisfied for this machine, + # and seed data may have been mutated by the previous run's `update` + # examples. Both are cheap to redo when already correct. + bundle_install + seed + return APP_PATH + end FileUtils.rm_rf(APP_PATH) FileUtils.mkdir_p(File.dirname(APP_PATH)) generate_app write_gemfile + vendor_bundle_path bundle_install install_active_admin generate_fixture_models @@ -136,6 +146,15 @@ def write_gemfile end end + # Vendors the generated app's gems inside APP_PATH itself, rather than the + # system gem home, so that caching tmp/e2e_app (as CI does) actually + # caches a runnable app. Without this, Bundler.with_unbundled_env strips + # BUNDLE_* and the gems installed by `bundle_install` land outside + # whatever directory gets cached. + def vendor_bundle_path + run!(["bundle", "config", "set", "--local", "path", "vendor/bundle"]) + end + def bundle_install run!(["bundle", "install"]) end @@ -224,27 +243,40 @@ def seed run!(["bin/rails", "runner", SEED_SCRIPT]) end + # Restorative rather than create-only: this runs on every suite run + # (cached or not), so it must reset any row the `update` examples + # mutated (e.g. the small-gods post's title) back to its seeded values, + # not just create the row if missing. Records are looked up by slug + # (posts) or email (authors/admin user) so ids and slugs stay stable + # across runs, which the `update` examples rely on. SEED_SCRIPT = <<~RUBY - AdminUser.find_or_create_by!(email: "#{ADMIN_EMAIL}") do |user| - user.password = "#{ADMIN_PASSWORD}" - user.password_confirmation = "#{ADMIN_PASSWORD}" - end - - Author.find_or_create_by!(email: "ursula@example.com") { |a| a.name = "Ursula" } - Author.find_or_create_by!(email: "terry@example.com") { |a| a.name = "Terry" } - - Post.find_or_create_by!(slug: "a-wizard-of-earthsea") do |p| - p.title = "A Wizard of Earthsea" - p.body = "The first." - end - Post.find_or_create_by!(slug: "the-tombs-of-atuan") do |p| - p.title = "The Tombs of Atuan" - p.body = "The second." - end - Post.find_or_create_by!(slug: "small-gods") do |p| - p.title = "Small Gods" - p.body = "Unrelated." - end + user = AdminUser.find_or_initialize_by(email: "#{ADMIN_EMAIL}") + user.password = "#{ADMIN_PASSWORD}" + user.password_confirmation = "#{ADMIN_PASSWORD}" + user.save! + + ursula = Author.find_or_initialize_by(email: "ursula@example.com") + ursula.name = "Ursula" + ursula.save! + + terry = Author.find_or_initialize_by(email: "terry@example.com") + terry.name = "Terry" + terry.save! + + earthsea = Post.find_or_initialize_by(slug: "a-wizard-of-earthsea") + earthsea.title = "A Wizard of Earthsea" + earthsea.body = "The first." + earthsea.save! + + atuan = Post.find_or_initialize_by(slug: "the-tombs-of-atuan") + atuan.title = "The Tombs of Atuan" + atuan.body = "The second." + atuan.save! + + small_gods = Post.find_or_initialize_by(slug: "small-gods") + small_gods.title = "Small Gods" + small_gods.body = "Unrelated." + small_gods.save! RUBY end end diff --git a/spec/e2e/support/app_server.rb b/spec/e2e/support/app_server.rb index 1ecb1a0..a23a194 100644 --- a/spec/e2e/support/app_server.rb +++ b/spec/e2e/support/app_server.rb @@ -57,8 +57,8 @@ def start! { "RAILS_ENV" => "development" }, "bin/rails", "server", "-p", port.to_s, "-b", "127.0.0.1", chdir: AppBuilder::APP_PATH, - out: LOG_PATH, - err: [LOG_PATH, "a"] + out: [LOG_PATH, "a"], + err: [:child, :out] ) end @@ -95,14 +95,19 @@ def free_port # Polls the mount point rather than the root path: a 401 or a JSON-RPC # error both mean the engine is mounted and answering, which is all the - # suite needs to know before it starts. + # suite needs to know before it starts. Rails booting but the engine + # failing to mount also answers HTTP requests (with an HTML 404), so a + # response alone is not enough evidence — it must look like the engine, + # not just like a webserver. def wait_for_boot! Timeout.timeout(BOOT_TIMEOUT) do loop do begin - Net::HTTP.post(URI(mcp_url), "{}", "Content-Type" => "application/json") - return - rescue Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError + response = Net::HTTP.post(URI(mcp_url), "{}", "Content-Type" => "application/json") + return if engine_response?(response) + + sleep 0.5 + rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, EOFError, SocketError sleep 0.5 end end @@ -111,6 +116,12 @@ def wait_for_boot! raise BootError, "Server did not boot within #{BOOT_TIMEOUT}s. Log tail:\n\n#{log_tail}" end + def engine_response?(response) + return true if response.code == "401" + + response.code == "200" && response["Content-Type"].to_s.include?("application/json") + end + def log_tail return "(no log at #{LOG_PATH})" unless File.exist?(LOG_PATH) From 0212b6284dfd16b1e165a705a0088c2f5b7d548e Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Sat, 19 Sep 2026 08:33:09 +0100 Subject: [PATCH 07/14] Require end-to-end coverage for MCP actions Adds a CLAUDE.md recording that every MCP action needs an e2e test, not just unit specs: nearly everything this gem does is a claim about code it does not own (ActiveAdmin's permitted params, the host's authorization adapter, Ransack, the host route set), and a mock cannot show those actually behave as expected when wired together. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7c706b9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,43 @@ +# CLAUDE.md + +Guidance for AI assistants working in this repository. + +## What this is + +`activeadmin_mcp` is a Rails engine that exposes an application's existing +ActiveAdmin resources over the Model Context Protocol. It speaks JSON-RPC 2.0 +over HTTP and is mounted at `/mcp` by default. The tools it offers — +`list_resources`, `query`, `update` — are defined in +`lib/activeadmin_mcp/request_handler.rb`. + +## Testing MCP actions + +**Every MCP action must be covered by an end-to-end test, not only by unit +specs.** This applies to new tools, new arguments on an existing tool, and any +change to how an existing tool authorizes, filters, or writes data. + +The reason is specific to this gem: almost everything it does is a claim about +code it does not own. `permit_params` is resolved by instantiating the real +ActiveAdmin controller and reading its compiled permitted params; authorization +runs through whichever adapter the host application configured; `query` hands +its arguments to Ransack; the engine mounts itself into the host's route set. +A unit spec can only assert that we called a mock the way we expected to — it +cannot show that ActiveAdmin, Ransack, Devise and Rails actually behave that +way when wired together. Several defects in this repository's history were +invisible to the unit suite for exactly that reason. + +The e2e suite lives in `spec/e2e/`. It generates a real Rails + ActiveAdmin + +Devise application into `tmp/e2e_app`, installs this gem into it through the +gem's own generator, boots it under Puma, and drives the mounted endpoint over +HTTP with a real API token. + + bundle exec rake spec # unit specs, fast + bundle exec rake e2e # end-to-end suite + +`rake e2e` caches the generated application, so only the first run is slow. +`E2E_REBUILD=1` forces a rebuild. See the README's "Running the tests" section. + +When you add an e2e example, make sure it can actually fail. Change the +generated application so the behaviour under test is wrong, watch the example +fail, then restore it. An end-to-end suite that passes regardless of what the +code does is worse than no suite, because it is believed. From 3b51e637d672105c252ce44b21beee6902ada8ea Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Sat, 19 Sep 2026 08:36:06 +0100 Subject: [PATCH 08/14] Run CI on Ruby 4.0.7 Bumps all three jobs and the release workflow from 3.4 to the latest Ruby 4 release. The gemspec's required_ruby_version is deliberately left at ">= 3.0.0": that is the floor for consumers of the gem, and raising it would drop every host application still on Ruby 3. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a78ecb..bf603b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "3.4" + ruby-version: "4.0.7" bundler-cache: true - name: Run specs @@ -56,7 +56,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "3.4" + ruby-version: "4.0.7" bundler-cache: true # The generated host application is expensive to build (a full diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 121d3c8..4838af8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "3.4" + ruby-version: "4.0.7" bundler-cache: true - name: Write version file From 66aa618382cbd09256601a415137cbf745205466 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Sat, 19 Sep 2026 08:38:31 +0100 Subject: [PATCH 09/14] Require Ruby 4.0 or later Raises required_ruby_version from ">= 3.0.0" to ">= 4.0.0", with the README's Requirements list and a breaking-change CHANGELOG entry to match. This drops support for host applications on Ruby 3.x. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++++++ README.md | 2 +- activeadmin_mcp.gemspec | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78dbb1c..3a3d330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Breaking:** the minimum supported Ruby is now 4.0. Applications on Ruby 3.x + must stay on the previous release until they upgrade. CI and the release + workflow now run on Ruby 4.0.7. + ### Security - Enforce ActiveAdmin authorization on reads. `list_resources` and `query` diff --git a/README.md b/README.md index 51cd696..594c8c0 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The server is a Rails engine mounted inside your application (by default at ## Requirements -- Ruby >= 3.0 +- Ruby >= 4.0 - Rails >= 6.1 - ActiveAdmin >= 2.0 diff --git a/activeadmin_mcp.gemspec b/activeadmin_mcp.gemspec index 0b34b05..60e1508 100644 --- a/activeadmin_mcp.gemspec +++ b/activeadmin_mcp.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.description = "Expose your ActiveAdmin resources to AI assistants via the Model Context Protocol (MCP)." spec.homepage = "https://github.com/OLIOEX/activeadmin_mcp" spec.license = "MIT" - spec.required_ruby_version = ">= 3.0.0" + spec.required_ruby_version = ">= 4.0.0" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage From fa6773ad419529baedcb9a8eec096c79875598dc Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Sat, 19 Sep 2026 08:58:19 +0100 Subject: [PATCH 10/14] Keep the e2e fixture application on disk, not in heredocs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ActiveAdmin registrations, model allowlists, migrations, seeds and Gemfile additions the e2e suite tests against were written from heredocs inside the builder, where nobody could read them. They are now checked in under spec/e2e/fixture_app/ and copied over the generated application, so the example app can be inspected and edited directly. The migrations in particular were previously invisible: they came from `rails generate model` with build-time timestamps. Checking them in with fixed versions makes the schema under test both visible and stable, and drops two generator shell-outs from the build. The cache fingerprint now covers the fixture tree as well as the builder. Without that, moving the fixtures out of the builder's own digest would have meant an edited fixture was silently ignored on the next run — the exact failure this suite exists to catch. Also snapshots the SQLite database after migrating and seeding, and restores it by file copy on cached runs rather than booting Rails to re-seed. Cached runs go from 4.2s to 3.4s. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 15 +- spec/e2e/fixture_app/Gemfile.deps | 14 ++ spec/e2e/fixture_app/README.md | 45 ++++ spec/e2e/fixture_app/app/admin/authors.rb | 7 + spec/e2e/fixture_app/app/admin/posts.rb | 7 + spec/e2e/fixture_app/app/models/author.rb | 8 + spec/e2e/fixture_app/app/models/post.rb | 10 + .../migrate/20260101000001_create_authors.rb | 12 ++ .../db/migrate/20260101000002_create_posts.rb | 13 ++ spec/e2e/fixture_app/db/seeds.rb | 37 ++++ spec/e2e/support/app_builder.rb | 203 ++++++++---------- 11 files changed, 252 insertions(+), 119 deletions(-) create mode 100644 spec/e2e/fixture_app/Gemfile.deps create mode 100644 spec/e2e/fixture_app/README.md create mode 100644 spec/e2e/fixture_app/app/admin/authors.rb create mode 100644 spec/e2e/fixture_app/app/admin/posts.rb create mode 100644 spec/e2e/fixture_app/app/models/author.rb create mode 100644 spec/e2e/fixture_app/app/models/post.rb create mode 100644 spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb create mode 100644 spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb create mode 100644 spec/e2e/fixture_app/db/seeds.rb diff --git a/CLAUDE.md b/CLAUDE.md index 7c706b9..67ab673 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,14 @@ HTTP with a real API token. `rake e2e` caches the generated application, so only the first run is slow. `E2E_REBUILD=1` forces a rebuild. See the README's "Running the tests" section. -When you add an e2e example, make sure it can actually fail. Change the -generated application so the behaviour under test is wrong, watch the example -fail, then restore it. An end-to-end suite that passes regardless of what the -code does is worse than no suite, because it is believed. +The application it generates is not written from heredocs: the ActiveAdmin +registrations, models, migrations and seeds it uses are checked in under +`spec/e2e/fixture_app/` and copied into place, so you can read and edit the +thing the suite tests against. Any static file you need to add to that +application belongs there too, not in a heredoc inside the builder. + +When you add an e2e example, make sure it can actually fail. Edit the relevant +file under `spec/e2e/fixture_app/` so the behaviour under test is wrong, watch +the example fail, then restore it — editing a fixture invalidates the build +cache, so the next run picks it up. An end-to-end suite that passes regardless +of what the code does is worse than no suite, because it is believed. diff --git a/spec/e2e/fixture_app/Gemfile.deps b/spec/e2e/fixture_app/Gemfile.deps new file mode 100644 index 0000000..48343c1 --- /dev/null +++ b/spec/e2e/fixture_app/Gemfile.deps @@ -0,0 +1,14 @@ +# Appended to the generated application's Gemfile by AppBuilder#write_gemfile. +# GEM_PATH is substituted with this repository's root, so the application +# always loads the gem from the working tree rather than from RubyGems. + +gem "activeadmin", "~> 3.2" +gem "devise" +gem "sassc-rails" + +# Rails 7.2's ActiveSupport::JSON.decode still calls JSON.parse with the +# quirks_mode keyword, which the json gem dropped in 2.9.0. Any request with a +# JSON body 500s until this is pinned back below that line. +gem "json", "< 2.9" + +gem "activeadmin_mcp", path: "GEM_PATH" diff --git a/spec/e2e/fixture_app/README.md b/spec/e2e/fixture_app/README.md new file mode 100644 index 0000000..e735557 --- /dev/null +++ b/spec/e2e/fixture_app/README.md @@ -0,0 +1,45 @@ +# Fixture application + +These files are copied over the Rails application the e2e suite generates +into `tmp/e2e_app`, replacing what the Rails and ActiveAdmin generators +produce. They are checked in rather than written from heredocs in +`../support/app_builder.rb` so that you can read, diff and edit the thing the +suite actually tests against. + +The layout mirrors the generated application, so a file here lands at the same +path there. + +Each one exists to give a claim in the README something to bite on: + +- `app/admin/posts.rb` permits `title` and `body` but **not** `slug`, so the + suite can prove an unpermitted attribute is dropped rather than written. +- `app/admin/authors.rb` registers `actions :index, :show`, so the suite can + prove `update` refuses a resource the admin UI would not let you edit. +- `app/models/*.rb` allowlist `ransackable_attributes`, which Ransack 4 + requires before it will filter on an attribute at all. +- `db/migrate/*.rb` create the `authors` and `posts` tables. They are checked + in with fixed version numbers rather than produced by `rails generate model`, + so the schema under test is visible and does not change from run to run. +- `db/seeds.rb` is restorative: it resets existing rows rather than only + creating missing ones, because the suite's `update` examples mutate a post + and the generated application is cached between runs. It reads the admin + credentials from the environment so that they are defined in exactly one + place, `AppBuilder`. + +Two files here are not copied into the application: + +- `README.md`, this file. +- `Gemfile.deps`, which is appended to the Gemfile the Rails generator wrote + rather than replacing it. `GEM_PATH` in it is substituted with the + repository root. + +Editing anything here except this README changes the builder's cache +fingerprint, so the next `rake e2e` rebuilds the application rather than +silently reusing a stale one. That is what makes these files safe to +experiment with: break one deliberately, run the suite, and watch the example +that covers it fail. + +After migrating and seeding, the builder snapshots the SQLite database to +`storage/seeded.sqlite3` inside the generated application and restores it by +file copy on subsequent runs, which is why a cached run does not pay for a +Rails boot to re-seed. diff --git a/spec/e2e/fixture_app/app/admin/authors.rb b/spec/e2e/fixture_app/app/admin/authors.rb new file mode 100644 index 0000000..1e73459 --- /dev/null +++ b/spec/e2e/fixture_app/app/admin/authors.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Registered without the update action, so the e2e suite can prove the MCP +# `update` tool refuses a resource the admin UI would not let you edit either. +ActiveAdmin.register Author do + actions :index, :show +end diff --git a/spec/e2e/fixture_app/app/admin/posts.rb b/spec/e2e/fixture_app/app/admin/posts.rb new file mode 100644 index 0000000..2a05357 --- /dev/null +++ b/spec/e2e/fixture_app/app/admin/posts.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Permits title and body but deliberately not slug, so the e2e suite can prove +# the MCP `update` tool drops attributes the admin form does not accept. +ActiveAdmin.register Post do + permit_params :title, :body +end diff --git a/spec/e2e/fixture_app/app/models/author.rb b/spec/e2e/fixture_app/app/models/author.rb new file mode 100644 index 0000000..ba9a7f0 --- /dev/null +++ b/spec/e2e/fixture_app/app/models/author.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Author < ApplicationRecord + # See the note in post.rb: Ransack 4 requires an explicit allowlist. + def self.ransackable_attributes(_auth_object = nil) + column_names + end +end diff --git a/spec/e2e/fixture_app/app/models/post.rb b/spec/e2e/fixture_app/app/models/post.rb new file mode 100644 index 0000000..cd31059 --- /dev/null +++ b/spec/e2e/fixture_app/app/models/post.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Post < ApplicationRecord + # Ransack 4 refuses to filter on any attribute absent from this allowlist, + # and the MCP `query` tool calls `ransack` directly rather than going + # through an ActiveAdmin filter. + def self.ransackable_attributes(_auth_object = nil) + column_names + end +end diff --git a/spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb b/spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb new file mode 100644 index 0000000..17e40d5 --- /dev/null +++ b/spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateAuthors < ActiveRecord::Migration[7.2] + def change + create_table :authors do |t| + t.string :name + t.string :email + + t.timestamps + end + end +end diff --git a/spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb b/spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb new file mode 100644 index 0000000..a4f08c6 --- /dev/null +++ b/spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreatePosts < ActiveRecord::Migration[7.2] + def change + create_table :posts do |t| + t.string :title + t.text :body + t.string :slug + + t.timestamps + end + end +end diff --git a/spec/e2e/fixture_app/db/seeds.rb b/spec/e2e/fixture_app/db/seeds.rb new file mode 100644 index 0000000..ebf90c6 --- /dev/null +++ b/spec/e2e/fixture_app/db/seeds.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Restorative by design: the e2e suite's `update` examples rewrite a post's +# title, and the generated application is cached between runs, so seeding has +# to reset existing rows rather than only create missing ones. Records are +# keyed on stable natural keys (email, slug) that no example mutates. + +admin_email = ENV.fetch("E2E_ADMIN_EMAIL") +admin_password = ENV.fetch("E2E_ADMIN_PASSWORD") + +AdminUser.find_or_initialize_by(email: admin_email).tap do |user| + user.password = admin_password + user.password_confirmation = admin_password + user.save! +end + +{ + "ursula@example.com" => "Ursula", + "terry@example.com" => "Terry", +}.each do |email, name| + Author.find_or_initialize_by(email: email).tap do |author| + author.name = name + author.save! + end +end + +{ + "a-wizard-of-earthsea" => ["A Wizard of Earthsea", "The first."], + "the-tombs-of-atuan" => ["The Tombs of Atuan", "The second."], + "small-gods" => ["Small Gods", "Unrelated."], +}.each do |slug, (title, body)| + Post.find_or_initialize_by(slug: slug).tap do |post| + post.title = title + post.body = body + post.save! + end +end diff --git a/spec/e2e/support/app_builder.rb b/spec/e2e/support/app_builder.rb index a65330b..b30acfc 100644 --- a/spec/e2e/support/app_builder.rb +++ b/spec/e2e/support/app_builder.rb @@ -9,30 +9,38 @@ module E2E # Generates a real Rails + ActiveAdmin application with the gem under test # installed into it, and caches the result between runs. # - # The application is cached on a fingerprint of this file, so editing the - # build recipe forces a rebuild. Editing the gem's own library code does - # not, and must not: the application references the gem with `path:`, so it - # always loads the current working tree. + # The application is cached on a fingerprint of this file and of everything + # under `../fixture_app`, so editing either the build recipe or a fixture + # forces a rebuild. Editing the gem's own library code does not, and must + # not: the application references the gem with `path:`, so it always loads + # the current working tree. class AppBuilder RAILS_VERSION = "7.2.2.2" REPO_ROOT = File.expand_path("../../..", __dir__) APP_PATH = File.join(REPO_ROOT, "tmp", "e2e_app") FINGERPRINT_PATH = File.join(APP_PATH, ".e2e_fingerprint") + # Checked-in files copied over the generated application. Kept as real + # files rather than heredocs so they can be read and edited directly. + FIXTURE_APP_PATH = File.expand_path("../fixture_app", __dir__) + + # Documentation, not part of the application. + FIXTURE_DOCS = "README.md" + + # Appended to the generated Gemfile rather than copied into place. + FIXTURE_GEMFILE = "Gemfile.deps" + + DATABASE_PATH = "storage/development.sqlite3" + + # A copy of the database taken immediately after migrating and seeding. + # Restoring it is a file copy; re-seeding is a full Rails boot, so this + # is the difference between roughly a second and roughly nothing on + # every cached run. + DATABASE_SNAPSHOT_PATH = "storage/seeded.sqlite3" + ADMIN_EMAIL = "admin@example.com" ADMIN_PASSWORD = "password" - # Rails 7.2's ActiveSupport::JSON.decode still calls JSON.parse with the - # quirks_mode keyword, which the json gem dropped in 2.9.0. Any request - # with a JSON body 500s until this is pinned back below that line. - GEMFILE_ADDITIONS = <<~RUBY - gem "activeadmin", "~> 3.2" - gem "devise" - gem "sassc-rails" - gem "json", "< 2.9" - gem "activeadmin_mcp", path: "GEM_PATH" - RUBY - class BuildError < StandardError; end class << self @@ -44,12 +52,12 @@ def build! # the generated application resolves against its own Gemfile. Raises with # the combined output on failure: a silent build failure here surfaces # much later as an inscrutable boot error. - def run!(command, chdir: APP_PATH) + def run!(command, chdir: APP_PATH, env: {}) output = nil status = nil Bundler.with_unbundled_env do - output, status = Open3.capture2e(*command, chdir: chdir) + output, status = Open3.capture2e(env, *command, chdir: chdir) end return output if status.success? @@ -61,11 +69,14 @@ def run!(command, chdir: APP_PATH) def build! if cached? # The bundle lives inside the cached directory (vendor/bundle), but a - # cache restore does not guarantee it is satisfied for this machine, - # and seed data may have been mutated by the previous run's `update` - # examples. Both are cheap to redo when already correct. + # cache restore does not guarantee it is satisfied for this machine. bundle_install - seed + + # The previous run's `update` examples mutated the seed data, so the + # database has to be put back. Fall back to re-seeding when there is + # no snapshot, which is the case for a cache saved before snapshots + # existed. + restore_database || seed return APP_PATH end @@ -77,13 +88,12 @@ def build! vendor_bundle_path bundle_install install_active_admin - generate_fixture_models - write_model_overrides - write_admin_registrations + copy_fixture_app install_mcp configure_mcp migrate seed + snapshot_database File.write(FINGERPRINT_PATH, fingerprint) APP_PATH @@ -99,11 +109,22 @@ def cached? end def fingerprint - Digest::SHA256.hexdigest([RAILS_VERSION, File.read(__FILE__)].join("\n")) + Digest::SHA256.hexdigest([RAILS_VERSION, File.read(__FILE__), fixture_digest].join("\n")) + end + + # Hashes every fixture's path and contents, so editing one invalidates the + # cache. Without this the fixtures would move out of this file's digest and + # an edited fixture would be silently ignored on the next run. + def fixture_digest + paths = Dir.glob(File.join(FIXTURE_APP_PATH, "**", "*")) + .select { |path| File.file?(path) } + .reject { |path| File.basename(path) == FIXTURE_DOCS } + + paths.sort.map { |path| "#{path.delete_prefix(FIXTURE_APP_PATH)}\n#{File.read(path)}" }.join("\n") end - def run!(command, chdir: APP_PATH) - self.class.run!(command, chdir: chdir) + def run!(command, chdir: APP_PATH, env: {}) + self.class.run!(command, chdir: chdir, env: env) end def generate_app @@ -140,9 +161,11 @@ def ensure_rails_installed end def write_gemfile + additions = File.read(File.join(FIXTURE_APP_PATH, FIXTURE_GEMFILE)).gsub("GEM_PATH", REPO_ROOT) + File.open(File.join(APP_PATH, "Gemfile"), "a") do |f| f.puts - f.puts GEMFILE_ADDITIONS.gsub("GEM_PATH", REPO_ROOT) + f.puts additions end end @@ -163,58 +186,20 @@ def install_active_admin run!(["bin/rails", "generate", "active_admin:install"]) end - def generate_fixture_models - run!(["bin/rails", "generate", "model", "Author", "name:string", "email:string"]) - run!(["bin/rails", "generate", "model", "Post", "title:string", "body:text", "slug:string"]) - end - - # Ransack 4 (used by ActiveAdmin 3.x, and called directly by the gem's - # `query` tool) refuses to filter on any attribute not listed in a - # model's ransackable_attributes allowlist. Without this override every - # `query` example against these fixtures would fail with a Ransack - # error rather than exercising the gem. - def write_model_overrides - File.write(File.join(APP_PATH, "app/models/post.rb"), <<~RUBY) - # frozen_string_literal: true - - class Post < ApplicationRecord - def self.ransackable_attributes(_auth_object = nil) - column_names - end - end - RUBY - - File.write(File.join(APP_PATH, "app/models/author.rb"), <<~RUBY) - # frozen_string_literal: true - - class Author < ApplicationRecord - def self.ransackable_attributes(_auth_object = nil) - column_names - end - end - RUBY + # Copies the checked-in fixture application over what the generators + # produced: the ActiveAdmin registrations, the model allowlists, and the + # seeds. See spec/e2e/fixture_app/README.md for what each file proves. + def copy_fixture_app + fixture_entries.each do |entry| + FileUtils.cp_r(File.join(FIXTURE_APP_PATH, entry), APP_PATH) + end end - # Post is fully editable but permits only title and body, so the suite can - # prove an unpermitted attribute (slug) is dropped rather than written. - # Author registers no update action, so the suite can prove update refuses - # a resource the admin UI would not let you edit either. - def write_admin_registrations - File.write(File.join(APP_PATH, "app/admin/posts.rb"), <<~RUBY) - # frozen_string_literal: true - - ActiveAdmin.register Post do - permit_params :title, :body - end - RUBY - - File.write(File.join(APP_PATH, "app/admin/authors.rb"), <<~RUBY) - # frozen_string_literal: true - - ActiveAdmin.register Author do - actions :index, :show - end - RUBY + # Everything in the fixture directory that belongs in the application: + # not the README, and not the Gemfile fragment, which is appended to the + # generated Gemfile rather than copied over it. + def fixture_entries + (Dir.children(FIXTURE_APP_PATH) - [FIXTURE_DOCS, FIXTURE_GEMFILE]).sort end def install_mcp @@ -239,44 +224,32 @@ def migrate run!(["bin/rails", "db:migrate"]) end - def seed - run!(["bin/rails", "runner", SEED_SCRIPT]) + # Safe to copy the database file directly: the seeding process has + # exited by this point, so SQLite has checkpointed its write-ahead log + # into the main file and removed it. + def snapshot_database + FileUtils.cp(File.join(APP_PATH, DATABASE_PATH), File.join(APP_PATH, DATABASE_SNAPSHOT_PATH)) end - # Restorative rather than create-only: this runs on every suite run - # (cached or not), so it must reset any row the `update` examples - # mutated (e.g. the small-gods post's title) back to its seeded values, - # not just create the row if missing. Records are looked up by slug - # (posts) or email (authors/admin user) so ids and slugs stay stable - # across runs, which the `update` examples rely on. - SEED_SCRIPT = <<~RUBY - user = AdminUser.find_or_initialize_by(email: "#{ADMIN_EMAIL}") - user.password = "#{ADMIN_PASSWORD}" - user.password_confirmation = "#{ADMIN_PASSWORD}" - user.save! - - ursula = Author.find_or_initialize_by(email: "ursula@example.com") - ursula.name = "Ursula" - ursula.save! - - terry = Author.find_or_initialize_by(email: "terry@example.com") - terry.name = "Terry" - terry.save! - - earthsea = Post.find_or_initialize_by(slug: "a-wizard-of-earthsea") - earthsea.title = "A Wizard of Earthsea" - earthsea.body = "The first." - earthsea.save! - - atuan = Post.find_or_initialize_by(slug: "the-tombs-of-atuan") - atuan.title = "The Tombs of Atuan" - atuan.body = "The second." - atuan.save! - - small_gods = Post.find_or_initialize_by(slug: "small-gods") - small_gods.title = "Small Gods" - small_gods.body = "Unrelated." - small_gods.save! - RUBY + # Returns false when there is nothing to restore, so the caller can seed + # instead. Any write-ahead log left behind by a server that did not shut + # down cleanly is discarded: replaying it over a restored database would + # corrupt it. + def restore_database + snapshot = File.join(APP_PATH, DATABASE_SNAPSHOT_PATH) + return false unless File.exist?(snapshot) + + database = File.join(APP_PATH, DATABASE_PATH) + FileUtils.rm_f(["#{database}-wal", "#{database}-shm"]) + FileUtils.cp(snapshot, database) + true + end + + def seed + run!( + ["bin/rails", "db:seed"], + env: { "E2E_ADMIN_EMAIL" => ADMIN_EMAIL, "E2E_ADMIN_PASSWORD" => ADMIN_PASSWORD } + ) + end end end From fb71f160e90d1baa24b81157dc52b43791fdc920 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Sat, 19 Sep 2026 08:59:58 +0100 Subject: [PATCH 11/14] Raise the Rails and ActiveAdmin floors to match Ruby 4 Requiring Ruby >= 4.0 left the declared support matrix unsatisfiable: Rails 6.1 and ActiveAdmin 2.0 cannot run on it, so the stated requirements described a combination nobody could install. Rails 7.2 is the oldest release this gem's own end-to-end suite runs on Ruby 4, and ActiveAdmin 2.x does not support Rails 7.2, so the floors follow from the Ruby one. Updated in the gemspec as well as the README, since both declared them. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 +++++-- README.md | 4 ++-- activeadmin_mcp.gemspec | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3d330..a048fef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Breaking:** the minimum supported Ruby is now 4.0. Applications on Ruby 3.x - must stay on the previous release until they upgrade. CI and the release +- **Breaking:** the minimum supported Ruby is now 4.0, Rails 7.2 and + ActiveAdmin 3.0. Applications below any of those must stay on the previous + release until they upgrade. The Rails and ActiveAdmin floors follow from the + Ruby one: Rails 7.2 is the oldest release this gem's end-to-end suite runs + on Ruby 4, and ActiveAdmin 2.x does not support Rails 7.2. CI and the release workflow now run on Ruby 4.0.7. ### Security diff --git a/README.md b/README.md index 594c8c0..6301d61 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ The server is a Rails engine mounted inside your application (by default at ## Requirements - Ruby >= 4.0 -- Rails >= 6.1 -- ActiveAdmin >= 2.0 +- Rails >= 7.2 +- ActiveAdmin >= 3.0 ## Installation diff --git a/activeadmin_mcp.gemspec b/activeadmin_mcp.gemspec index 60e1508..1be0b29 100644 --- a/activeadmin_mcp.gemspec +++ b/activeadmin_mcp.gemspec @@ -24,8 +24,8 @@ Gem::Specification.new do |spec| end spec.require_paths = ["lib"] - spec.add_dependency "rails", ">= 6.1" - spec.add_dependency "activeadmin", ">= 2.0" + spec.add_dependency "rails", ">= 7.2" + spec.add_dependency "activeadmin", ">= 3.0" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.0" From 287830757037052275bc41047c9556aa07d60c32 Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Sat, 19 Sep 2026 09:02:38 +0100 Subject: [PATCH 12/14] Lock ActiveAdmin to the 3.5 series Replaces the open ">= 3.0" dependency with "~> 3.5", so the gem declares the series it is actually tested against and ActiveAdmin 4 has to be adopted deliberately rather than arriving through a host application's bundle update. The e2e fixture application's Gemfile moves from "~> 3.2" to match, so the suite installs what the gemspec supports rather than something merely compatible with it. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +++++++------ README.md | 2 +- activeadmin_mcp.gemspec | 2 +- spec/e2e/fixture_app/Gemfile.deps | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a048fef..6e04ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Breaking:** the minimum supported Ruby is now 4.0, Rails 7.2 and - ActiveAdmin 3.0. Applications below any of those must stay on the previous - release until they upgrade. The Rails and ActiveAdmin floors follow from the - Ruby one: Rails 7.2 is the oldest release this gem's end-to-end suite runs - on Ruby 4, and ActiveAdmin 2.x does not support Rails 7.2. CI and the release - workflow now run on Ruby 4.0.7. +- **Breaking:** the minimum supported Ruby is now 4.0 and the minimum Rails is + 7.2, and ActiveAdmin is constrained to `~> 3.5`. Applications outside those + must stay on the previous release until they upgrade. Rails 7.2 is the oldest + release this gem's end-to-end suite runs on Ruby 4; the ActiveAdmin + constraint pins the gem to the 3.5 series it is tested against, and will need + raising deliberately for ActiveAdmin 4. CI and the release workflow now run + on Ruby 4.0.7. ### Security diff --git a/README.md b/README.md index 6301d61..94a02e9 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The server is a Rails engine mounted inside your application (by default at - Ruby >= 4.0 - Rails >= 7.2 -- ActiveAdmin >= 3.0 +- ActiveAdmin ~> 3.5 ## Installation diff --git a/activeadmin_mcp.gemspec b/activeadmin_mcp.gemspec index 1be0b29..16de9c5 100644 --- a/activeadmin_mcp.gemspec +++ b/activeadmin_mcp.gemspec @@ -25,7 +25,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "rails", ">= 7.2" - spec.add_dependency "activeadmin", ">= 3.0" + spec.add_dependency "activeadmin", "~> 3.5" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.0" diff --git a/spec/e2e/fixture_app/Gemfile.deps b/spec/e2e/fixture_app/Gemfile.deps index 48343c1..c1f3c6d 100644 --- a/spec/e2e/fixture_app/Gemfile.deps +++ b/spec/e2e/fixture_app/Gemfile.deps @@ -2,7 +2,7 @@ # GEM_PATH is substituted with this repository's root, so the application # always loads the gem from the working tree rather than from RubyGems. -gem "activeadmin", "~> 3.2" +gem "activeadmin", "~> 3.5" gem "devise" gem "sassc-rails" From 4b809ef039330a96b2cd62ee0617a9a4cb81012f Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Sat, 19 Sep 2026 09:04:46 +0100 Subject: [PATCH 13/14] Drop the frozen_string_literal magic comments Removes the comment from all 41 Ruby files, including the generator templates that write it into host applications. Co-Authored-By: Claude Opus 5 (1M context) --- Rakefile | 2 -- activeadmin_mcp.gemspec | 2 -- app/controllers/activeadmin_mcp/mcp_controller.rb | 2 -- app/models/activeadmin_mcp/api_token.rb | 2 -- config/routes.rb | 2 -- lib/activeadmin_mcp.rb | 2 -- lib/activeadmin_mcp/authorization.rb | 2 -- lib/activeadmin_mcp/configuration.rb | 2 -- lib/activeadmin_mcp/engine.rb | 2 -- lib/activeadmin_mcp/form_field_collector.rb | 2 -- lib/activeadmin_mcp/record_updater.rb | 2 -- lib/activeadmin_mcp/request_handler.rb | 2 -- lib/activeadmin_mcp/resource_registry.rb | 2 -- lib/activeadmin_mcp/version.rb | 2 -- lib/generators/activeadmin_mcp/install/install_generator.rb | 2 -- lib/generators/activeadmin_mcp/install/templates/initializer.rb | 2 -- .../activeadmin_mcp/install/templates/mcp_api_tokens.rb | 2 -- .../activeadmin_mcp/install/templates/migration.rb.erb | 2 -- spec/activeadmin_mcp/api_token_spec.rb | 2 -- spec/activeadmin_mcp/authorization_spec.rb | 2 -- spec/activeadmin_mcp/configuration_spec.rb | 2 -- spec/activeadmin_mcp/record_updater_spec.rb | 2 -- spec/activeadmin_mcp/request_handler_spec.rb | 2 -- spec/activeadmin_mcp/resource_registry_spec.rb | 2 -- spec/activeadmin_mcp_spec.rb | 2 -- spec/e2e/app_install_spec.rb | 2 -- spec/e2e/e2e_helper.rb | 2 -- spec/e2e/fixture_app/app/admin/authors.rb | 2 -- spec/e2e/fixture_app/app/admin/posts.rb | 2 -- spec/e2e/fixture_app/app/models/author.rb | 2 -- spec/e2e/fixture_app/app/models/post.rb | 2 -- .../e2e/fixture_app/db/migrate/20260101000001_create_authors.rb | 2 -- spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb | 2 -- spec/e2e/fixture_app/db/seeds.rb | 2 -- spec/e2e/mcp_server_spec.rb | 2 -- spec/e2e/mcp_tools_spec.rb | 2 -- spec/e2e/support/app_builder.rb | 2 -- spec/e2e/support/app_server.rb | 2 -- spec/e2e/support/mcp_client.rb | 2 -- spec/spec_helper.rb | 2 -- spec/support/active_record.rb | 2 -- 41 files changed, 82 deletions(-) diff --git a/Rakefile b/Rakefile index ffa819b..b22ff08 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "bundler/gem_tasks" require "rspec/core/rake_task" diff --git a/activeadmin_mcp.gemspec b/activeadmin_mcp.gemspec index 16de9c5..be16aa5 100644 --- a/activeadmin_mcp.gemspec +++ b/activeadmin_mcp.gemspec @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require_relative "lib/activeadmin_mcp/version" Gem::Specification.new do |spec| diff --git a/app/controllers/activeadmin_mcp/mcp_controller.rb b/app/controllers/activeadmin_mcp/mcp_controller.rb index 54d7120..2031b49 100644 --- a/app/controllers/activeadmin_mcp/mcp_controller.rb +++ b/app/controllers/activeadmin_mcp/mcp_controller.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp class McpController < ActionController::API before_action :authenticate_mcp_token! diff --git a/app/models/activeadmin_mcp/api_token.rb b/app/models/activeadmin_mcp/api_token.rb index d27907a..c7e3ef2 100644 --- a/app/models/activeadmin_mcp/api_token.rb +++ b/app/models/activeadmin_mcp/api_token.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "digest" require "securerandom" diff --git a/config/routes.rb b/config/routes.rb index c23449a..8f71857 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - ActiveadminMcp::Engine.routes.draw do post "/", to: "mcp#call" end diff --git a/lib/activeadmin_mcp.rb b/lib/activeadmin_mcp.rb index e727866..502a1e4 100644 --- a/lib/activeadmin_mcp.rb +++ b/lib/activeadmin_mcp.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require_relative "activeadmin_mcp/version" require_relative "activeadmin_mcp/configuration" require_relative "activeadmin_mcp/authorization" diff --git a/lib/activeadmin_mcp/authorization.rb b/lib/activeadmin_mcp/authorization.rb index 685bd63..2742e7b 100644 --- a/lib/activeadmin_mcp/authorization.rb +++ b/lib/activeadmin_mcp/authorization.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp # Applies a resource's ActiveAdmin authorization adapter to MCP tool calls, so # reads, listings and writes obey the same rules as the admin UI. diff --git a/lib/activeadmin_mcp/configuration.rb b/lib/activeadmin_mcp/configuration.rb index a89be64..833e11c 100644 --- a/lib/activeadmin_mcp/configuration.rb +++ b/lib/activeadmin_mcp/configuration.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp class Configuration MOUNT_STRATEGIES = %i[prepend append none].freeze diff --git a/lib/activeadmin_mcp/engine.rb b/lib/activeadmin_mcp/engine.rb index c510678..a771593 100644 --- a/lib/activeadmin_mcp/engine.rb +++ b/lib/activeadmin_mcp/engine.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp class Engine < ::Rails::Engine isolate_namespace ActiveadminMcp diff --git a/lib/activeadmin_mcp/form_field_collector.rb b/lib/activeadmin_mcp/form_field_collector.rb index 6e8e1d6..b3e27de 100644 --- a/lib/activeadmin_mcp/form_field_collector.rb +++ b/lib/activeadmin_mcp/form_field_collector.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp # Records the field names declared by an ActiveAdmin `form do ... end` block. # diff --git a/lib/activeadmin_mcp/record_updater.rb b/lib/activeadmin_mcp/record_updater.rb index dcc02d6..0a59332 100644 --- a/lib/activeadmin_mcp/record_updater.rb +++ b/lib/activeadmin_mcp/record_updater.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp # Updates a single ActiveAdmin-managed record, enforcing the same three gates # the admin UI would: the resource must expose the update action, the current diff --git a/lib/activeadmin_mcp/request_handler.rb b/lib/activeadmin_mcp/request_handler.rb index f88dc74..a6fb0ca 100644 --- a/lib/activeadmin_mcp/request_handler.rb +++ b/lib/activeadmin_mcp/request_handler.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp class RequestHandler PROTOCOL_VERSION = "2025-06-18" diff --git a/lib/activeadmin_mcp/resource_registry.rb b/lib/activeadmin_mcp/resource_registry.rb index 5068651..3aaed78 100644 --- a/lib/activeadmin_mcp/resource_registry.rb +++ b/lib/activeadmin_mcp/resource_registry.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp module ResourceRegistry class << self diff --git a/lib/activeadmin_mcp/version.rb b/lib/activeadmin_mcp/version.rb index 3d1f9d0..9caeb55 100644 --- a/lib/activeadmin_mcp/version.rb +++ b/lib/activeadmin_mcp/version.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp VERSION = "0.0.4" end diff --git a/lib/generators/activeadmin_mcp/install/install_generator.rb b/lib/generators/activeadmin_mcp/install/install_generator.rb index 9cee91f..6646075 100644 --- a/lib/generators/activeadmin_mcp/install/install_generator.rb +++ b/lib/generators/activeadmin_mcp/install/install_generator.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "rails/generators" require "rails/generators/active_record" diff --git a/lib/generators/activeadmin_mcp/install/templates/initializer.rb b/lib/generators/activeadmin_mcp/install/templates/initializer.rb index bb0abbd..40263c2 100644 --- a/lib/generators/activeadmin_mcp/install/templates/initializer.rb +++ b/lib/generators/activeadmin_mcp/install/templates/initializer.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - ActiveadminMcp.configure do |config| # Uncomment to enable API token authentication. # Requires running the auth migration first: diff --git a/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb b/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb index 170a3ea..b474c49 100644 --- a/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb +++ b/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - ActiveAdmin.register_page "MCP API Tokens" do menu label: "MCP Tokens", parent: ActiveadminMcp.config.menu_parent, priority: 100 diff --git a/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb b/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb index b65c5bf..35d79db 100644 --- a/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb +++ b/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class CreateMcpApiTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] def change return if table_exists?(:mcp_api_tokens) diff --git a/spec/activeadmin_mcp/api_token_spec.rb b/spec/activeadmin_mcp/api_token_spec.rb index 6468d4e..d308616 100644 --- a/spec/activeadmin_mcp/api_token_spec.rb +++ b/spec/activeadmin_mcp/api_token_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" require "support/active_record" diff --git a/spec/activeadmin_mcp/authorization_spec.rb b/spec/activeadmin_mcp/authorization_spec.rb index caac7d8..d9640cf 100644 --- a/spec/activeadmin_mcp/authorization_spec.rb +++ b/spec/activeadmin_mcp/authorization_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp::Authorization do diff --git a/spec/activeadmin_mcp/configuration_spec.rb b/spec/activeadmin_mcp/configuration_spec.rb index 53f5403..6fe6467 100644 --- a/spec/activeadmin_mcp/configuration_spec.rb +++ b/spec/activeadmin_mcp/configuration_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp::Configuration do diff --git a/spec/activeadmin_mcp/record_updater_spec.rb b/spec/activeadmin_mcp/record_updater_spec.rb index 0f8cc2a..988e17d 100644 --- a/spec/activeadmin_mcp/record_updater_spec.rb +++ b/spec/activeadmin_mcp/record_updater_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp::RecordUpdater do diff --git a/spec/activeadmin_mcp/request_handler_spec.rb b/spec/activeadmin_mcp/request_handler_spec.rb index fae865d..116e439 100644 --- a/spec/activeadmin_mcp/request_handler_spec.rb +++ b/spec/activeadmin_mcp/request_handler_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp::RequestHandler do diff --git a/spec/activeadmin_mcp/resource_registry_spec.rb b/spec/activeadmin_mcp/resource_registry_spec.rb index fbe7cf5..cbfbd91 100644 --- a/spec/activeadmin_mcp/resource_registry_spec.rb +++ b/spec/activeadmin_mcp/resource_registry_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp::ResourceRegistry do diff --git a/spec/activeadmin_mcp_spec.rb b/spec/activeadmin_mcp_spec.rb index 7a6956b..b009b56 100644 --- a/spec/activeadmin_mcp_spec.rb +++ b/spec/activeadmin_mcp_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp do diff --git a/spec/e2e/app_install_spec.rb b/spec/e2e/app_install_spec.rb index 89a2fab..50fdb89 100644 --- a/spec/e2e/app_install_spec.rb +++ b/spec/e2e/app_install_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - RSpec.describe "the generated host application" do let(:app_path) { E2E::AppBuilder::APP_PATH } diff --git a/spec/e2e/e2e_helper.rb b/spec/e2e/e2e_helper.rb index 4d4d174..c3bb83f 100644 --- a/spec/e2e/e2e_helper.rb +++ b/spec/e2e/e2e_helper.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "rspec" require_relative "../../lib/activeadmin_mcp/version" diff --git a/spec/e2e/fixture_app/app/admin/authors.rb b/spec/e2e/fixture_app/app/admin/authors.rb index 1e73459..ced9403 100644 --- a/spec/e2e/fixture_app/app/admin/authors.rb +++ b/spec/e2e/fixture_app/app/admin/authors.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - # Registered without the update action, so the e2e suite can prove the MCP # `update` tool refuses a resource the admin UI would not let you edit either. ActiveAdmin.register Author do diff --git a/spec/e2e/fixture_app/app/admin/posts.rb b/spec/e2e/fixture_app/app/admin/posts.rb index 2a05357..cd6c28b 100644 --- a/spec/e2e/fixture_app/app/admin/posts.rb +++ b/spec/e2e/fixture_app/app/admin/posts.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - # Permits title and body but deliberately not slug, so the e2e suite can prove # the MCP `update` tool drops attributes the admin form does not accept. ActiveAdmin.register Post do diff --git a/spec/e2e/fixture_app/app/models/author.rb b/spec/e2e/fixture_app/app/models/author.rb index ba9a7f0..54d81b5 100644 --- a/spec/e2e/fixture_app/app/models/author.rb +++ b/spec/e2e/fixture_app/app/models/author.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class Author < ApplicationRecord # See the note in post.rb: Ransack 4 requires an explicit allowlist. def self.ransackable_attributes(_auth_object = nil) diff --git a/spec/e2e/fixture_app/app/models/post.rb b/spec/e2e/fixture_app/app/models/post.rb index cd31059..6003a1f 100644 --- a/spec/e2e/fixture_app/app/models/post.rb +++ b/spec/e2e/fixture_app/app/models/post.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class Post < ApplicationRecord # Ransack 4 refuses to filter on any attribute absent from this allowlist, # and the MCP `query` tool calls `ransack` directly rather than going diff --git a/spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb b/spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb index 17e40d5..bbe0ca7 100644 --- a/spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb +++ b/spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class CreateAuthors < ActiveRecord::Migration[7.2] def change create_table :authors do |t| diff --git a/spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb b/spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb index a4f08c6..e092dc7 100644 --- a/spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb +++ b/spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class CreatePosts < ActiveRecord::Migration[7.2] def change create_table :posts do |t| diff --git a/spec/e2e/fixture_app/db/seeds.rb b/spec/e2e/fixture_app/db/seeds.rb index ebf90c6..29bf6e9 100644 --- a/spec/e2e/fixture_app/db/seeds.rb +++ b/spec/e2e/fixture_app/db/seeds.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - # Restorative by design: the e2e suite's `update` examples rewrite a post's # title, and the generated application is cached between runs, so seeding has # to reset existing rows rather than only create missing ones. Records are diff --git a/spec/e2e/mcp_server_spec.rb b/spec/e2e/mcp_server_spec.rb index 8556d54..18377ec 100644 --- a/spec/e2e/mcp_server_spec.rb +++ b/spec/e2e/mcp_server_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - RSpec.describe "the MCP server" do let(:url) { E2E::AppServer.instance.mcp_url } let(:client) { E2E::McpClient.new(url: url, token: E2E.token) } diff --git a/spec/e2e/mcp_tools_spec.rb b/spec/e2e/mcp_tools_spec.rb index 0dade81..fe49afd 100644 --- a/spec/e2e/mcp_tools_spec.rb +++ b/spec/e2e/mcp_tools_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - RSpec.describe "the MCP tools" do let(:client) { E2E::McpClient.new(url: E2E::AppServer.instance.mcp_url, token: E2E.token) } diff --git a/spec/e2e/support/app_builder.rb b/spec/e2e/support/app_builder.rb index b30acfc..3b309b3 100644 --- a/spec/e2e/support/app_builder.rb +++ b/spec/e2e/support/app_builder.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "bundler" require "digest" require "fileutils" diff --git a/spec/e2e/support/app_server.rb b/spec/e2e/support/app_server.rb index a23a194..6caa4d0 100644 --- a/spec/e2e/support/app_server.rb +++ b/spec/e2e/support/app_server.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "fileutils" require "net/http" require "socket" diff --git a/spec/e2e/support/mcp_client.rb b/spec/e2e/support/mcp_client.rb index 3c3168d..3079b40 100644 --- a/spec/e2e/support/mcp_client.rb +++ b/spec/e2e/support/mcp_client.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "json" require "net/http" require "uri" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c064758..195e9f6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "rails" require "active_record" require "action_controller" diff --git a/spec/support/active_record.rb b/spec/support/active_record.rb index 7059d69..bcd52b2 100644 --- a/spec/support/active_record.rb +++ b/spec/support/active_record.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "active_record" # Spin up an in-memory SQLite database with just the tables the ApiToken From 27a48e8b32b0ccacf5dc218c69cf085acc73df5f Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Sat, 19 Sep 2026 09:13:46 +0100 Subject: [PATCH 14/14] Reset the e2e database before every example Examples previously shared one database for the whole run, so the two update examples had to mutate the same post in a fixed sequence and the suite was pinned to :defined order. Any new example that asserted a seeded value would have passed on a fresh build and failed on every rerun. Restoring the seeded rows now happens in a before hook. It is a transaction against the live database rather than a Rails boot, so the cost is milliseconds and the suite can afford to pay it 19 times: cached runs are 2.8s, marginally faster than the 3.4s they took while resetting once per run. The restore goes through SQLite rather than copying the snapshot file over the database, because the application server holds that file open and swapping it underneath its page cache invites inconsistent reads. The API token table is excluded: the token is minted after the snapshot is taken, so restoring it would revoke the token and every subsequent request would come back 401. With no example depending on another, order is now :random, which is what keeps the isolation honest. Also records in CLAUDE.md that e2e descriptions should be verbose enough that the documentation-format output reads as a specification of the MCP interface on its own. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 31 +++++++++++++++ spec/e2e/e2e_helper.rb | 12 +++++- spec/e2e/fixture_app/README.md | 7 ++-- spec/e2e/support/app_builder.rb | 69 ++++++++++++++++++++++++--------- 4 files changed, 97 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 67ab673..41b8b34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,3 +48,34 @@ file under `spec/e2e/fixture_app/` so the behaviour under test is wrong, watch the example fail, then restore it — editing a fixture invalidates the build cache, so the next run picks it up. An end-to-end suite that passes regardless of what the code does is worse than no suite, because it is believed. + +Every example starts from the seeded database: a `before` hook restores it from +a snapshot taken after migrating and seeding. So examples must not depend on +what another one left behind, and the suite runs in random order to keep that +honest. Write each one as though it runs alone, because it might. + +### Write e2e descriptions out in full + +Give e2e examples and their enclosing blocks descriptions verbose enough that +the `--format documentation` output reads as a specification of the MCP +interface on its own, without anyone opening the file. These descriptions are +the closest thing this project has to a written contract for how the server +behaves, and they are what someone debugging a CI failure sees first. + +State the behaviour and its condition, not the mechanics: + + # Too terse: names a method, not a behaviour. + it "filters" + + # Better: someone reading the output learns what the server guarantees. + it "filters records with Ransack syntax passed straight through to the model" + + # Too terse: gives no clue why refusing is correct. + it "refuses Author" + + # Better: the reason is the point of the example. + it "refuses to update a resource registered without the update action" + +Prefer a long description to a comment explaining a short one. Favour the +language of the README and the MCP tools — resources, attributes, permitted +params, authorization — over the language of the implementation. diff --git a/spec/e2e/e2e_helper.rb b/spec/e2e/e2e_helper.rb index c3bb83f..c927ac3 100644 --- a/spec/e2e/e2e_helper.rb +++ b/spec/e2e/e2e_helper.rb @@ -17,7 +17,11 @@ class << self end config.disable_monkey_patching! - config.order = :defined + + # Every example starts from the seeded database, so no example depends on + # what another one left behind and the order is free to vary. + config.order = :random + Kernel.srand config.seed config.before(:suite) do E2E::AppBuilder.build! @@ -25,6 +29,12 @@ class << self E2E.token = E2E::AppServer.mint_token! end + # Restoring the seeded rows is a transaction against the live database + # rather than a Rails boot, so paying it per example costs milliseconds. + config.before do + E2E::AppBuilder.reset_database! + end + config.after(:suite) do E2E::AppServer.stop! end diff --git a/spec/e2e/fixture_app/README.md b/spec/e2e/fixture_app/README.md index e735557..6e9f87b 100644 --- a/spec/e2e/fixture_app/README.md +++ b/spec/e2e/fixture_app/README.md @@ -40,6 +40,7 @@ experiment with: break one deliberately, run the suite, and watch the example that covers it fail. After migrating and seeding, the builder snapshots the SQLite database to -`storage/seeded.sqlite3` inside the generated application and restores it by -file copy on subsequent runs, which is why a cached run does not pay for a -Rails boot to re-seed. +`storage/seeded.sqlite3` inside the generated application. Restoring from that +snapshot is a transaction against the live database rather than a Rails boot, +so the suite can afford to do it before every single example — which is why +examples here never have to undo their own writes. diff --git a/spec/e2e/support/app_builder.rb b/spec/e2e/support/app_builder.rb index 3b309b3..2699fdb 100644 --- a/spec/e2e/support/app_builder.rb +++ b/spec/e2e/support/app_builder.rb @@ -2,6 +2,7 @@ require "digest" require "fileutils" require "open3" +require "sqlite3" module E2E # Generates a real Rails + ActiveAdmin application with the gem under test @@ -31,11 +32,16 @@ class AppBuilder DATABASE_PATH = "storage/development.sqlite3" # A copy of the database taken immediately after migrating and seeding. - # Restoring it is a file copy; re-seeding is a full Rails boot, so this - # is the difference between roughly a second and roughly nothing on - # every cached run. + # Restoring from it costs milliseconds; re-seeding is a full Rails boot, + # which is what makes it affordable to reset between examples. DATABASE_SNAPSHOT_PATH = "storage/seeded.sqlite3" + # Tables holding state created after the snapshot was taken, which a reset + # must therefore leave alone. The API token is minted once the server is + # up, so restoring this table would revoke it and every subsequent request + # would come back 401. + SESSION_TABLES = %w[mcp_api_tokens].freeze + ADMIN_EMAIL = "admin@example.com" ADMIN_PASSWORD = "password" @@ -46,6 +52,34 @@ def build! new.build! end + def snapshot_exists? + File.exist?(File.join(APP_PATH, DATABASE_SNAPSHOT_PATH)) + end + + # Puts the seeded rows back, table by table, through SQLite itself. + # + # Copying the snapshot file over the database would be simpler but is + # not safe here: the application server holds the database open, and + # swapping the file underneath its page cache invites it to read a + # mixture of the old and new images. Going through a transaction on the + # live connection takes SQLite's locks and leaves every reader + # consistent, which is what makes this usable between examples rather + # than only between runs. + def reset_database! + snapshot = File.join(APP_PATH, DATABASE_SNAPSHOT_PATH) + raise BuildError, "No database snapshot at #{snapshot}" unless File.exist?(snapshot) + + SQLite3::Database.new(File.join(APP_PATH, DATABASE_PATH)) do |db| + db.execute("ATTACH DATABASE ? AS seed", [snapshot]) + + begin + db.transaction { restore_tables(db) } + ensure + db.execute("DETACH DATABASE seed") + end + end + end + # Runs a command with the gem's own bundler environment stripped out, so # the generated application resolves against its own Gemfile. Raises with # the combined output on failure: a silent build failure here surfaces @@ -62,6 +96,19 @@ def run!(command, chdir: APP_PATH, env: {}) raise BuildError, "Command failed: #{command.join(' ')}\n\n#{output}" end + + private + + def restore_tables(db) + tables = db.execute( + "SELECT name FROM seed.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ).flatten - SESSION_TABLES + + tables.each do |table| + db.execute("DELETE FROM main.\"#{table}\"") + db.execute("INSERT INTO main.\"#{table}\" SELECT * FROM seed.\"#{table}\"") + end + end end def build! @@ -74,7 +121,7 @@ def build! # database has to be put back. Fall back to re-seeding when there is # no snapshot, which is the case for a cache saved before snapshots # existed. - restore_database || seed + self.class.snapshot_exists? ? self.class.reset_database! : seed return APP_PATH end @@ -229,20 +276,6 @@ def snapshot_database FileUtils.cp(File.join(APP_PATH, DATABASE_PATH), File.join(APP_PATH, DATABASE_SNAPSHOT_PATH)) end - # Returns false when there is nothing to restore, so the caller can seed - # instead. Any write-ahead log left behind by a server that did not shut - # down cleanly is discarded: replaying it over a restored database would - # corrupt it. - def restore_database - snapshot = File.join(APP_PATH, DATABASE_SNAPSHOT_PATH) - return false unless File.exist?(snapshot) - - database = File.join(APP_PATH, DATABASE_PATH) - FileUtils.rm_f(["#{database}-wal", "#{database}-shm"]) - FileUtils.cp(snapshot, database) - true - end - def seed run!( ["bin/rails", "db:seed"],