Skip to content

WheelsTest: custom global helpers added via include don't auto-bind into spec scope (getMetaData misses them) #2790

@bpamiri

Description

@bpamiri

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

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions