Skip to content

Commit 359648c

Browse files
wheels-bot[bot]github-actions[bot]bpamiriclaude
authored
feat(testing): add browserLoginAsHandler override for /_browser/login-as fixture (#2832)
* feat(testing): add browserLoginAsHandler override for /_browser/login-as fixture The built-in `/_browser/login-as` fixture hard-coded `session.userId = 1` and `session.userEmail = params.identifier`, so any real app whose authentication writes a richer session shape (`session.member = { id, email, firstName, lastName }` etc.) had to either skip `loadBrowserTestFixtures` and duplicate the entire route + env-gate, or patch the vendor tree on every upgrade. Introduces `application.wheels.browserLoginAsHandler` — a `"Controller##action"` string read at route-registration time and used as the `/_browser/login-as` target in place of `BrowserTestLogin##create`. The override controller is a normal Wheels controller with full access to `params`, `session`, `model()`, and `inject()`. Env-gating extracts from the framework controller into `wheels.middleware.BrowserTestFixtureGuard`, attached to the whole `/_browser` scope so the gate still applies when an app supplies its own handler. Recommended path from the cross-framework research on #2830: matches Wheels' existing `to = "Controller##action"` routing convention without introducing event/signal infrastructure, and stores only a string in `application.wheels` (no Adobe CF function-member crash). Fixes #2830 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * docs(web/guides): document browserLoginAsHandler override for loginAs fixture Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(test): escape '#' in BrowserLoginAsHandlerSpec describe label The describe() label embedded the issue reference as "(#2830)" with a lone '#'. CFML treats '#' as an interpolation delimiter, so the unescaped, unclosed '#' threw "Invalid Syntax Closing [#] not found" at compile time. Because core tests compile the entire wheels.tests.specs directory, this one syntax error crashed the whole Lucee 7 bundle (HTTP 500), not just this spec. Escaped to '##2830'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * fix(middleware): add init() to BrowserTestFixtureGuard Scope middleware registered by component-path string are instantiated by Dispatch.cfc::$resolveMiddlewareInstance() via CreateObject("component", name).init(). BrowserTestFixtureGuard defined no init(), so every /_browser/* request threw "has no function with name [init]", breaking the fixture pages exercised by BrowserLoginSpec and BrowserRouteSpec (HTTP 417 on the core suite). Add a no-arg init() returning this, matching sibling middleware (SecurityHeaders/Cors/RateLimiter). Verified on a fresh local Lucee 7 server: wheelstest now 147/147 with zero 'has no init' server-log errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 84541de commit 359648c

7 files changed

Lines changed: 234 additions & 12 deletions

File tree

.ai/wheels/testing/browser-testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,5 @@ bash tools/test-local.sh # skips browser specs if JARs missin
6565
- **Data URLs work for most tests** — no server needed for ~95% of DSL coverage. Full HTTP integration (cookies, form submits, redirects) needs a running fixture app; that wiring is the same as Wheels Web app bootstrap (separate server + baseUrl).
6666
- **`this.browserTestSkipped`** — when Playwright JARs aren't installed (fresh CI, clean machine), `beforeAll` sets this flag and `browserDescribe`'s hooks short-circuit. All `it`s should check `if (this.browserTestSkipped) return;` to stay green on CI.
6767
- **CI runs browser tests**`pr.yml` and `snapshot.yml` install Playwright JARs + Chromium (cached via `browser-manifest.json` hash). Browser specs run as part of the normal test suite. `WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007` is set automatically. The base URL is resolved at instance time through a layered lookup (`this.baseUrl` → Wheels setting → JVM property `wheels.browserTest.baseUrl` → env var → CGI auto-detect → `http://localhost:8080`); per-spec `this.baseUrl` takes priority over the env var. Set `this.baseUrl` in the component pseudo-constructor (outside any function), not inside `beforeAll()``super.beforeAll()` calls `$resolveBaseUrl()` and caches the result, so a `this.baseUrl =` assignment that runs after `super.beforeAll()` is silently ignored.
68-
- **Fixture routes**`/_browser/login-as` and `/_browser/logout` are mounted automatically in test mode. They must come before `.wildcard()` in routes.cfm. In the Routes UI (`/wheels/routes`) all `/_browser/*` routes appear under the **Internal** tab, not Application.
68+
- **Fixture routes**`/_browser/login-as` and `/_browser/logout` are mounted automatically in test mode. They must come before `.wildcard()` in routes.cfm. In the Routes UI (`/wheels/routes`) all `/_browser/*` routes appear under the **Internal** tab, not Application. The `/_browser/login-as` handler is configurable: `set(browserLoginAsHandler = "AuthFixture##loginAs")` in `config/settings.cfm` substitutes that `Controller##action` at route-registration time (default is `BrowserTestLogin##create`). Env-gating is handled by `wheels.middleware.BrowserTestFixtureGuard` on the whole `/_browser` scope — custom handlers do not need to re-implement the guard. Empty string or absent setting falls back to the default. (#2830)
6969
- **Dialogs are Lucee-only**`acceptDialog`, `dismissDialog`, `dialogMessage` use `createDynamicProxy` which is Lucee-specific. Specs skip gracefully on other engines.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
2323
### Added
2424

2525
- RustCFML is now recognized as a first-class engine in the engine-adapter layer. Wheels detects it via `server.coldfusion.productName == "RustCFML"` (it exposes no `server.lucee`/`server.boxlang`), instantiates a `RustCFMLAdapter` (extends `Base`, whose defaults are Lucee-shaped, matching RustCFML's semantics) ordered before the Adobe ColdFusion fallback, and accepts any version in `$checkMinimumVersion` (RustCFML is pre-1.0 and rapidly evolving, so the usual minimum-version guard doesn't apply). Because RustCFML does not yet implement the `cfcache` built-in, the framework's cfcache-backed template/static cache degrades gracefully to a no-op when the adapter reports `supportsCfcache() = false`, so requests still render (cacheless-but-working). The new `supportsCfcache()` capability defaults to `true` on Lucee/Adobe/BoxLang, leaving their behavior unchanged. Support is best-effort: RustCFML is a young, JVM-free CFML interpreter and is not yet part of the CI matrix (#2837)
26+
- The built-in `/_browser/login-as` browser-test fixture (mounted by `set(loadBrowserTestFixtures = true)`) now honors an `application.wheels.browserLoginAsHandler` override. Set it in `config/settings.cfm``set(browserLoginAsHandler = "AuthFixture##loginAs")` — and the framework dispatches `/_browser/login-as` to that controller##action instead of the default `BrowserTestLogin##create`, letting apps with richer session shapes (e.g. `session.member = { id, email, firstName, lastName }`) drive the fixture without forking the vendor tree or duplicating the route + env-gate boilerplate. Env-gating moves to a new `wheels.middleware.BrowserTestFixtureGuard` middleware attached to the `/_browser` scope so the gate still applies under override. The setting falls back to `BrowserTestLogin##create` when unset or empty (#2830)
2627

2728
### Changed
2829

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Env-gates the `/_browser/*` browser-test fixture scope.
3+
*
4+
* The fixtures are only mounted when
5+
* `application.wheels.loadBrowserTestFixtures = true` AND
6+
* `application.wheels.environment` is `testing` or `development`
7+
* (see `vendor/wheels/Global.cfc::$lockedLoadRoutes`). This middleware
8+
* is belt-and-braces: it re-checks the environment at request time so
9+
* the gate still applies when an app supplies its own handler via
10+
* `set(browserLoginAsHandler = "AuthFixture##loginAs")` (issue #2830).
11+
*
12+
* Without the middleware, an app whose custom handler does not
13+
* re-implement the env check would expose the fixture in production if
14+
* `loadBrowserTestFixtures` were ever flipped on by mistake.
15+
*
16+
* Attached to the `/_browser` scope in
17+
* `vendor/wheels/public/browser-fixtures/routes.cfm`.
18+
*
19+
* [section: Middleware]
20+
* [category: Built-in]
21+
*/
22+
component implements="wheels.middleware.MiddlewareInterface" output="false" {
23+
24+
/**
25+
* Required so the dispatcher can instantiate this middleware when it is
26+
* registered by component-path string on a route scope —
27+
* `Dispatch.cfc::$resolveMiddlewareInstance()` does
28+
* `CreateObject("component", name).init()`. Takes no configuration.
29+
*/
30+
public BrowserTestFixtureGuard function init() {
31+
return this;
32+
}
33+
34+
public string function handle(required struct request, required any next) {
35+
if (
36+
!StructKeyExists(application.wheels, "environment")
37+
|| !ListFindNoCase("testing,development", application.wheels.environment)
38+
) {
39+
Throw(
40+
type = "Wheels.BrowserTestSecurityError",
41+
message = "/_browser/* fixture endpoints are only available in testing/development environments"
42+
);
43+
}
44+
return arguments.next(arguments.request);
45+
}
46+
47+
}

vendor/wheels/public/browser-fixtures/controllers/BrowserTestLogin.cfc

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
/**
22
* Browser-test fixture controller — framework-internal.
3-
* Env-gated `loginAs` endpoint for browser specs. Issues #2135, #2138.
3+
* `loginAs` endpoint for browser specs. Issues #2135, #2138.
4+
*
5+
* Env-gating is enforced by `wheels.middleware.BrowserTestFixtureGuard`
6+
* on the `/_browser` scope (issue #2830) so an app supplying its own
7+
* handler via `set(browserLoginAsHandler = "...")` inherits the same
8+
* gate. The route is only registered in testing/development to begin
9+
* with — the middleware is belt-and-braces.
410
*/
511
component extends="Controller" {
612

713
function config() {
814
}
915

1016
function create() {
11-
if (!ListFindNoCase("testing,development", application.wheels.environment)) {
12-
Throw(
13-
type = "Wheels.BrowserTestSecurityError",
14-
message = "loginAs endpoint is only available in testing/development environments"
15-
);
16-
}
17-
1817
session.userId = 1;
1918
session.userEmail = params.identifier;
2019
$renderBrowserFixtureView(action = "create");

vendor/wheels/public/browser-fixtures/routes.cfm

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,40 @@
1313
* the framework's controller/view resolver appends those directories to
1414
* the search path when the fixtures are active.
1515
*
16+
* The `/_browser/login-as` route's handler is configurable. By default
17+
* it dispatches to the framework's `BrowserTestLogin##create`, which
18+
* writes a minimal `session.userId` / `session.userEmail` shape. Apps
19+
* with a richer real-world session shape (e.g.
20+
* `session.member = { id, email, firstName, lastName }`) can override
21+
* the handler in `config/settings.cfm`:
22+
*
23+
* set(browserLoginAsHandler = "AuthFixture##loginAs");
24+
*
25+
* The app's controller is a normal Wheels controller and has full
26+
* access to `params`, `session`, `model()`, and `inject()`. Env-gating
27+
* is handled at the `/_browser/*` scope by
28+
* `wheels.middleware.BrowserTestFixtureGuard` so the app's handler does
29+
* not need to re-implement the guard. Issue #2830.
30+
*
1631
* Must come before `.wildcard()` in the app's own route table.
1732
*/
33+
local.loginAsHandler = "BrowserTestLogin##create";
34+
if (
35+
StructKeyExists(application.wheels, "browserLoginAsHandler")
36+
&& IsSimpleValue(application.wheels.browserLoginAsHandler)
37+
&& Len(application.wheels.browserLoginAsHandler)
38+
) {
39+
local.loginAsHandler = application.wheels.browserLoginAsHandler;
40+
}
41+
1842
mapper()
19-
.scope(path = "/_browser")
43+
.scope(path = "/_browser", middleware = ["wheels.middleware.BrowserTestFixtureGuard"])
2044
.get(name = "browserTestHome", pattern = "/home", to = "BrowserTestHome##index")
2145
.get(name = "browserTestLogin", pattern = "/login", to = "BrowserTestSessions##new")
2246
.post(name = "browserTestAuthenticate", pattern = "/login", to = "BrowserTestSessions##create")
2347
.get(name = "browserTestDashboard", pattern = "/dashboard", to = "BrowserTestHome##dashboard")
2448
.post(name = "browserTestLogout", pattern = "/logout", to = "BrowserTestSessions##destroy")
25-
.get(name = "browserTestLoginAs", pattern = "/login-as", to = "BrowserTestLogin##create")
49+
.get(name = "browserTestLoginAs", pattern = "/login-as", to = local.loginAsHandler)
2650
.end()
2751
.end();
2852
</cfscript>
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
component extends="wheels.WheelsTest" {
2+
3+
// Regression / extension-point guard for issue #2830.
4+
//
5+
// The built-in `/_browser/login-as` fixture (mounted when
6+
// `application.wheels.loadBrowserTestFixtures = true`) hard-coded its
7+
// session writes to `session.userId = 1` and `session.userEmail =
8+
// params.identifier`. Real apps store richer session shapes, so they
9+
// either had to skip the framework fixture and duplicate the route
10+
// themselves or patch the vendor tree on every upgrade.
11+
//
12+
// The fix introduces `application.wheels.browserLoginAsHandler` — a
13+
// `"Controller##action"` string the framework reads at route-
14+
// registration time and uses as the `/_browser/login-as` target in
15+
// place of `BrowserTestLogin##create`. Env-gating moves to a
16+
// `wheels.middleware.BrowserTestFixtureGuard` middleware that's
17+
// attached to the whole `/_browser` scope so the gate still applies
18+
// when an app supplies its own handler.
19+
//
20+
// These tests pin the routing contract: default points at the built-
21+
// in controller, override redirects to the app's controller, and
22+
// every `/_browser/*` route declares the env-gate middleware so app
23+
// handlers don't need to re-implement it.
24+
25+
function beforeAll() {
26+
_originalRoutes = Duplicate(application.wheels.routes);
27+
_originalNamed = StructCopy(application.wheels.namedRoutePositions);
28+
_originalEnv = application.wheels.environment;
29+
_originalLoadFixtures = StructKeyExists(application.wheels, "loadBrowserTestFixtures")
30+
? application.wheels.loadBrowserTestFixtures
31+
: false;
32+
_hadHandler = StructKeyExists(application.wheels, "browserLoginAsHandler");
33+
_originalHandler = _hadHandler ? application.wheels.browserLoginAsHandler : "";
34+
_originalControllerPath = application.wheels.controllerPath;
35+
_originalStaticRoutes = StructKeyExists(application.wheels, "staticRoutes")
36+
? StructCopy(application.wheels.staticRoutes)
37+
: {};
38+
}
39+
40+
function afterAll() {
41+
application.wheels.routes = _originalRoutes;
42+
application.wheels.namedRoutePositions = _originalNamed;
43+
application.wheels.environment = _originalEnv;
44+
application.wheels.loadBrowserTestFixtures = _originalLoadFixtures;
45+
application.wheels.controllerPath = _originalControllerPath;
46+
if (_hadHandler) {
47+
application.wheels.browserLoginAsHandler = _originalHandler;
48+
} else if (StructKeyExists(application.wheels, "browserLoginAsHandler")) {
49+
StructDelete(application.wheels, "browserLoginAsHandler");
50+
}
51+
application.wheels.staticRoutes = _originalStaticRoutes;
52+
}
53+
54+
function run() {
55+
describe("browserLoginAsHandler — app-level override of /_browser/login-as fixture (##2830)", () => {
56+
57+
beforeEach(() => {
58+
application.wheels.environment = "testing";
59+
application.wheels.loadBrowserTestFixtures = true;
60+
});
61+
62+
it("defaults /_browser/login-as to the framework's BrowserTestLogin##create when no override is set", () => {
63+
if (StructKeyExists(application.wheels, "browserLoginAsHandler")) {
64+
StructDelete(application.wheels, "browserLoginAsHandler");
65+
}
66+
67+
application.wo.$lockedLoadRoutes();
68+
69+
var loginRoute = $findRouteByName("browserTestLoginAs");
70+
expect(StructIsEmpty(loginRoute)).toBeFalse(
71+
"expected /_browser/login-as route to be registered when loadBrowserTestFixtures = true"
72+
);
73+
expect(loginRoute.controller).toBe("BrowserTestLogin");
74+
expect(loginRoute.action).toBe("create");
75+
});
76+
77+
it("delegates /_browser/login-as to browserLoginAsHandler controller##action when the setting is configured", () => {
78+
application.wheels.browserLoginAsHandler = "MyAuthFixture##loginAs";
79+
80+
application.wo.$lockedLoadRoutes();
81+
82+
var loginRoute = $findRouteByName("browserTestLoginAs");
83+
expect(StructIsEmpty(loginRoute)).toBeFalse(
84+
"expected /_browser/login-as route to be registered when loadBrowserTestFixtures = true"
85+
);
86+
expect(loginRoute.controller).toBe(
87+
"MyAuthFixture",
88+
"expected /_browser/login-as to dispatch to the app's controller from application.wheels.browserLoginAsHandler, got controller=" & loginRoute.controller
89+
);
90+
expect(loginRoute.action).toBe("loginAs");
91+
});
92+
93+
it("ignores an empty browserLoginAsHandler setting and falls back to BrowserTestLogin##create", () => {
94+
application.wheels.browserLoginAsHandler = "";
95+
96+
application.wo.$lockedLoadRoutes();
97+
98+
var loginRoute = $findRouteByName("browserTestLoginAs");
99+
expect(StructIsEmpty(loginRoute)).toBeFalse();
100+
expect(loginRoute.controller).toBe("BrowserTestLogin");
101+
expect(loginRoute.action).toBe("create");
102+
});
103+
104+
it("env-gates the /_browser/* scope via BrowserTestFixtureGuard middleware so app handlers still get the gate", () => {
105+
if (StructKeyExists(application.wheels, "browserLoginAsHandler")) {
106+
StructDelete(application.wheels, "browserLoginAsHandler");
107+
}
108+
109+
application.wo.$lockedLoadRoutes();
110+
111+
var loginRoute = $findRouteByName("browserTestLoginAs");
112+
expect(StructIsEmpty(loginRoute)).toBeFalse();
113+
expect(StructKeyExists(loginRoute, "middleware")).toBeTrue(
114+
"expected /_browser scope to register env-gate middleware on each route"
115+
);
116+
117+
var mw = loginRoute.middleware;
118+
if (IsSimpleValue(mw)) {
119+
mw = ListToArray(mw);
120+
}
121+
var found = false;
122+
for (var entry in mw) {
123+
if (FindNoCase("BrowserTestFixtureGuard", entry)) {
124+
found = true;
125+
break;
126+
}
127+
}
128+
expect(found).toBeTrue(
129+
"expected /_browser route middleware to include wheels.middleware.BrowserTestFixtureGuard, got: " & SerializeJSON(mw)
130+
);
131+
});
132+
133+
});
134+
}
135+
136+
private struct function $findRouteByName(required string name) {
137+
for (var route in application.wheels.routes) {
138+
if (StructKeyExists(route, "name") && route.name == arguments.name) {
139+
return route;
140+
}
141+
}
142+
return {};
143+
}
144+
145+
}

web/sites/guides/src/content/docs/v4-0-0/testing/browser-tests.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,13 @@ component extends="wheels.wheelstest.BrowserTest" {
176176

177177
## The DSL — auth fixtures
178178

179-
`loginAs(identifier)` takes a single string (email, username, or whatever your app uses to identify a user) and navigates to `/_browser/login-as?identifier=...`. That route is mounted automatically in test mode and sets `session.userId` and `session.userEmail` server-side — no real login form, no password hashing, no redirect handshake. Use it when the spec's intent is "what does an authenticated user see", not "what does the login form do". All `/_browser/*` fixture routes appear under the **Internal** tab in the Routes UI (`/wheels/routes`), not Application — they are framework-owned and do not correspond to files in your app.
179+
`loginAs(identifier)` takes a single string (email, username, or whatever your app uses to identify a user) and navigates to `/_browser/login-as?identifier=...`. That route is mounted automatically in test mode. The default handler writes `session.userId = 1` and `session.userEmail = identifier` — enough for simple apps. If your app stores a richer session shape (e.g. `session.member = { id, email, firstName, lastName }`), add one line to `config/settings.cfm`:
180+
181+
```cfm title="config/settings.cfm"
182+
set(browserLoginAsHandler = "AuthFixture##loginAs");
183+
```
184+
185+
The framework dispatches `/_browser/login-as` to that controller##action instead of the built-in one. Your controller is a normal Wheels controller with full access to `params`, `session`, `model()`, and `inject()`. Env-gating is handled by `wheels.middleware.BrowserTestFixtureGuard` on the `/_browser` scope — your handler does not need to re-implement the guard. Remove the setting or set it to an empty string to fall back to the default. No real login form, no password hashing, no redirect handshake. Use `loginAs` when the spec's intent is "what does an authenticated user see", not "what does the login form do". All `/_browser/*` fixture routes appear under the **Internal** tab in the Routes UI (`/wheels/routes`), not Application — they are framework-owned and do not correspond to files in your app.
180186

181187
```cfm {test:compile} title="tests/specs/browser/AuthenticatedSpec.cfc"
182188
component extends="wheels.wheelstest.BrowserTest" {

0 commit comments

Comments
 (0)