Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ai/wheels/testing/browser-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,6 @@ bash tools/test-local.sh # skips browser specs if JARs missin
- **`client` is a Lucee reserved scope.** `var client = ...` in a closure throws "client scope is not enabled". Use `var c = ...` or `var bc = ...`. (Generalized rule: see CLAUDE.md anti-pattern #11.)
- **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).
- **`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.
- **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.
- **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.
- **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.
- **Dialogs are Lucee-only** — `acceptDialog`, `dismissDialog`, `dialogMessage` use `createDynamicProxy` which is Lucee-specific. Specs skip gracefully on other engines.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
### Fixed

- `wheels.wheelstest.BrowserTest` now throws a clear `Wheels.BrowserTest.NotWired` error — naming `browserDescribe()` as the fix — when a spec calls a DSL method on `this.browser` from a plain `describe()` block. Previously the uninitialized `this.browser` was an empty string, producing the misleading `function [visitUrl] does not exist in the String` on every newcomer's first BrowserTest spec. A sentinel `UnwiredBrowserGuard` is now installed at `this.browser` before `browserDescribe()` wires the real `BrowserClient` and after `$endBrowserContext()` tears it down
- `BrowserTest`'s default base URL is no longer hardcoded to `http://localhost:8080`. `$resolveBaseUrl()` now consults a layered lookup at instance time: `this.baseUrl` (per-spec override, set in the component pseudo-constructor) → `get("browserTestBaseUrl")` (Wheels setting) → `-Dwheels.browserTest.baseUrl=...` (JVM system property) → `WHEELS_BROWSER_TEST_BASE_URL` env var → `cgi.server_name`/`cgi.server_port` auto-detect → `http://localhost:8080` default. Specs running against a non-8080 server (Titan on 60050, `wheels new` scaffolds on 60080) can set `this.baseUrl` in the pseudo-constructor or rely on the CGI auto-detect instead of comparing `getBaseUrl()` against a sentinel string. The bare-env-var approach still works for CI but is no longer the only escape hatch (the JVM caches env vars at process start, so post-launch `export` had no effect). Regression spec at `vendor/wheels/tests/specs/wheelstest/BrowserTestBaseUrlResolutionSpec.cfc` (#2779)
- Linux `.deb` / `.rpm` packages double-nested the framework at `/opt/wheels/module/vendor/wheels/wheels/` instead of `/opt/wheels/module/vendor/wheels/`. `wheels-core-VER.zip` carries a top-level `wheels/` directory that `unzip` preserves; the nfpm `type: tree` rule then copied the entire `build/framework/` tree (wrapper and all) into the destination, leaving `Injector.cfc` one level too deep. Every fresh `wheels new` install on Ubuntu/Fedora then crashed on first request with `could not find component or class with name [wheels.Injector]`, cascading into the cryptic `The key [WO] does not exist.` error in `onError`. The brew formula handles this correctly via `(share/"wheels/framework/wheels").install Dir["*"]`; the Linux nfpm configs now pin `src` at `./build/framework/wheels/` to match. Regression spec at `vendor/wheels/tests/specs/cli/LinuxPackageStagingSpec.cfc` (#2773)
- `onError` in the generated app template and demo `public/Application.cfc` now guards `application.wo` with `StructKeyExists(application, "wo")` after the recovery try/catch. When `new wheels.Injector(...)` fails during `onApplicationStart` (e.g. a stale `/wheels` mapping under Lucee Express 7), the original error is preserved via a minimal HTML fallback instead of cascading into the cryptic "The key [WO] does not exist" exception that hit "Your First 15 Minutes" tutorial users on fresh installs

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Regression for #2779: BrowserTest's default base URL is hardcoded to
* http://localhost:8080 and the only escape hatch (the JVM-cached
* WHEELS_BROWSER_TEST_BASE_URL env var) cannot be changed after server
* start. Specs running against non-8080 servers must apply a manual
* fallback comparing getBaseUrl() to a sentinel string.
*
* After the fix, BrowserTest resolves the base URL through a layered
* lookup at instance time:
* 1. this.baseUrl (per-spec override)
* 2. get("browserTestBaseUrl") (Wheels setting)
* 3. JVM system property (wheels.browserTest.baseUrl)
* 4. Env var (WHEELS_BROWSER_TEST_BASE_URL)
* 5. CGI auto-detect (cgi.server_name + cgi.server_port)
* 6. Default (http://localhost:8080)
*/
component extends="wheels.WheelsTest" {

function run() {
describe("BrowserTest base URL layered resolution (issue ##2779)", () => {

it("honors this.baseUrl as the highest-precedence override", () => {
var bt = new wheels.wheelstest.BrowserTest();
bt.baseUrl = "http://override.example:9999";
expect(bt.$resolveBaseUrl()).toBe("http://override.example:9999");
});

it("falls back through layers when this.baseUrl is empty", () => {
// Intentionally weak assertion: JVM env vars are read-only
// from CFML and the Wheels get() setting requires a live
// framework context, so we can't fully isolate which layer
// (2–6) fires here. The JVM-property test below and the
// direct $detectBaseUrlFromCgi tests cover layers 3 and 5
// in isolation. This case only verifies the chain
// terminates with a valid http(s):// URL — the original bug
// was returning the wrong port, not an invalid value.
var bt = new wheels.wheelstest.BrowserTest();
bt.baseUrl = "";
expect(bt.$resolveBaseUrl()).toMatch("^https?://");
});

it("$detectBaseUrlFromCgi returns blank when port is the stale 8080 default", () => {
var bt = new wheels.wheelstest.BrowserTest();
var fakeCgi = {server_port: "8080", server_name: "localhost", https: "off"};
expect(bt.$detectBaseUrlFromCgi(fakeCgi)).toBe("");
});

it("$detectBaseUrlFromCgi builds the URL from a non-default port", () => {
var bt = new wheels.wheelstest.BrowserTest();
var fakeCgi = {server_port: "60050", server_name: "localhost", https: "off"};
expect(bt.$detectBaseUrlFromCgi(fakeCgi)).toBe("http://localhost:60050");
});

it("$detectBaseUrlFromCgi honors the https scheme when cgi.https is on", () => {
var bt = new wheels.wheelstest.BrowserTest();
var fakeCgi = {server_port: "443", server_name: "staging.example.com", https: "on"};
expect(bt.$detectBaseUrlFromCgi(fakeCgi)).toBe("https://staging.example.com");
});

it("$detectBaseUrlFromCgi omits canonical http port 80", () => {
var bt = new wheels.wheelstest.BrowserTest();
var fakeCgi = {server_port: "80", server_name: "example.com", https: "off"};
expect(bt.$detectBaseUrlFromCgi(fakeCgi)).toBe("http://example.com");
});

it("$detectBaseUrlFromCgi returns blank when server_port is missing or zero", () => {
var bt = new wheels.wheelstest.BrowserTest();
expect(bt.$detectBaseUrlFromCgi({server_port: "0"})).toBe("");
expect(bt.$detectBaseUrlFromCgi({})).toBe("");
});

it("JVM system property is honored when this.baseUrl is empty", () => {
var bt = new wheels.wheelstest.BrowserTest();
bt.baseUrl = "";
var sys = createObject("java", "java.lang.System");
var key = "wheels.browserTest.baseUrl";
var prior = sys.getProperty(key);
try {
sys.setProperty(key, "http://jvm-prop.example:1234");
expect(bt.$resolveBaseUrl()).toBe("http://jvm-prop.example:1234");
} finally {
if (isNull(prior)) {
sys.clearProperty(key);
} else {
sys.setProperty(key, prior);
}
}
});

});
}
}
74 changes: 64 additions & 10 deletions vendor/wheels/wheelstest/BrowserTest.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ component extends="wheels.WheelsTest" {
// "function [X] does not exist in the String".
this.browser = new wheels.wheelstest.UnwiredBrowserGuard();
this.browserTestSkipped = false;
// Per-spec base-URL override (issue #2779). Highest-precedence layer in
// $resolveBaseUrl(). Set in the component pseudo-constructor (the
// `this.baseUrl = "..."` line outside any function in the subclass),
// NOT inside beforeAll() — super.beforeAll() calls $resolveBaseUrl() and
// caches the resolved URL before any beforeAll-level assignment can take
// effect, so a late override is silently ignored.
this.baseUrl = "";

variables.$launcher = "";
variables.$browser = "";
Expand Down Expand Up @@ -232,26 +239,73 @@ component extends="wheels.WheelsTest" {
}

/**
* Base URL for `client.visit("/path")`. Defaults to localhost:8080
* (the default LuCLI port); override via WHEELS_BROWSER_TEST_BASE_URL env.
* Base URL for `client.visit("/path")`. Resolved through a layered lookup
* at instance time so post-launch overrides take effect (the JVM caches
* env vars at process start, so `export`-ing after `wheels start` is too
* late for the bare-env-var approach). Precedence, highest first:
*
* 1. this.baseUrl — per-spec override
* 2. get("browserTestBaseUrl") — Wheels setting
* 3. -Dwheels.browserTest.baseUrl=... — JVM system property
* 4. WHEELS_BROWSER_TEST_BASE_URL env — CI / shell
* 5. $detectBaseUrlFromCgi(cgi) — derived from the in-flight
* test-runner request
* 6. "http://localhost:8080" default — bare LuCLI port
*
* For specs that use only `visitUrl(absolute)` (e.g. data:/file:), the
* baseUrl is effectively unused.
*/
private string function $resolveBaseUrl() {
public string function $resolveBaseUrl() {
if (len(this.baseUrl ?: "")) return this.baseUrl;

try {
var setting = get(name="browserTestBaseUrl");
if (len(setting ?: "")) return setting;
} catch (any e) {
// Setting not registered — fall through to next layer.
}

try {
var env = createObject("java", "java.lang.System")
.getenv("WHEELS_BROWSER_TEST_BASE_URL");
var sys = createObject("java", "java.lang.System");
var prop = sys.getProperty("wheels.browserTest.baseUrl");
if (!isNull(prop) && len(prop)) return prop;
var env = sys.getenv("WHEELS_BROWSER_TEST_BASE_URL");
if (!isNull(env) && len(env)) return env;
} catch (any e) {
// Best-effort: SecurityManager could deny env access. Falling
// back to the localhost default is correct — if the user actually
// configured a different URL but we can't read it, their tests
// will fail with connection-refused, surfacing the problem
// clearly rather than silently using the wrong URL.
// Best-effort: a SecurityManager could deny system access.
// Falling through to CGI detection / default is correct.
}

try {
var detected = $detectBaseUrlFromCgi(cgi);
if (len(detected)) return detected;
} catch (any e) {
// cgi scope unavailable (rare; e.g. background thread) — fall
// through to the hardcoded default.
}

return "http://localhost:8080";
}

/**
* Derive base URL from the in-flight test-runner request. The core test
* suite runs over HTTP at /wheels/core/tests, so the cgi scope already
* names the correct host:port for the running server. Returns "" when
* cgi looks like the bare LuCLI default (localhost:8080) so downstream
* layers can supply the real value.
*/
public string function $detectBaseUrlFromCgi(required any cgiScope) {
if (!structKeyExists(arguments.cgiScope, "server_port") || !val(arguments.cgiScope.server_port ?: 0)) {
return "";
}
var port = val(arguments.cgiScope.server_port);
var host = len(arguments.cgiScope.server_name ?: "") ? arguments.cgiScope.server_name : "localhost";
var scheme = (arguments.cgiScope.https ?: "off") == "on" ? "https" : "http";
if (host == "localhost" && port == 8080) return "";
var isCanonicalPort = (scheme == "http" && port == 80) || (scheme == "https" && port == 443);
return scheme & "://" & host & (isCanonicalPort ? "" : ":" & port);
}

/**
* Best-effort capture of screenshot + HTML on test failure.
* Called from aroundEach catch block. Swallows all errors to avoid
Expand Down
16 changes: 14 additions & 2 deletions web/sites/guides/src/content/docs/v4-0-0/testing/browser-tests.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ component extends="wheels.wheelstest.BrowserTest" {

`this.browser` is a `BrowserClient`. Every method that mutates state returns `this`, so calls chain fluently until you hit a terminal (a method that returns a value — `currentUrl()`, `text()`, etc.).

- `visit(path)` — navigate to a path joined to the base URL (`http://localhost:8080` by default, or `WHEELS_BROWSER_TEST_BASE_URL`). Path must start with `/`.
- `visit(path)` — navigate to a path joined to the base URL, resolved at instance time through a layered lookup: `this.baseUrl` (per-spec override) → `get("browserTestBaseUrl")` (Wheels setting) → JVM system property `wheels.browserTest.baseUrl` → `WHEELS_BROWSER_TEST_BASE_URL` env var → CGI auto-detect → `http://localhost:8080` default. Path must start with `/`.
- `visitUrl(url)` — navigate to an absolute URL. Use for `data:`, `file:`, or cross-origin tests.
- `visitRoute(route, key, params)` — resolve a named route via `URLFor()` and navigate there. Requires the Wheels app to be booted.
- `back()` / `forward()` / `refresh()` — drive the browser history.
Expand Down Expand Up @@ -316,7 +316,19 @@ When a spec fails, the `aroundEach` hook captures a screenshot and HTML dump to

The CI workflow sets `WHEELS_CI=true`, which `BrowserTest.beforeAll` interprets as an opt-out from running browser specs (they're skipped via `this.browserTestSkipped = true`). To run browser specs in CI, also set `WHEELS_BROWSER_CI_ENABLE=true` and ensure Playwright is installed on the runner. CI caches the Playwright JARs and Chromium via the `browser-manifest.json` hash; first runs pay the download cost once.

Set `WHEELS_BROWSER_TEST_BASE_URL` so `visit("/path")` resolves to the right origin on the CI runner. For local work, the default `http://localhost:8080` matches `wheels server run --port=8080`. See [CI Integration](/v4-0-0/testing/ci-integration/) for the full workflow.
Set `WHEELS_BROWSER_TEST_BASE_URL`, set `this.baseUrl` in the component pseudo-constructor, or rely on CGI auto-detect so `visit("/path")` resolves to the right origin. The env var is the standard CI escape hatch; `this.baseUrl` takes priority and lets individual specs target a different port without touching the environment. Set it in the pseudo-constructor — outside any function — so it is in place before `super.beforeAll()` runs `$resolveBaseUrl()` and caches the value:

```cfm
component extends="wheels.wheelstest.BrowserTest" {
this.baseUrl = "http://staging:60050"; // pseudo-constructor — set before super.beforeAll()

function run() {
// specs ...
}
}
```

For local work against a non-8080 server (Titan on 60050, `wheels new` scaffolds on 60080), CGI auto-detect picks up the correct host and port automatically. See [CI Integration](/v4-0-0/testing/ci-integration/) for the full workflow.

## When to skip browser tests

Expand Down
Loading
Loading