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 spec
This 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 list
Workaround 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.wo as a struct, not via getMetaData. Replace the loop in vendor/wheels/WheelsTest.cfc:13-25 with:
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. Then Global.cfc maintains a variables.$customHelpers struct 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 (where app/global/functions.cfm is 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.
Summary
The
WheelsTestbase class auto-binds Wheels globals fromapplication.wointo the spec'svariablesandthisscopes (so tests can callmodel(...),urlFor(...), etc. bare). The mechanism usesgetMetaData(application.wo).functionsto enumerate methods.That enumeration misses user-defined global helpers added via
includefromapp/global/functions.cfm. The functions ARE present on theapplication.woobject —structKeyExists(application.wo, "myHelper")returns true andapplication.wo.myHelper(...)works at runtime — butgetMetaData(application.wo).functionsdoesn't list them because the metadata system only reports methods defined directly on the CFC, not symbols injected viainclude.Net effect: every spec that exercises a user-defined global helper has to manually rebind in
beforeAll():This 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
includeconvention (this is how every Titan helper is added):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).functionsreflects methods defined asfunction ... { }directly within the CFC body (or inherited throughextends). It does NOT include symbols that were merged into the component's scope via<cfinclude>orinclude "..."from a<cfscript>block — those are "external" functions that happen to be reachable through the CFC'sthis/variablesscope but aren't part of its metadata.Wheels' Global.cfc loads
app/global/functions.cfmviaincludenear the bottom of its file. Helpers added via that include become struct keys onapplication.wobut aren't enumerable viagetMetaData.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
4.0.0-SNAPSHOT+1779vendor/wheels/WheelsTest.cfc:13-25(the auto-bind loop)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 insideDataPaiPermissionSpec.cfc); future admin-UI specs in Phase 1 will all need it too.