Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3251-app-test-runner-subpath.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The scaffolded `tests/runner.cfm` now resolves its include of the built-in app test runner through `$resolveSubpathInclude()` instead of a hardcoded absolute `/wheels/tests/app-runner.cfm` path. Under a URL subpath / CommandBox multi-subfolder install the bare `/wheels` mapping did not resolve, so `/wheels/app/tests` and `wheels test` failed; the include is now prefixed with the app's resolved `webPath` and works at the web root and under a subfolder alike (#3251, refs #2887)
7 changes: 6 additions & 1 deletion cli/lucli/templates/app/tests/runner.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,10 @@
Keep the include below as the last line (or replicate its body
inline) — the framework runner is what produces the JSON / HTML
output the rest of the system expects.
The include path is resolved through $resolveSubpathInclude so it
works both at the web root and under a URL subpath / CommandBox
multi-subfolder install, where a bare `/wheels/...` mapping does not
resolve (issue #3251).
--->
<cfinclude template="/wheels/tests/app-runner.cfm">
<cfinclude template="#application.wo.$resolveSubpathInclude('/wheels/tests/app-runner.cfm')#">
28 changes: 28 additions & 0 deletions vendor/wheels/Global.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -2622,6 +2622,34 @@ return local.$wheels;
return local.rv;
}

/**
* Internal function. Rewrites a framework-relative include path (e.g.
* `/wheels/tests/app-runner.cfm`) so it resolves under a URL subpath
* install (issue #3251). The shipped app test-runner template includes
* the built-in app runner via an absolute `/wheels/...` path, which only
* resolves when the app is mounted at the web root; under a CommandBox
* multi-subfolder / IIS-subfolder topology the `/wheels` mapping does not
* resolve and the include fails. Prefixing the resolved `webPath` (the
* same subpath derivation as $resolveFrameworkPaths) makes the include
* work in both root and subfolder installs. Pure so it can be unit-tested
* in isolation.
*/
public string function $resolveSubpathInclude(required string template, string webPath) {
// Default to the app's resolved webPath without a runtime default-arg
// expression (some engines evaluate those eagerly); callers in tests
// pass webPath explicitly.
local.wp = StructKeyExists(arguments, "webPath") ? arguments.webPath : application.wheels.webPath;
local.base = Len(local.wp) ? local.wp : "/";
if (Right(local.base, 1) != "/") {
local.base &= "/";
}
// Strip any leading slash(es) from the framework-relative template so
// the join produces a single boundary slash. Anchored to the start so
// it never touches interior path separators.
local.relative = ReReplace(arguments.template, "^/+", "");
return local.base & local.relative;
}

/**
* Internal function.
*/
Expand Down
88 changes: 88 additions & 0 deletions vendor/wheels/tests/specs/global/resolveSubpathIncludeSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
component extends="wheels.WheelsTest" {

function run() {

g = application.wo

describe("Tests that $resolveSubpathInclude (issue 3251)", () => {

// The app test-runner template ships
// `<cfinclude template="/wheels/tests/app-runner.cfm">`. The leading
// `/wheels` resolves only when the app is mounted at the web root.
// Under a CommandBox multi-subfolder / IIS-subfolder topology the
// mapping does not resolve, so the include fails. $resolveSubpathInclude
// rewrites a framework-relative include path against the resolved
// `webPath` (the same subpath derivation as $resolveFrameworkPaths) so
// the include works in both root and subfolder installs.

it("returns the absolute path unchanged for a root install (webPath '/')", () => {
expect(
g.$resolveSubpathInclude(
template = "/wheels/tests/app-runner.cfm",
webPath = "/"
)
).toBe("/wheels/tests/app-runner.cfm")
})

it("prefixes the subpath for a subfolder install", () => {
expect(
g.$resolveSubpathInclude(
template = "/wheels/tests/app-runner.cfm",
webPath = "/wheelsproject1/"
)
).toBe("/wheelsproject1/wheels/tests/app-runner.cfm")
})

it("handles a nested subpath", () => {
expect(
g.$resolveSubpathInclude(
template = "/wheels/tests/app-runner.cfm",
webPath = "/team/site/"
)
).toBe("/team/site/wheels/tests/app-runner.cfm")
})

it("normalizes a webPath missing its trailing slash", () => {
expect(
g.$resolveSubpathInclude(
template = "/wheels/tests/app-runner.cfm",
webPath = "/wheelsproject1"
)
).toBe("/wheelsproject1/wheels/tests/app-runner.cfm")
})

it("falls back to '/' when webPath is empty", () => {
expect(
g.$resolveSubpathInclude(
template = "/wheels/tests/app-runner.cfm",
webPath = ""
)
).toBe("/wheels/tests/app-runner.cfm")
})

it("tolerates a template that omits its leading slash", () => {
expect(
g.$resolveSubpathInclude(
template = "wheels/tests/app-runner.cfm",
webPath = "/wheelsproject1/"
)
).toBe("/wheelsproject1/wheels/tests/app-runner.cfm")
})

it("uses application.wheels.webPath when no webPath argument is passed (the production call shape)", () => {
// The shipped runner template (cli/lucli/templates/app/tests/runner.cfm)
// calls this with NO webPath argument, so the fallback branch that reads
// application.wheels.webPath is the only path real callers take. Pin it by
// asserting the no-arg result equals an explicit call passing the current
// webPath — this exercises the previously-uncovered branch without mutating
// global app state.
expect(
g.$resolveSubpathInclude(template = "/wheels/tests/app-runner.cfm")
).toBe(
g.$resolveSubpathInclude(template = "/wheels/tests/app-runner.cfm", webPath = application.wheels.webPath)
)
})

})
}
}
6 changes: 5 additions & 1 deletion web/sites/guides/src/content/docs/v4-0-0/testing/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ Every test in a Wheels app is a CFC under `tests/specs/`, grouped by what it exe
| Functional tests | `tests/specs/functional/` | Single-feature end-to-end — middleware + route + filter + action + view |
| Browser tests | `tests/specs/browser/` | Full UI flows driven by Playwright — JavaScript, Turbo, form submission |

<Aside>
`wheels new` scaffolds `tests/specs/controllers/`, `tests/specs/functional/`, and `tests/specs/models/`. Create `tests/specs/view/` and `tests/specs/browser/` yourself when you add those kinds of tests.
</Aside>

Model and controller tests are the bulk of a healthy suite. Integration tests cover journeys that span multiple actions and users. Functional tests verify one feature end-to-end through the full request pipeline. Browser tests cover what only exists in the browser — JavaScript-driven UI, Turbo Frame updates, anything where the server's response alone doesn't tell you whether the feature works.

## What a WheelsTest spec looks like
Expand Down Expand Up @@ -91,7 +95,7 @@ Four files drive your test setup. Knowing what each one does saves a lot of head

| File | What it does |
|------|--------------|
| `tests/TestRunner.cfc` | Sets up shared state. Runs before and after the whole suite. |
| `tests/runner.cfm` | Entry point for `/wheels/app/tests` and `wheels test`. Customise here for pre-test bootstrap — the built-in app runner handles the rest. |
| `tests/populate.cfm` | Seeds test data. Runs **once per test run**, not per spec. |
| `tests/_assets/models/` | Test-only models, often using `table()` to map to test tables. |
| `tests/specs/<category>/` | Where your actual specs live. |
Expand Down
Loading