Skip to content

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

Closed

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.

Activity

  1. wheels-bot commented on May 21, 2026

    @wheels-bot
    Contributor

    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 at vendor/wheels/WheelsTest.cfc:13-25 (the pseudo-constructor that auto-binds application.wo methods into spec scope). See .ai/wheels/testing/ for adjacent context.

    Fix sketch

    The reporter's diagnosis is on point: getMetaData(application.wo).functions only enumerates methods defined directly on the CFC body, not symbols merged in via <cfinclude> / include. Since Wheels' Global.cfc pulls user helpers in through app/global/functions.cfm via include, those helpers exist on application.wo as struct keys but are invisible to the auto-bind loop.

    Likely fix: replace the metadata-driven loop at vendor/wheels/WheelsTest.cfc:13-25 with a struct-key iteration over application.wo that uses isCustomFunction() 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:

    1. Confirm isCustomFunction() and struct-key iteration over application.wo behave identically on Lucee 6/7, Adobe CF 2018/2021/2023/2025, and BoxLang (run tools/test-matrix.sh adobe2023 mysql and tools/test-matrix.sh lucee7 mysql at minimum).
    2. Add a spec under vendor/wheels/tests/specs/ that registers a synthetic include-style helper on application.wo before construction and asserts it ends up in variables / this.
    3. Keep the existing public-only and "don't clobber existing scope" guards.

    Confidence: medium

    Report 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 -->

  2. wheels-bot commented on May 21, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Fix proposed

    Draft PR: #2793

    Spec: vendor/wheels/tests/specs/wheelstest/WheelsTestAutoBindIncludesSpec.cfc — failing → passing
    Implementation: vendor/wheels/WheelsTest.cfc

    The auto-bind loop now iterates application.wo as a struct and uses isCustomFunction() to detect UDFs — catching both metadata-declared methods and helpers injected via cfinclude from app/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 by bot-update-docs.yml.

  3. added 4 commits that reference this issue on May 22, 2026
    343ae6f
    b0f22ad
    c5efa62
    7b2ca56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions