Skip to content

Add an end-to-end test suite against a real ActiveAdmin install - #9

Merged
lloydwatkin merged 14 commits into
mainfrom
e2e-headless-activeadmin-tests
Sep 19, 2026
Merged

lloydwatkin merged 14 commits into
mainfrom
e2e-headless-activeadmin-tests

Conversation

@lloydwatkin

Copy link
Copy Markdown
Member

Every spec in this repo was a unit test against mocks — nothing booted Rails, mounted the engine, or served an HTTP request. So the README's claims (the installer wires it up, Bearer tokens authenticate, permit_params is enforced, resources without an update action are refused) were verified only against mocks of the surrounding framework.

This adds spec/e2e/, which generates a real Rails 7.2 + ActiveAdmin + Devise application into tmp/e2e_app, installs this gem into it through the gem's own activeadmin_mcp:install --auth devise_token generator, boots it under Puma, and drives the mounted /mcp endpoint over HTTP with a real API token.

No browser is involved — the MCP endpoint is plain JSON-RPC over HTTP, so one would add nothing.

What it covers

19 examples:

  • Install — ActiveAdmin present, the gem's initializer written with authentication_method = :devise_token, the token migration and MCP Tokens admin page generated, the fixture tables migrated
  • Protocol — initialize returns protocol 2025-06-18 and the right serverInfo; tools/list advertises exactly the three tools
  • Auth — missing token and unknown token both get HTTP 401 with JSON-RPC -32000; the minted token gets 200
  • list_resources — resources and attributes, with Devise's sensitive columns filtered out of a real AdminUser table
  • query — Ransack filtering, limit, unknown resource returns an error rather than raising
  • update — a permitted attribute persists; an unpermitted one (slug) is dropped; a resource registered actions :index, :show is refused; a missing record returns an error

The two fixture resources are chosen so each claim has something to bite on: Post permits only title and body, and Author registers no update action.

Why this is worth the machinery

The auth examples are the only coverage McpController has anywhere in the repo. The permit-drop example reaches RecordUpdater#from_permit_params, which instantiates the real ActiveAdmin controller and reads its compiled permitted params — a path that cannot be honestly unit-tested. And getting a 200 from POST /mcp at all proves the engine's :prepend mount strategy works inside a real ActiveAdmin route set.

The suite was checked for vacuity by hand: permitting slug in the generated app makes the relevant example fail with expected: ["title"], got: ["title", "slug"].

Running it

bundle exec rake spec   # unit specs, unchanged, 71 examples
bundle exec rake e2e    # end-to-end, 19 examples

The generated app is cached by a fingerprint of the builder, so only the first run is slow (a full bundle install, and a gem install rails -v 7.2.2.2 if that version is absent). E2E_REBUILD=1 forces a rebuild. Documented in the README's new "Running the tests" section.

A CI job runs it on every PR, caching tmp/e2e_app and uploading the Rails log on failure.

Known issue this surfaced, deliberately not fixed here

RequestHandler#tool_query calls model.ransack(q) unguarded, and McpController rescues only JSON::ParserError. On Ransack 4, a host model that has not allowlisted ransackable_attributes therefore raises straight through the controller and the MCP client receives an HTML 500 instead of a JSON-RPC error. That is most applications that have not done the ActiveAdmin 3 upgrade chore.

The e2e builder works around it by writing ransackable_attributes onto the fixture models, and the README now documents the requirement — but the library should degrade gracefully instead. That is a change to lib/ behaviour and belongs in its own PR.

Notes for review

  • The CI cache fix in the last commit addresses a bug where a cache hit restored the generated app without its bundle (gems were installing to the system gem home, outside the cached directory), which would have failed every run after the first. It has only been exercised locally — the failure only appears on the second CI run, so it's worth watching two runs before trusting it.
  • gem "json", "< 2.9" is pinned in the generated app's Gemfile: Rails 7.2's ActiveSupport::JSON.decode calls JSON.parse(json, quirks_mode: true) and the json gem removed that keyword in 2.9.0.
  • CLAUDE.md is new, recording that MCP actions need e2e coverage rather than unit specs alone.

🤖 Generated with Claude Code

lloydwatkin and others added 14 commits September 18, 2026 20:21
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…g 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@lloydwatkin
lloydwatkin merged commit 9f7f691 into main Sep 19, 2026
3 checks passed
@lloydwatkin
lloydwatkin deleted the e2e-headless-activeadmin-tests branch September 19, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant