Repository navigation
WheelsTest: custom global helpers added via include don't auto-bind into spec scope (getMetaData misses them) #2790
Description
Summary
The WheelsTest base class auto-binds Wheels globals from application.wo into the spec's variables and this scopes (so tests can call model(...), urlFor(...), etc. bare). The mechanism uses getMetaData(application.wo).functions to enumerate methods.
That enumeration misses user-defined global helpers added via include from app/global/functions.cfm. The functions ARE present on the application.wo object — structKeyExists(application.wo, "myHelper") returns true and application.wo.myHelper(...) works at runtime — but getMetaData(application.wo).functions doesn't list them because the metadata system only reports methods defined directly on the CFC, not symbols injected via include.
Net effect: every spec that exercises a user-defined global helper has to manually rebind in beforeAll():
variables.myHelper = application.wo.myHelper;
variables.anotherHelper = application.wo.anotherHelper;
// ... one line per helper, per specThis boilerplate is a maintenance tax. Forget it and the spec fails with a confusing "function not defined" error even though the function clearly works in controllers/views/models.
Repro
Set up a custom global helper via the include convention (this is how every Titan helper is added):
// app/global/functions.cfm
<cfscript>
include "cfml.cfm";
include "datapai/helpers.cfm"; // includes our custom helpers
</cfscript>// app/global/datapai/helpers.cfm
<cfscript>
function can(required string permissionKey) {
return StructKeyExists(session, "datapai")
&& StructKeyExists(session.datapai, "permissions")
&& StructKeyExists(session.datapai.permissions, arguments.permissionKey);
}
</cfscript>Then in a controller it works:
// app/controllers/Foo.cfc
component extends="Controller" {
function index() {
if (can("intranet.reports.view")) { /* ... */ } // works fine
}
}But in a spec:
// tests/specs/datapai/helpers/CanSpec.cfc
component extends="wheels.WheelsTest" {
function run() {
describe("can()", () => {
it("returns true for a granted permission", () => {
session.datapai = { permissions: { "intranet.reports.view": true } };
expect(can("intranet.reports.view")).toBeTrue();
// ^^^ throws: "function 'can' not defined"
});
});
}
}Investigation:
// In a debug it() block:
writeDump(structKeyExists(application.wo, "can")); // true
writeDump(application.wo.can("intranet.reports.view")); // works
writeDump(getMetaData(application.wo).functions); // 'can' is NOT in this listWorkaround that every Titan datapai spec uses:
function beforeAll() {
variables.can = application.wo.can;
variables.canAny = application.wo.canAny;
variables.canAll = application.wo.canAll;
variables.hasRole = application.wo.hasRole;
// ... 7 more for the rest of our helpers
}Why this happens
getMetaData(target).functions reflects methods defined as function ... { } directly within the CFC body (or inherited through extends). It does NOT include symbols that were merged into the component's scope via <cfinclude> or include "..." from a <cfscript> block — those are "external" functions that happen to be reachable through the CFC's this/variables scope but aren't part of its metadata.
Wheels' Global.cfc loads app/global/functions.cfm via include near the bottom of its file. Helpers added via that include become struct keys on application.wo but aren't enumerable via getMetaData.
Suggested fixes
-
Iterate
application.woas a struct, not viagetMetaData. Replace the loop invendor/wheels/WheelsTest.cfc:13-25with:if (structKeyExists(application, "wo")) { for (local.key in application.wo) { if (isCustomFunction(application.wo[local.key])) { local.methodExists = structKeyExists(variables, local.key) || structKeyExists(this, local.key); if (!local.methodExists) { variables[local.key] = application.wo[local.key]; this[local.key] = application.wo[local.key]; } } } }isCustomFunction()is a Lucee/CFML built-in that distinguishes UDFs from other values. This pattern catches both metadata-enumerable AND include-injected functions. -
Alternative: add a
$registerHelper(name, function)API to Global.cfc so consumers explicitly opt their helpers into the auto-bind path. ThenGlobal.cfcmaintains avariables.$customHelpersstruct that WheelsTest reads. -
Doc fix as an interim: document the limitation in the testing guide and ship a
bindGlobals(["can", "canAny", ...])helper on WheelsTest that takes a list of names and rebinds them. Less elegant than (1) but unblocks users immediately.
Repo / version
- Wheels Core:
4.0.0-SNAPSHOT+1779 - File:
vendor/wheels/WheelsTest.cfc:13-25(the auto-bind loop) - Adjacent:
vendor/wheels/Global.cfc(whereapp/global/functions.cfmis included)
Where found
Surfaced writing tests for a 10-function custom-helper module in Titan's DataPAI portal (Phase 0). Every spec that calls a custom helper needs the manual rebind. We have 4 specs that exercise the helpers (CanSpec.cfc, DefaultLandingUrlSpec.cfc, LoadSessionSpec.cfc, and a load-bearing rebind inside DataPaiPermissionSpec.cfc); future admin-UI specs in Phase 1 will all need it too.
Activity
wheels-bot commentedon May 21, 2026
Wheels Bot — Triage
Classified as bug.
Suspected layer
config— closest fit from the documented layer list, but the bug actually lives in test-infrastructure code atvendor/wheels/WheelsTest.cfc:13-25(the pseudo-constructor that auto-bindsapplication.womethods into spec scope). See.ai/wheels/testing/for adjacent context.Fix sketch
The reporter's diagnosis is on point:
getMetaData(application.wo).functionsonly enumerates methods defined directly on the CFC body, not symbols merged in via<cfinclude>/include. Since Wheels'Global.cfcpulls user helpers in throughapp/global/functions.cfmvia include, those helpers exist onapplication.woas struct keys but are invisible to the auto-bind loop.Likely fix: replace the metadata-driven loop at
vendor/wheels/WheelsTest.cfc:13-25with a struct-key iteration overapplication.wothat usesisCustomFunction()to identify UDFs (the issue's suggested fix #1).isCustomFunction()is already used elsewhere in the framework (vendor/wheels/wheelstest/system/BaseSpec.cfc,Assertion.cfc), so it's a known-portable building block. Propose-fix should:isCustomFunction()and struct-key iteration overapplication.wobehave identically on Lucee 6/7, Adobe CF 2018/2021/2023/2025, and BoxLang (runtools/test-matrix.sh adobe2023 mysqlandtools/test-matrix.sh lucee7 mysqlat minimum).vendor/wheels/tests/specs/that registers a synthetic include-style helper onapplication.wobefore construction and asserts it ends up invariables/this.Confidence:
mediumReport is clear and the fix location is unambiguous, but the replacement leans on
isCustomFunction()plus struct iteration over a CFC scope — both areas with historical cross-engine variance — so the cross-engine auto-downgrade rule applies.<!-- wheels-bot:triage:2790 -->
<!-- wheels-bot:triage-class:bug -->
<!-- wheels-bot:triage-confidence:medium -->
wheels-bot commentedon May 21, 2026
Wheels Bot — Fix proposed
Draft PR: #2793
Spec:
vendor/wheels/tests/specs/wheelstest/WheelsTestAutoBindIncludesSpec.cfc— failing → passingImplementation:
vendor/wheels/WheelsTest.cfcThe auto-bind loop now iterates
application.woas a struct and usesisCustomFunction()to detect UDFs — catching both metadata-declared methods and helpers injected viacfincludefromapp/global/functions.cfm. The public-only filter and don't-clobber guard from the previous loop are preserved.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): address Reviewer A/B consensus findings (round 1)
fix(test): auto-bind include-injected globals into WheelsTest spec sc…
Merge branch 'develop' into fix/bot-2791-routing-inside-namespace-foo…
chore: merge develop into PR branch to resolve CHANGELOG conflict
Release 4.0.2 (#2819)