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
1 change: 1 addition & 0 deletions .ai/wheels/testing/browser-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,6 @@ bash tools/test-local.sh # skips browser specs if JARs missin
- **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. 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.
- **Isolated application context (#3374)** — `BrowserTest.$startBrowserContext()` sends `X-Wheels-Test-Context` (Playwright `extraHTTPHeaders`) plus a `WHEELS_TEST_CONTEXT` cookie so fixture HTTP binds `<this.name>_wheelsTest`, not the live app. `Application.cfc` must include `vendor/wheels/events/testcontext.cfm` after `config/app.cfm` (ships in `wheels new`). Without that snippet, browser tests still work via the #3373 live-scope swap.
- **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)
- **Dialogs are Lucee-only** — `acceptDialog`, `dismissDialog`, `dialogMessage` use `createDynamicProxy` which is Lucee-specific. Specs skip gracefully on other engines.
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,8 @@ component extends="wheels.WheelsTest" {
- **App tests**: `/wheels/app/tests` — project-specific, in `tests/specs/`. Uses `tests/populate.cfm` and `tests/TestRunner.cfc`.
- **Core tests**: `/wheels/core/tests` — framework, in `vendor/wheels/tests/specs/`. Uses `vendor/wheels/tests/populate.cfm`. **This is what CI runs across all engines × DBs.**

**Isolated test application (#3374):** `Application.cfc` includes `vendor/wheels/events/testcontext.cfm` after `config/app.cfm` so runner URLs (and TestClient/browser requests that send `X-Wheels-Test-Context`) bind `<this.name>_wheelsTest` — a separate CFML application scope. The live `application.wheels` is not swapped. `$testClient(testContext=false)` addresses the live app. A request-scoped overlay cannot replace this (blockers B1–B9 on #3025). Existing apps without the include still use the #3373 named-lock swap on the live scope.

**Critical**: core tests use `directory="wheels.tests.specs"` which compiles EVERY CFC in the directory. One compilation error in any spec file crashes the entire suite for that engine. The "inline closure as constructor named arg" anti-pattern (#5 in Cross-Engine Invariants) is the classic example.

### Test-specific gotchas
Expand Down
10 changes: 10 additions & 0 deletions changelog.d/test-runner-app-isolation.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- Web test runner isolation: `/wheels/core/tests` and `/wheels/app/tests` (and
TestClient / browser requests that send `X-Wheels-Test-Context` or the
`WHEELS_TEST_CONTEXT` cookie) now bind a separate CFML application name
(`<this.name>_wheelsTest`) when `Application.cfc` includes
`vendor/wheels/events/testcontext.cfm` after `config/app.cfm`. The live
`application.wheels` is no longer swapped for the duration of a run, so
concurrent normal requests keep production config. The snippet ships in
`wheels new` and the demo app; existing apps keep the [#3373](https://github.com/wheels-dev/wheels/pull/3373)
named-lock swap on the live scope until they add the include
(refs [#3374](https://github.com/wheels-dev/wheels/issues/3374)).
7 changes: 7 additions & 0 deletions cli/lucli/templates/app/public/Application.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ component output="false" {

include "../config/app.cfm";

// Issue #3374: bind test-runner / TestClient / browser requests to a
// separate CFML application name so the live application.wheels is never
// swapped. No-op when vendor/wheels is absent.
if (FileExists(GetDirectoryFromPath(GetCurrentTemplatePath()) & "../vendor/wheels/events/testcontext.cfm")) {
include "../vendor/wheels/events/testcontext.cfm";
}

function onApplicationStart() {
application.env = duplicate(this.env);

Expand Down
7 changes: 7 additions & 0 deletions examples/starter-app/public/Application.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ component output="false" {

include "../config/app.cfm";

// Issue #3374: bind test-runner / TestClient / browser requests to a
// separate CFML application name so the live application.wheels is never
// swapped. No-op when vendor/wheels is absent.
if (FileExists(GetDirectoryFromPath(GetCurrentTemplatePath()) & "../vendor/wheels/events/testcontext.cfm")) {
include "../vendor/wheels/events/testcontext.cfm";
}

function onApplicationStart() {
// Consume the single-use reload-password handoff left by
// $handleRestartAppRequest() for environment-switch restarts (issue #3030).
Expand Down
7 changes: 7 additions & 0 deletions examples/tweet/public/Application.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ component output="false" {

include "../config/app.cfm";

// Issue #3374: bind test-runner / TestClient / browser requests to a
// separate CFML application name so the live application.wheels is never
// swapped. No-op when vendor/wheels is absent.
if (FileExists(GetDirectoryFromPath(GetCurrentTemplatePath()) & "../vendor/wheels/events/testcontext.cfm")) {
include "../vendor/wheels/events/testcontext.cfm";
}

function onApplicationStart() {
// Consume the single-use reload-password handoff left by
// $handleRestartAppRequest() for environment-switch restarts (issue #3030).
Expand Down
7 changes: 7 additions & 0 deletions public/Application.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ component output="false" {
// config/app.cfm can reference this.env safely (issue #2325).
include "../config/app.cfm";

// Issue #3374: bind test-runner / TestClient / browser requests to a
// separate CFML application name so the live application.wheels is never
// swapped. No-op when vendor/wheels is absent (examples without a vendor tree).
if (FileExists(GetDirectoryFromPath(GetCurrentTemplatePath()) & "../vendor/wheels/events/testcontext.cfm")) {
include "../vendor/wheels/events/testcontext.cfm";
}

function onApplicationStart() {
application.env = duplicate(this.env);

Expand Down
2 changes: 2 additions & 0 deletions tools/test-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ if [ -n "$FILTER" ]; then
middleware) FILTER="wheels.tests.specs.middleware" ;;
dispatch) FILTER="wheels.tests.specs.dispatch" ;;
migrator) FILTER="wheels.tests.specs.migrator" ;;
internal) FILTER="wheels.tests.specs.internal" ;;
interfaces) FILTER="wheels.tests.specs.interfaces" ;;
esac
TEST_URL="${TEST_URL}&directory=${FILTER}"
fi
Expand Down
16 changes: 14 additions & 2 deletions vendor/wheels/WheelsTest.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,21 @@ component extends="wheels.wheelstest.system.BaseSpec" {
/**
* Return a configured TestClient instance.
* The base URL is auto-detected from the current server port.
*
* @testContext When true (default), send the isolation header + cookie so
* fixture HTTP binds the isolated test application (issue #3374). Pass
* false to address the live application (isolation specs).
*/
public any function $testClient() {
return new wheels.wheelstest.TestClient(baseUrl = $getTestBaseUrl());
public any function $testClient(boolean testContext = true) {
// Do not name this local `client` — that is a reserved CFML scope
// and Lucee throws "client scope is not enabled" (anti-pattern 11).
var httpClient = new wheels.wheelstest.TestClient(baseUrl = $getTestBaseUrl());
if (arguments.testContext) {
var ctx = new wheels.events.TestContext();
httpClient.withHeader(ctx.headerName(), "1");
httpClient.withCookie(ctx.cookieName(), "1");
}
return httpClient;
}

/**
Expand Down
119 changes: 119 additions & 0 deletions vendor/wheels/events/TestContext.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Helpers for the web test-runner's isolated CFML application context (issue #3374).
*
* A request-scoped config overlay cannot re-bake dialect adapters, model
* caches, or routes (blockers B1–B9 on #3025). The supported isolation
* model is a second application name, derived in Application.cfc's
* constructor via events/testcontext.cfm. This CFC is the runtime twin
* of that include: same suffix / header / cookie names, unit-testable
* without booting a second application.
*
* Do not instantiate this from Application.cfc's constructor — `this.mappings`
* is not guaranteed to be registered yet. The .cfm include inlines the
* same checks.
*/
component {

/**
* Suffix appended to this.name for isolated test requests.
*/
public string function applicationNameSuffix() {
return "_wheelsTest";
}

/**
* HTTP header TestClient / ParallelRunner / Playwright send so fixture
* and browser requests (which are NOT /wheels/core/tests) bind the
* isolated application. CGI key is http_x_wheels_test_context.
*/
public string function headerName() {
return "X-Wheels-Test-Context";
}

/**
* CGI struct key for headerName() after the engine's CGI mapping.
*/
public string function cgiHeaderKey() {
return "http_x_wheels_test_context";
}

/**
* Cookie name (backup for Playwright follow-on navigations).
*/
public string function cookieName() {
return "WHEELS_TEST_CONTEXT";
}

/**
* True when applicationName already carries the isolation suffix.
*/
public boolean function isIsolatedApplicationName(required string applicationName) {
var suffix = applicationNameSuffix();
var nameLen = Len(arguments.applicationName);
var suffixLen = Len(suffix);
if (nameLen < suffixLen) {
return false;
}
return Right(arguments.applicationName, suffixLen) == suffix;
}

/**
* Return applicationName with the isolation suffix, idempotent.
*/
public string function isolatedApplicationName(required string applicationName) {
if (isIsolatedApplicationName(arguments.applicationName)) {
return arguments.applicationName;
}
return arguments.applicationName & applicationNameSuffix();
}

/**
* True when this request should bind the isolated test application.
*
* Markers (any one is enough):
* - URL path contains /wheels/core/tests or /wheels/app/tests
* - X-Wheels-Test-Context header (CGI http_x_wheels_test_context)
* - WHEELS_TEST_CONTEXT cookie
*
* Parameter names avoid reserved CGI/cookie/url/request scopes
* (anti-pattern 11 / invariant 15).
*/
public boolean function requestIsTestContext(struct cgiScope = {}, struct cookieScope = {}) {
var haystack = $cgiHaystack(arguments.cgiScope);
if (FindNoCase("/wheels/core/tests", haystack) || FindNoCase("/wheels/app/tests", haystack)) {
return true;
}

var headerKey = cgiHeaderKey();
if (StructKeyExists(arguments.cgiScope, headerKey) && Len(ToString(arguments.cgiScope[headerKey]))) {
return true;
}

var cName = cookieName();
if (StructKeyExists(arguments.cookieScope, cName) && Len(ToString(arguments.cookieScope[cName]))) {
return true;
}

return false;
}

/**
* Concatenate the CGI fields that can carry the runner path under
* rewrite, subdirectory, and query-string front-controller shapes.
*/
public string function $cgiHaystack(required struct cgiScope) {
var haystack = "";
var keys = "path_info,script_name,query_string,request_url,http_url";
var i = 0;
var key = "";
var keyCount = ListLen(keys);
for (i = 1; i <= keyCount; i++) {
key = ListGetAt(keys, i);
if (StructKeyExists(arguments.cgiScope, key)) {
haystack &= " " & ToString(arguments.cgiScope[key]);
}
}
return haystack;
}

}
75 changes: 75 additions & 0 deletions vendor/wheels/events/testcontext.cfm
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<cfscript>
// Included from Application.cfc AFTER config/app.cfm finalizes this.name.
// Issue #3374: bind test-runner / TestClient / browser requests to a
// separate CFML application scope so the live application.wheels is never
// mutated. This file is constructor-context (not a function) — do not use
// the local scope; temp state lives on this.wheels and is deleted after.
//
// Keep the suffix / header CGI key / cookie name in lockstep with
// wheels.events.TestContext — TestRunnerIsolationSpec scans both.
//
// Cannot CreateObject("wheels.events.TestContext") from here: this.mappings
// is not guaranteed to be registered during Application.cfc's constructor.

if (StructKeyExists(this, "name") && Len(this.name)) {
this.wheels.$testContext = {
suffix = "_wheelsTest",
haystack = "",
match = false
};

if (
Len(this.name) >= Len(this.wheels.$testContext.suffix)
&& Right(this.name, Len(this.wheels.$testContext.suffix)) == this.wheels.$testContext.suffix
) {
this.wheels.$testContext.match = true;
} else {
if (IsDefined("cgi.path_info")) {
this.wheels.$testContext.haystack &= " " & ToString(cgi.path_info);
}
if (IsDefined("cgi.script_name")) {
this.wheels.$testContext.haystack &= " " & ToString(cgi.script_name);
}
if (IsDefined("cgi.query_string")) {
this.wheels.$testContext.haystack &= " " & ToString(cgi.query_string);
}
if (IsDefined("cgi.request_url")) {
this.wheels.$testContext.haystack &= " " & ToString(cgi.request_url);
}
if (IsDefined("cgi.http_url")) {
this.wheels.$testContext.haystack &= " " & ToString(cgi.http_url);
}

if (
FindNoCase("/wheels/core/tests", this.wheels.$testContext.haystack)
|| FindNoCase("/wheels/app/tests", this.wheels.$testContext.haystack)
) {
this.wheels.$testContext.match = true;
}

if (
!this.wheels.$testContext.match
&& IsDefined("cgi.http_x_wheels_test_context")
&& Len(ToString(cgi.http_x_wheels_test_context))
) {
this.wheels.$testContext.match = true;
}

if (!this.wheels.$testContext.match) {
try {
if (IsDefined("cookie.WHEELS_TEST_CONTEXT") && Len(ToString(cookie.WHEELS_TEST_CONTEXT))) {
this.wheels.$testContext.match = true;
}
} catch (any e) {
// cookie scope unavailable in this constructor — header/path still apply
}
}

if (this.wheels.$testContext.match) {
this.name = this.name & this.wheels.$testContext.suffix;
}
}

StructDelete(this.wheels, "$testContext");
}
</cfscript>
42 changes: 35 additions & 7 deletions vendor/wheels/tests/runner.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@
application.wo.set(viewPath = AssetPath & "views")
application.wo.set(modelPath = AssetPath & "models")
application.wo.set(wheelsComponentPath = "/wheels")
// Isolated-app boot (issue #3374) mounts browser-fixture controllers
// onto controllerPath. Drop that flag so a later $lockedLoadRoutes
// cannot append the fixture path after this swap — otherwise
// controller("wheels") falls through to the last-path Controller.cfc
// stub (no mixins). Test-asset BrowserTest* controllers + tests/routes.cfm
// keep /_browser working.
application.wo.set(loadBrowserTestFixtures = false)
// Drop class caches from the isolated app's onApplicationStart
// (and from any prior run). Same reason as the model-cache clear
// below: those instances were baked against the live paths.
// StructClear — do not call application.wo.$clearControllerInitializationCache()
// here: this closure is Adobe 2025 invariant 16b (zero-arg call
// through the application scope).
if (StructKeyExists(application.wheels, "controllers")) {
StructClear(application.wheels.controllers)
}
if (StructKeyExists(application.wheels, "existingObjectFiles")) {
StructClear(application.wheels.existingObjectFiles)
}
if (StructKeyExists(application.wheels, "nonExistingObjectFiles")) {
StructClear(application.wheels.nonExistingObjectFiles)
}

/* set migration level for tests*/
application.wheels.migrationLevel = 2;
Expand Down Expand Up @@ -166,15 +188,21 @@
bundlesDiscovered = local.bundlesDiscovered
)

// ── Concurrency guard (issue #3025) ─────────────────────────────────
// The swap→run→restore window below mutates the LIVE application.wheels
// struct ($_setTestboxEnv backs it up in application.$$$wheels and swaps
// in test config; the finally block swaps it back). Two overlapping test
// requests used to clobber each other's backup, which could restore TEST
// config as the live config until the next reload=true. Serialize the
// whole window under an exclusive named lock (precedent:
// ── Concurrency guard (issue #3025) + isolated app (issue #3374) ──
// The swap→run→restore window below mutates application.wheels
// ($_setTestboxEnv backs it up in application.$$$wheels and swaps
// in test config; the finally block swaps it back). When
// Application.cfc includes events/testcontext.cfm, test-runner
// requests bind `<this.name>_wheelsTest` — a separate CFML application
// scope — so this swap never touches the live app's application.wheels.
// Apps that have not applied that snippet still swap the live scope;
// the exclusive named lock below remains the fallback (precedent:
// migrator/TenantMigrator.cfc::$runForTenant).
//
// Two overlapping test requests used to clobber each other's backup,
// which could restore TEST config as the live config until the next
// reload=true. Serialize the whole window under an exclusive named lock.
//
// Re-entrancy: ParallelRunner partitions re-enter this template via
// fresh top-level HTTP GETs while the parent request holds the swap and
// the lock. Those sub-requests detect the already-applied swap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ component extends="wheels.WheelsTest" {
describe("Controller Interface Contracts", () => {

beforeEach(() => {
// Create a controller instance to test mixin methods
ctrl = controller("wheels");
// Use a real test-asset controller file (Test.cfc). controller("wheels")
// has no matching Wheels.cfc, so $createControllerClass falls through
// to the last-path Controller.cfc stub — the browser-fixture stub
// when that path is still on the search list — which is not $init'd
// with mixins (issue ##3374).
ctrl = controller("Test");
});

describe("ControllerFilterInterface", () => {
Expand Down
5 changes: 3 additions & 2 deletions vendor/wheels/tests/specs/interfaces/ViewInterfaceSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ component extends="wheels.WheelsTest" {
describe("View Interface Contracts", () => {

beforeEach(() => {
// Controller instances have view helpers mixed in
ctrl = controller("wheels");
// Same reason as ControllerInterfaceSpec: a real test-asset
// controller file so mixin helpers are present (issue ##3374).
ctrl = controller("Test");
});

describe("ViewFormInterface", () => {
Expand Down
Loading
Loading