Repository navigation
Browser-test fixture: built-in /_browser/login-as sets bare session.userId / session.userEmail, no extension point for app session shape #2830
Description
Summary
The built-in /_browser/login-as fixture mounted when application.wheels.loadBrowserTestFixtures = true calls into vendor/wheels/public/browser-fixtures/controllers/BrowserTestLogin.cfc#create(), whose entire body sets:
session.userId = 1;
session.userEmail = params.identifier;This shape is fine for the framework's own demo specs (see vendor/wheels/tests/specs/wheelstest/BrowserLoginSpec.cfc) but is incompatible with any real app whose authentication writes a richer session structure — e.g. session.member = { id, email, firstName, lastName }, plus app-specific session enrichment.
There's no extension point for an app that opts into loadBrowserTestFixtures = true to also have its app-specific session keys populated by the fixture. The only paths today:
- Don't opt in to the framework fixture and write your own controller + route at the same
/_browser/login-aspath before the framework include. This is what we did. Works, but means every Wheels app that wants browser-test auth duplicates the route + controller + environment-gating boilerplate. It also can't easily co-exist with the framework fixture if a plugin later wants to use the built-in path. - Patch the framework's
BrowserTestLogin.cfcto also set app-specific session keys. Brittle (vendor-tree changes get clobbered on upgrade) and doesn't compose if multiple plugins want a say.
Why it matters
BrowserClient.loginAs(identifier) (in vendor/wheels/wheelstest/BrowserClient.cfc:547-550) is the documented DSL entrypoint for browser-test auth, but it points at a fixture whose session shape doesn't match real apps. The result is: most production-app teams writing browser specs will eventually need to abandon loadBrowserTestFixtures=true and re-implement the same login fixture themselves.
Suggestion
A configurable callback or override hook. Two shapes that would work:
-
Settings-driven handler.
set(browserLoginAsHandler = "AuthFixture##loginAs")inconfig/settings.cfmtells the framework to delegate/_browser/login-asto the app's controller instead of runningBrowserTestLogin#create. The framework route still mounts and still env-gates, but body execution is the app's. Handler receives(identifier)(plus any additional query params the BrowserClient DSL adds, e.g. our&isAD=false). -
Lifecycle hook. A
$afterBrowserLogin(identifier)callback the framework calls after setting its ownsession.userId/session.userEmail. The app fills in its richer session state on top. Less invasive but only works if your app's session shape extends the fixture's; doesn't help apps that need different/no top-leveluserId.
(1) is more flexible and matches existing Wheels convention-with-override patterns. Either way, docs at https://guides.wheels.dev/v4-0-0/testing/browser-testing/ should call out that the built-in fixture's session shape is {userId, userEmail} so app authors know upfront whether the fixture is usable for them.
Discovered during
Phase 1 of an internal RBAC portal build on Wheels 4.0.2 / Lucee 7. We ended up shipping a custom BrowserLoginAs.cfc controller mounted at /_browser/login-as (registered before the namespace block in config/routes.cfm) that sets session.member, session.isADAuth, and calls our app's session-enrichment helper. The custom controller env-gates identically to the framework's. Pattern is in https://github.com/paiindustries/titan/pull/3337 (private). Happy to extract specific snippets if useful for spec design.
Activity
wheels-bot commentedon May 28, 2026
Wheels Bot — Triage
Classified as framework-design (API/feature design question).
Cross-framework research will follow shortly — Rails, Laravel, Django,
and other modern frameworks will be consulted before any path forward
is proposed.
<!-- wheels-bot:triage:2830 -->
<!-- wheels-bot:triage-class:framework-design -->
wheels-bot commentedon May 28, 2026
Wheels Bot — Cross-Framework Research
Question
The built-in
/_browser/login-asfixture (BrowserTestLogin#create) hard-codessession.userId = 1andsession.userEmail = params.identifier. Real apps store richer session shapes (e.g.session.member = { id, email, firstName, lastName }). There's no extension point for apps that opt intoloadBrowserTestFixtures = trueto customize the fixture's session writes — today's only paths (don't opt in + write a parallel route, or patch the vendor tree) duplicate boilerplate or break on upgrade. How should the framework expose a clean override hook?How other frameworks handle this
sign_in(user)/login_asserialize_into_session(record)controls session shapeloginAs($userId, $guard?)browser;actingAs($user)feature_dusk/login/{userId}/{guard?}HTTP route;SessionGuard::login()firesIlluminate\Auth\Events\Login, app subscribes via listenerClient.force_login(user, backend=None)_auth_user_*session keys; firesuser_logged_insignal; cookie-transplant for Seleniumlog_in_user(conn, user)mix phx.gen.auth(bothMyAppAuth.log_in_user/3andMyAppConnCase.log_in_user/2); framework ships no built-in endpointKernelBrowser::loginUser($user, $firewall='main', $tokenAttributes=[])TestBrowserTokenunder_security_<firewall>;$tokenAttributesaccepts ad-hoc enrichmentLoginSuccessEventso app listeners are bypassed in tests — widely-cited painDominant pattern
All five frameworks converge on the same shape: framework writes a minimal default, app extends. They diverge sharply on the extension mechanism. Three of five (Rails, Django, Laravel) use event/signal listeners that fire from both real and test logins — clean composability with plugins, but it requires pub/sub infrastructure. Symfony does direct token-attribute injection per-call but bypasses its own event chain in tests (a known wart). Phoenix punts entirely with scaffold-the-helper-into-the-app, taking "let the app own the body" to its extreme. The user's suggestion (1) — settings-driven handler delegation — is the Phoenix endpoint dressed in Wheels'
Controller##actionconvention; suggestion (2) is the Laravel/Django/Rails event approach.Recommended path for Wheels
Ship the user's suggestion (1) as the canonical extension point. It matches Wheels' existing routing convention (
to = "Controller##action"strings), stores nothing but a string inapplication.wheels(no Adobe-CF function-member crash, anti-pattern #2), reuses existing route dispatch and controller invocation, and the app's handler is just a normal controller with full access toparams,session,model(), andinject(). The framework keeps env-gating and view rendering; the app provides the body. Suggestion (2)'s lifecycle hook is the cross-framework majority but requires event infrastructure Wheels doesn't currently have — defer it.Lives in:
vendor/wheels/public/browser-fixtures/routes.cfm(override read at route registration) + newvendor/wheels/middleware/BrowserTestFixtureGuard.cfc(env-gate extracted from the controller so it still applies under override).Composes with: existing
Controller##actionroute targets, the DI container (inject()inconfig()),params/session, model finders, and any other controller filter. The app's handler is a normal Wheels controller.Cross-engine notes
Clean. Setting holds a string (
"AuthFixture##loginAs"), never a closure — avoids the Adobe CFapplication-scope function-member crash (CLAUDE.md anti-pattern #2). Override is read at route-registration time (single-threaded boot), so no runtime closure-capture concerns. Noobj.map(), noobj["key"]()in closures, no<cfheader attributeCollection=arguments>patterns. Standard middleware + controller dispatch, both already validated across Lucee 5/6/7, Adobe 2018/2021/2023/2025, and BoxLang.Open questions
set(afterBrowserLoginAs = "AuthFixture##enrichSession")) that runs on top of framework defaults, for apps that want to add fields rather than replace the body? This is the Laravel/Django event-listener shape and composes better with plugins, but adds a second extension point./_browser/*scope (defense-in-depth, recommended) or onlylogin-as(matches today's behavior)?browserLoginAsHandler(matches the URL segment) vsbrowserTestLoginAs(matchesloadBrowserTestFixtures) vsbrowserLoginAs? Prefer the first for clarity.app/controllers/(current convention) or a dedicatedapp/test-fixtures/controllers/directory so it's visually quarantined? TheloadBrowserTestFixturesgate prevents route registration in production, but the controller class file still ships.set(browserLoginAsHandler = "browserAuthService")) resolved viaservice(), for apps that want a non-controller object to handle this? Symmetric with Wheels' DI container conventions.Confidence:
mediumThree of five surveyed frameworks (Rails, Django, Laravel) use event/signal listeners as the canonical extension point — a pattern Wheels can't adopt without new pub/sub infrastructure. The handler-override approach (the user's preferred suggestion, well-precedented by Phoenix's scaffold model and Laravel's route-name-takeover escape hatch) is the cleanest fit for Wheels conventions, but it's a meaningful fork from the cross-framework majority — a human should weigh whether to also ship the event/callback variant for plugin composability.
Sources
wheels-bot commentedon May 28, 2026
Wheels Bot — Fix proposed
Draft PR: #2832
Spec:
vendor/wheels/tests/specs/wheelstest/BrowserLoginAsHandlerSpec.cfc— failing → passing.Implementation:
vendor/wheels/public/browser-fixtures/routes.cfm(readsapplication.wheels.browserLoginAsHandler, defaults toBrowserTestLogin##create), newvendor/wheels/middleware/BrowserTestFixtureGuard.cfc(env-gate on the/_browserscope), andvendor/wheels/public/browser-fixtures/controllers/BrowserTestLogin.cfc(drops the redundant in-controller env-gate now that the middleware owns it).Followed the cross-framework research's recommended path: a settings-driven
to = "Controller##action"handler override. The Laravel/Django/Rails event-listener variant from the survey is deferred per the research's note that it would require pub/sub infrastructure Wheels doesn't currently have.A human review is required before merge. Reviewer A and Reviewer B will weigh in shortly. Doc updates (MDX guides,
.ai/wheels/,CLAUDE.md) are handled separately bybot-update-docs.yml.fix(test): escape '#' in BrowserLoginAsHandlerSpec describe label
Merge branch 'develop' into fix/bot-2830-browser-test-fixture-built-i…