From da1f73a51aa3b36a741b6b58ba1632608d537090 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 13:50:52 +0000 Subject: [PATCH 1/3] fix(test): isolate the web runner in a separate application scope (#3374) Bind test-runner, TestClient, and browser requests to _wheelsTest so the live application.wheels is never swapped. Keep the #3373 named lock as a fallback for apps without the Application.cfc include. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- .ai/wheels/testing/browser-testing.md | 1 + CLAUDE.md | 2 + .../test-runner-app-isolation.fixed.md | 10 + .../templates/app/public/Application.cfc | 7 + examples/starter-app/public/Application.cfc | 7 + examples/tweet/public/Application.cfc | 7 + public/Application.cfc | 7 + vendor/wheels/WheelsTest.cfc | 14 +- vendor/wheels/events/TestContext.cfc | 119 ++++++++++++ vendor/wheels/events/testcontext.cfm | 75 ++++++++ vendor/wheels/tests/runner.cfm | 20 +- .../internal/TestRunnerIsolationSpec.cfc | 182 ++++++++++++++++++ vendor/wheels/wheelstest/BrowserTest.cfc | 50 +++-- vendor/wheels/wheelstest/ParallelRunner.cfc | 8 +- .../v4-0-0/testing/fixtures-and-test-data.mdx | 4 +- .../v4-0-0/testing/running-tests-locally.mdx | 1 + 16 files changed, 488 insertions(+), 26 deletions(-) create mode 100644 changelog.d/test-runner-app-isolation.fixed.md create mode 100644 vendor/wheels/events/TestContext.cfc create mode 100644 vendor/wheels/events/testcontext.cfm create mode 100644 vendor/wheels/tests/specs/internal/TestRunnerIsolationSpec.cfc diff --git a/.ai/wheels/testing/browser-testing.md b/.ai/wheels/testing/browser-testing.md index 5592f6a020..b755661f8a 100644 --- a/.ai/wheels/testing/browser-testing.md +++ b/.ai/wheels/testing/browser-testing.md @@ -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 `_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. diff --git a/CLAUDE.md b/CLAUDE.md index adcc55f907..6eb19f355c 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `_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 diff --git a/changelog.d/test-runner-app-isolation.fixed.md b/changelog.d/test-runner-app-isolation.fixed.md new file mode 100644 index 0000000000..281bea4d7e --- /dev/null +++ b/changelog.d/test-runner-app-isolation.fixed.md @@ -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 + (`_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)). diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc index 45c0dd7a05..8d847f9069 100644 --- a/cli/lucli/templates/app/public/Application.cfc +++ b/cli/lucli/templates/app/public/Application.cfc @@ -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); diff --git a/examples/starter-app/public/Application.cfc b/examples/starter-app/public/Application.cfc index 99e870afc9..e3061464f4 100644 --- a/examples/starter-app/public/Application.cfc +++ b/examples/starter-app/public/Application.cfc @@ -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). diff --git a/examples/tweet/public/Application.cfc b/examples/tweet/public/Application.cfc index 99e870afc9..e3061464f4 100755 --- a/examples/tweet/public/Application.cfc +++ b/examples/tweet/public/Application.cfc @@ -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). diff --git a/public/Application.cfc b/public/Application.cfc index 7887d19228..a6a3891711 100644 --- a/public/Application.cfc +++ b/public/Application.cfc @@ -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); diff --git a/vendor/wheels/WheelsTest.cfc b/vendor/wheels/WheelsTest.cfc index 3c05644586..6238c80f6f 100644 --- a/vendor/wheels/WheelsTest.cfc +++ b/vendor/wheels/WheelsTest.cfc @@ -54,9 +54,19 @@ 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) { + var client = new wheels.wheelstest.TestClient(baseUrl = $getTestBaseUrl()); + if (arguments.testContext) { + var ctx = new wheels.events.TestContext(); + client.withHeader(ctx.headerName(), "1"); + client.withCookie(ctx.cookieName(), "1"); + } + return client; } /** diff --git a/vendor/wheels/events/TestContext.cfc b/vendor/wheels/events/TestContext.cfc new file mode 100644 index 0000000000..3afcfddc9e --- /dev/null +++ b/vendor/wheels/events/TestContext.cfc @@ -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; + } + +} diff --git a/vendor/wheels/events/testcontext.cfm b/vendor/wheels/events/testcontext.cfm new file mode 100644 index 0000000000..e8dd4976f0 --- /dev/null +++ b/vendor/wheels/events/testcontext.cfm @@ -0,0 +1,75 @@ + + // 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"); + } + diff --git a/vendor/wheels/tests/runner.cfm b/vendor/wheels/tests/runner.cfm index 13cccdb103..c4e56e442c 100644 --- a/vendor/wheels/tests/runner.cfm +++ b/vendor/wheels/tests/runner.cfm @@ -166,15 +166,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 `_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 diff --git a/vendor/wheels/tests/specs/internal/TestRunnerIsolationSpec.cfc b/vendor/wheels/tests/specs/internal/TestRunnerIsolationSpec.cfc new file mode 100644 index 0000000000..cace60b996 --- /dev/null +++ b/vendor/wheels/tests/specs/internal/TestRunnerIsolationSpec.cfc @@ -0,0 +1,182 @@ +/** + * Guards for issue #3374: the web test runner must not mutate the live + * application's application.wheels. A request-scoped overlay cannot re-bake + * dialect adapters, the app-scoped model cache, or routes (blockers B1–B9 + * on #3025). Isolation is a second CFML application name, derived in + * Application.cfc's constructor via events/testcontext.cfm. + * + * Coverage: + * + * 1. Unit — wheels.events.TestContext path / header / cookie detection + * (no HTTP, no second application). + * 2. Structural — testcontext.cfm, Application.cfc (demo + wheels new + * template), WheelsTest.$testClient, and runner.cfm stay wired together. + * 3. Behavioral — this suite is already inside the isolated application + * (name ends with _wheelsTest). A TestClient request WITHOUT the + * isolation marker hits /wheels/info on the LIVE application and must + * see a different application name. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("Web test-runner application isolation (issue ##3374)", () => { + + describe("TestContext detection", () => { + + it("suffixes an application name exactly once", () => { + var ctx = new wheels.events.TestContext(); + expect(ctx.applicationNameSuffix()).toBe("_wheelsTest"); + expect(ctx.isolatedApplicationName("wheels-dev")).toBe("wheels-dev_wheelsTest"); + expect(ctx.isolatedApplicationName("wheels-dev_wheelsTest")).toBe("wheels-dev_wheelsTest"); + expect(ctx.isIsolatedApplicationName("wheels-dev")).toBeFalse(); + expect(ctx.isIsolatedApplicationName("wheels-dev_wheelsTest")).toBeTrue(); + }); + + it("treats /wheels/core/tests and /wheels/app/tests paths as test context", () => { + var ctx = new wheels.events.TestContext(); + expect( + ctx.requestIsTestContext(cgiScope = {path_info = "/wheels/core/tests"}) + ).toBeTrue(); + expect( + ctx.requestIsTestContext(cgiScope = {script_name = "/index.cfm", path_info = "/wheels/app/tests"}) + ).toBeTrue(); + expect( + ctx.requestIsTestContext(cgiScope = {query_string = "controller=wheels&view=core/tests"}) + ).toBeFalse("a query string without the runner path is not a test context"); + expect( + ctx.requestIsTestContext(cgiScope = {path_info = "/", script_name = "/index.cfm"}) + ).toBeFalse(); + }); + + it("treats the isolation header or cookie as test context", () => { + var ctx = new wheels.events.TestContext(); + var cgiArgs = {}; + cgiArgs[ctx.cgiHeaderKey()] = "1"; + expect(ctx.requestIsTestContext(cgiScope = cgiArgs)).toBeTrue(); + + var cookieArgs = {}; + cookieArgs[ctx.cookieName()] = "1"; + expect(ctx.requestIsTestContext(cookieScope = cookieArgs)).toBeTrue(); + }); + + it("does not treat an empty header or cookie as test context", () => { + var ctx = new wheels.events.TestContext(); + var cgiArgs = {}; + cgiArgs[ctx.cgiHeaderKey()] = ""; + expect(ctx.requestIsTestContext(cgiScope = cgiArgs)).toBeFalse(); + + var cookieArgs = {}; + cookieArgs[ctx.cookieName()] = ""; + expect(ctx.requestIsTestContext(cookieScope = cookieArgs)).toBeFalse(); + }); + + }); + + describe("wiring", () => { + + it("testcontext.cfm and TestContext.cfc share suffix, header CGI key, and cookie name", () => { + var ctx = new wheels.events.TestContext(); + var includeSource = FileRead(ExpandPath("/wheels/events/testcontext.cfm")); + expect(Find(ctx.applicationNameSuffix(), includeSource) > 0).toBeTrue( + "testcontext.cfm must use the same application-name suffix as TestContext.cfc" + ); + expect(FindNoCase(ctx.cgiHeaderKey(), includeSource) > 0).toBeTrue( + "testcontext.cfm must read the same CGI header key as TestContext.cgiHeaderKey()" + ); + expect(FindNoCase(ctx.cookieName(), includeSource) > 0).toBeTrue( + "testcontext.cfm must read the same cookie name as TestContext.cookieName()" + ); + expect(FindNoCase("/wheels/core/tests", includeSource) > 0).toBeTrue(); + expect(FindNoCase("/wheels/app/tests", includeSource) > 0).toBeTrue(); + }); + + it("demo and wheels-new Application.cfc include testcontext.cfm after config/app.cfm", () => { + // /wheels → vendor/wheels; walk up to the repo root without + // relying on ExpandPath("..") which some engines refuse. + var eventsDir = GetDirectoryFromPath(ExpandPath("/wheels/events/testcontext.cfm")); + var wheelsDir = GetDirectoryFromPath(eventsDir); + var vendorDir = GetDirectoryFromPath(wheelsDir); + var repoRoot = GetDirectoryFromPath(vendorDir); + var files = [ + repoRoot & "public/Application.cfc", + repoRoot & "cli/lucli/templates/app/public/Application.cfc" + ]; + for (var filePath in files) { + expect(FileExists(filePath)).toBeTrue("expected Application.cfc at #filePath#"); + var source = FileRead(filePath); + var appIncludePos = FindNoCase("config/app.cfm", source); + var testIncludePos = FindNoCase("events/testcontext.cfm", source); + expect(appIncludePos > 0).toBeTrue("#filePath# must include config/app.cfm"); + expect(testIncludePos > 0).toBeTrue( + "#filePath# must include vendor/wheels/events/testcontext.cfm (issue ##3374)" + ); + expect(testIncludePos > appIncludePos).toBeTrue( + "#filePath# must include testcontext.cfm AFTER config/app.cfm so this.name is finalized" + ); + } + }); + + it("WheelsTest.$testClient sends the isolation header by default", () => { + var source = FileRead(ExpandPath("/wheels/WheelsTest.cfc")); + expect(Find("testContext", source) > 0).toBeTrue( + "WheelsTest.$testClient must accept a testContext argument" + ); + expect(FindNoCase("headerName()", source) > 0 || FindNoCase("X-Wheels-Test-Context", source) > 0).toBeTrue( + "WheelsTest.$testClient must send the isolation header so fixture HTTP binds the test application" + ); + }); + + it("runner.cfm documents the isolated application name and keeps the named-lock fallback", () => { + var source = FileRead(ExpandPath("/wheels/tests/runner.cfm")); + expect(FindNoCase("_wheelsTest", source) > 0).toBeTrue( + "runner.cfm must mention the isolated application-name suffix" + ); + expect(FindNoCase("wheelsTestRunner_", source) > 0).toBeTrue( + "runner.cfm must keep the exclusive named lock as the fallback for apps without the Application.cfc snippet" + ); + }); + + }); + + describe("in-flight isolation", () => { + + it("the suite itself is bound to the isolated application name", () => { + var ctx = new wheels.events.TestContext(); + expect(ctx.isIsolatedApplicationName(application.applicationName)).toBeTrue( + "the web runner request must bind `_wheelsTest` so application.wheels here is NOT the live app (issue ##3374). If this fails, Application.cfc is not including events/testcontext.cfm." + ); + }); + + it("a concurrent request without the test marker sees the live application name", () => { + // This spec runs inside the isolated application. A TestClient + // with testContext=false omits the header and cookie and hits + // a non-runner path, so Application.cfc must bind the LIVE + // application name. + var live = $testClient(testContext = false); + live.get(path = "/wheels/info", params = {format = "json"}); + expect(live.statusCode()).toBe( + 200, + "live /wheels/info?format=json must be reachable (development GUI)" + ); + + var payload = live.json(); + expect(IsStruct(payload)).toBeTrue(" /wheels/info?format=json must return a struct"); + expect(StructKeyExists(payload, "application")).toBeTrue(); + expect(StructKeyExists(payload.application, "name")).toBeTrue(); + + var ctx = new wheels.events.TestContext(); + expect(ctx.isIsolatedApplicationName(payload.application.name)).toBeFalse( + "a normal request must not bind the isolated test application — saw `#payload.application.name#` (issue ##3374)" + ); + expect(payload.application.name).notToBe( + application.applicationName, + "live and test requests must use different CFML application names" + ); + }); + + }); + + }); + } +} diff --git a/vendor/wheels/wheelstest/BrowserTest.cfc b/vendor/wheels/wheelstest/BrowserTest.cfc index fec60077da..2e5c3f3648 100644 --- a/vendor/wheels/wheelstest/BrowserTest.cfc +++ b/vendor/wheels/wheelstest/BrowserTest.cfc @@ -181,9 +181,16 @@ component extends="wheels.WheelsTest" { var contextOpts = $buildContextOptions(); - if (isObject(contextOpts)) { - variables.$context = variables.$browser.newContext(contextOpts); - } else { + // extraHTTPHeaders (Map) is the primary isolation marker so the first + // navigation binds the test application (issue #3374 / B6). Fall back + // to a bare context if the Java Map interop fails on an engine. + try { + if (IsObject(contextOpts)) { + variables.$context = variables.$browser.newContext(contextOpts); + } else { + variables.$context = variables.$browser.newContext(); + } + } catch (any e) { variables.$context = variables.$browser.newContext(); } variables.$page = variables.$context.newPage(); @@ -194,6 +201,16 @@ component extends="wheels.WheelsTest" { baseUrl=variables.$baseUrl, launcher=variables.$launcher ); + // Cookie backup so follow-on navigations / form posts stay isolated + // even if extraHTTPHeaders was dropped by the fallback above. + if (Len(variables.$baseUrl ?: "")) { + try { + var ctx = new wheels.events.TestContext(); + this.browser.setCookie(name = ctx.cookieName(), value = "1", url = variables.$baseUrl); + } catch (any cookieErr) { + // Best-effort: header may already be on the context. + } + } } public void function $endBrowserContext() { @@ -369,24 +386,29 @@ component extends="wheels.WheelsTest" { } /** - * Builds Browser$NewContextOptions if viewport config is set. - * Returns the options object, or empty string if no config. + * Builds Browser$NewContextOptions. Always sets extraHTTPHeaders so + * Playwright requests bind the isolated test application (issue #3374). + * Viewport is applied on top when configured. */ private any function $buildContextOptions() { - if (!structKeyExists(this, "browserViewport") || !len(this.browserViewport ?: "")) { - return ""; - } + var ctx = new wheels.events.TestContext(); + var headerMap = CreateObject("java", "java.util.LinkedHashMap").init(); + headerMap.put(ctx.headerName(), "1"); + var setterMap = {setExtraHTTPHeaders: headerMap}; - var dims = $resolveViewportDims(this.browserViewport); + if (StructKeyExists(this, "browserViewport") && Len(this.browserViewport ?: "")) { + var dims = $resolveViewportDims(this.browserViewport); - var viewport = variables.$launcher.$buildOption( - className="com.microsoft.playwright.options.ViewportSize", - constructorArgs=[dims.width, dims.height] - ); + var viewport = variables.$launcher.$buildOption( + className="com.microsoft.playwright.options.ViewportSize", + constructorArgs=[dims.width, dims.height] + ); + setterMap.setViewportSize = viewport; + } return variables.$launcher.$buildOption( className="com.microsoft.playwright.Browser$NewContextOptions", - setterMap={setViewportSize: viewport} + setterMap=setterMap ); } diff --git a/vendor/wheels/wheelstest/ParallelRunner.cfc b/vendor/wheels/wheelstest/ParallelRunner.cfc index 9df082a6af..fd914f276e 100644 --- a/vendor/wheels/wheelstest/ParallelRunner.cfc +++ b/vendor/wheels/wheelstest/ParallelRunner.cfc @@ -196,7 +196,13 @@ component { method = "GET", timeout = 600, result = "local.httpResult" - ); + ) { + // Path already matches /wheels/core|app/tests (isolated + // by Application.cfc). Header is belt-and-suspenders so + // a rewrite that hides PATH_INFO still binds the test + // application (issue #3374). + cfhttpparam(type = "header", name = "X-Wheels-Test-Context", value = "1"); + } if (listFirst(local.httpResult.statusCode, " ") == "200" || listFirst(local.httpResult.statusCode, " ") == "417") { thread.success = true; diff --git a/web/sites/guides/src/content/docs/v4-0-0/testing/fixtures-and-test-data.mdx b/web/sites/guides/src/content/docs/v4-0-0/testing/fixtures-and-test-data.mdx index 35c46be1fb..497dcbc9d8 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/testing/fixtures-and-test-data.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/testing/fixtures-and-test-data.mdx @@ -31,8 +31,8 @@ The trigger conditions live in `vendor/wheels/tests/runner.cfm`. The runner read This is a significant departure from Rails or Laravel, where the framework wraps each test in a transaction and rolls back. Wheels sets `application.wheels.transactionMode = "none"` in `runner.cfm`, so writes during a test run persist across specs. The two mechanisms you have for isolation are: (1) make `populate.cfm` idempotent so re-running it always resets the world, and (2) wrap destructive specs in a manual `transaction { ... }` block that rolls back. -