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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ ColdBox is an HMVC (Hierarchical Model-View-Controller) framework designed for t
- Bodyless component calls, e.g. `bx:component;`
- `continue;` and `break;` statements for Adobe ColdFusion/Lucee compatibility.

### Annotations And Optional Values
- In BoxLang (`.bx`) classes and examples, write annotations as BoxLang annotations above the declaration, not as inline attributes: `@appMapping( "/root" )` and `@baseURL( "http://127.0.0.1:8080" )` on the lines before `class extends="coldbox.system.testing.BaseTestCase" {`. Keep `extends` and `implements` inline. CFML (`.cfc`) components keep inline attributes.
- Do not start a docblock line with `@` in an example, because BoxLang reads it as documentation metadata.
- Prefer the elvis operator `?:` and safe navigation `?.` over `structKeyExists()` and `isNull()` checks when they say the same thing.

## JavaScript Coding Standards

### Spacing and Formatting
Expand Down
20 changes: 10 additions & 10 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `COLDBOX-1456` `Bootstrap.onSessionStart()` ran the session start handler on a controller that was still loading during a reinit. It now skips the event until the controller is initiated.
- WireBox released a circular partner to other threads before the outer object finished wiring. A singleton, engine scope or CacheBox object built inside another build left the wiring set as soon as its own wiring ended, while holding a reference to the outer object. Keys now stay marked until the outermost build completes, tracked on the injector across all scopes.
- `COLDBOX-1455` WireBox singleton, engine (application, session, server) and CacheBox scopes handed objects to other threads before their dependencies were wired. A single wiring lock now makes other threads wait, while the wiring thread still resolves circular dependencies.

### Changed

- WireBox `CFScopes` scope renamed to `EngineScopes`

### Added

- Browser testing for ColdBox applications (BoxLang), built on TestBox browser support and bx-playwright: annotate any `BaseTestCase` with `@browser`, `@browserProfile` or `@baseURL` and it gets TestBox's `browse()`, `this.playwright()`, `browserAvailable()` and browser matchers, while it still loads your application like any integration test. `BaseTestCase` adds the ColdBox helpers `routeURL()`, `visitRoute()` and `assertRouteIs()` for named routes, including module routes. Logged-in tests use bx-playwright saved sessions. Needs a TestBox release with annotation-driven browser support (Ortus-Solutions/TestBox#222).
- `group()` now accepts a `meta` struct option that every route inside the group inherits. Nested groups merge outer-first and a route's own `meta()` values win on conflict. This lets a single group declare route metadata, for example the permissions consumed by security middleware.
- `Router.registerMiddleware()` registers a closure, lambda, object instance or WireBox ID under a name, singly or as a struct of `name : target` pairs, so it can be referenced from `middleware()`, `group( { middleware : [ name ] } )` and `withoutMiddleware( name )`. Registering a name that already exists, including a `middlewareGroup()` name, throws `Router.DuplicateMiddleware` unless `force = true`. (COLDBOX-1460)

### Fixed

- `event.route( "name@module" )` built module route links without a slash between the module entry point and the route pattern
- Adobe ColdFusion: a request context decorator copied the `this` reference of the original context, so its inherited methods ran against the original context and missed the decorator's own state and mocks
- `COLDBOX-1456` `Bootstrap.onSessionStart()` ran the session start handler on a controller that was still loading during a reinit. It now skips the event until the controller is initiated.
- WireBox released a circular partner to other threads before the outer object finished wiring. A singleton, engine scope or CacheBox object built inside another build left the wiring set as soon as its own wiring ended, while holding a reference to the outer object. Keys now stay marked until the outermost build completes, tracked on the injector across all scopes.
- `COLDBOX-1455` WireBox singleton, engine (application, session, server) and CacheBox scopes handed objects to other threads before their dependencies were wired. A single wiring lock now makes other threads wait, while the wiring thread still resolves circular dependencies.
- `BaseTestCase.execute()` (and the `get()`, `post()`, etc. helpers built on it) now runs route-scoped middleware registered with `Router.middleware()`, in the same order as the Bootstrap, so integration tests exercise the same request lifecycle as a real request.
- Passing a component instance as route middleware (for example `middleware( myObject )`) no longer fails on engines where `isStruct()` is true for components. (COLDBOX-1460)

### Changed

- WireBox `CFScopes` scope renamed to `EngineScopes`

## [8.2.0] - 2026-09-23

- <https://coldbox.ortusbooks.com/readme/release-history/whats-new-with-8.2.0>
Expand Down
223 changes: 223 additions & 0 deletions system/testing/BaseTestCase.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,229 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
return variables.env;
}

/********************************************* BROWSER TESTING ROUTE HELPERS *********************************************/

/**
* The path of a named route, without scheme and host, built by ColdBox's own event.route(), so it carries
* the routing app mapping and, for module routes (`name@module` or `module:name`), the module entry point.
* Use it with TestBox browser specs: annotate the test with `@browser`, `@browserProfile` or `@baseURL`.
*
* <pre>
* routeURL( "users.show", { id : 5 } ) // /users/5/
* routeURL( "home@blog" ) // /blog/home/
* </pre>
*
* @name The route name, `name@module` or `module:name` for module routes
* @params The route placeholder values, for example { id : 5 }
*
* @return The route path, with the query string when the link has one
*
* @throws InvalidArgumentException When the named route does not exist
*/
string function routeURL( required string name, struct params = {} ){
return routeLinkPath( getRequestContext().route( arguments.name, arguments.params ) )
}

/**
* Visit a named route with a bx-playwright page: page.visit( routeURL( name, params ) ). Relative routes
* resolve against the browser spec `baseURL`.
*
* @page The bx-playwright page, from browse()
* @name The route name, `name@module` or `module:name` for module routes
* @params The route placeholder values, for example { id : 5 }
*
* @return The page
*/
function visitRoute(
required page,
required string name,
struct params = {}
){
arguments.page.visit( routeURL( arguments.name, arguments.params ) )
return arguments.page
}

/**
* Assert that a bx-playwright page is on a named route. With params, the page path must be the path of
* routeURL( name, params ). Without params, the page path must match the route pattern, so any value of
* its placeholders passes. Like ColdBox routing, the match ignores case and the trailing slash, and the
* query string and hash are ignored. It waits for the page URL with bx-playwright's waitForUrl(), up to the
* bx-playwright assertion timeout (the `timeouts.assertion` setting).
*
* <pre>
* assertRouteIs( page, "users.show" ) // any user
* assertRouteIs( page, "users.show", { id : 5 } ) // user 5
* </pre>
*
* @page The bx-playwright page, from browse()
* @name The route name, `name@module` or `module:name` for module routes
* @params The route placeholder values, empty to match any value of the placeholders
*
* @return The page
*
* @throws TestBox.AssertionFailed When the page path does not match the route before the assertion timeout
*/
function assertRouteIs(
required page,
required string name,
struct params = {}
){
var expected = "route [#arguments.name#]"
var pathRegex = ""
if ( arguments.params.isEmpty() ) {
pathRegex = routePathRegex( arguments.name )
} else {
expected = "route [#arguments.name#] with params #serializeJSON( arguments.params )#"
pathRegex = quoteRouteRegex(
reReplace(
routeLinkPath( routeURL( arguments.name, arguments.params ), false ),
"/+$",
""
)
)
}
var urlRegex = "^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]*" & pathRegex & "/?(\?.*)?(##.*)?$"
var timeout = arguments.page.getConfig().timeouts.assertion ?: 5000
try {
arguments.page.waitForUrl( arguments.page.regex( urlRegex, "i" ), timeout )
} catch ( any e ) {
if ( !listFindNoCase( "Playwright.Timeout,Playwright.AssertionFailed", e.type ) ) {
throw( object = e )
}
throw(
type = "TestBox.AssertionFailed",
message = "Expected the page to be on #expected#, but the path is [#routeLinkPath( arguments.page.url() )#]",
detail = "The page URL must match the regex [#urlRegex#]. #e.message#"
)
}
return arguments.page
}

/**
* The path of a URL, without scheme and host.
*
* @link An absolute or relative URL
* @withQuery Keep the query string
*
* @return The raw path, plus the raw query string when there is one and withQuery is true
*/
private string function routeLinkPath( required string link, boolean withQuery = true ){
var uri = createObject( "java", "java.net.URI" ).create( arguments.link )
var path = uri.getRawPath() ?: ""
var query = uri.getRawQuery() ?: ""
if ( !len( path ) ) {
path = "/"
}
return arguments.withQuery && len( query ) ? path & "?" & query : path
}

/**
* The regex a page path must match to be on a named route, any value of its placeholders included: the
* routing path of the application, the module entry point and the route's own regex, which ColdBox
* builds from the route pattern and its constraints. Every route registered with the name counts, so a
* route with optional placeholders matches with and without them. Not anchored, and without the trailing slash.
*
* @name The route name, `name@module` or `module:name` for module routes
*
* @return The path regex
*
* @throws InvalidArgumentException When the named route does not exist
*/
private string function routePathRegex( required string name ){
var router = getController().getWireBox().getInstance( "router@coldbox" )
var routes = router.getRoutes()
var routeName = arguments.name
if ( find( "@", arguments.name ) ) {
routes = router.getModuleRoutes( getToken( arguments.name, 2, "@" ) )
routeName = getToken( arguments.name, 1, "@" )
} else if ( find( ":", arguments.name ) ) {
routes = router.getModuleRoutes( getToken( arguments.name, 1, ":" ) )
routeName = getToken( arguments.name, 2, ":" )
}
// A route with optional placeholders, such as /posts/:id?, is registered as several routes with the same
// name (/posts/:id, then /posts): the page may be on any of them
var variants = []
var matched = false
for ( var route in routes ) {
if ( route.name == routeName ) {
matched = true
var variant = reReplace( route.regexPattern ?: "", "^/+|/+$", "", "all" )
if ( !variants.findNoCase( variant ) ) {
variants.append( variant )
}
}
}
if ( !matched ) {
throw(
type = "InvalidArgumentException",
message = "The named route '#arguments.name#' does not exist"
)
}
var regex = quoteRouteRegex(
reReplace(
routeLinkPath( getRequestContext().getSESBaseURL(), false ),
"/+$",
""
)
)
var entryPoint = reReplace(
routeModuleEntryPoint( arguments.name ),
"^/+|/+$",
"",
"all"
)
if ( len( entryPoint ) ) {
regex &= "/" & quoteRouteRegex( entryPoint )
}
var paths = variants.filter( function( variant ){
return len( variant )
} )
if ( paths.len() ) {
var alternatives = "/(?:" & paths.toList( "|" ) & ")"
// An empty variant (the route is the root of the app or module) matches without a path
regex &= paths.len() < variants.len() ? "(?:" & alternatives & ")?" : alternatives
}
return regex
}

/**
* The inherited entry point of the module of a route name (`name@module` or `module:name`).
*
* @name The route name
*
* @return The module entry point, or an empty string for application routes
*/
private string function routeModuleEntryPoint( required string name ){
var module = ""
if ( find( "@", arguments.name ) ) {
module = getToken( arguments.name, 2, "@" )
}
if ( find( ":", arguments.name ) ) {
module = getToken( arguments.name, 1, ":" )
}
if ( !len( module ) ) {
return ""
}
var modules = getController().getSetting( "modules" )
return modules.keyExists( module ) ? modules[ module ].inheritedEntryPoint : ""
}

/**
* Escape regex special characters. Playwright runs URL regexes in the browser, so Java's \Q...\E quoting cannot be used.
*
* @text The literal text
*
* @return The text with regex special characters escaped
*/
private string function quoteRouteRegex( required string text ){
return reReplace(
arguments.text,
"([.*+?^$\{\}()|\[\]\\/])",
"\\\1",
"all"
)
}

/**
* Separate a route into two parts: the base route, and a query string collection
*
Expand Down
12 changes: 9 additions & 3 deletions system/web/context/RequestContext.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,10 @@ component serializable="false" accessors="true" {
* Get a representation of this instance
*/
struct function getMemento(){
// Return only non-function elements
// Return only non-function elements, without the `this` reference that Adobe keeps in the variables scope:
// a decorator restoring it would run its own methods with `this` pointing to the original context
return variables.filter( function( key, value ){
return ( !isCustomFunction( value ) );
return ( !isCustomFunction( value ) && arguments.key != "this" );
} );
}

Expand Down Expand Up @@ -1371,8 +1372,13 @@ component serializable="false" accessors="true" {

// Did we find it?
if ( !foundRoute.isEmpty() ) {
// Join the module entry point and the route pattern with a single slash
var routePath = foundRoute.pattern
if ( len( entryPoint ) ) {
routePath = reReplace( entryPoint, "/+$", "" ) & "/" & reReplace( routePath, "^/+", "" )
}
var args = {
to : entryPoint & foundRoute.pattern,
to : routePath,
ssl : !isNull( arguments.ssl ) ? arguments.ssl : javacast( "null", "" )
};

Expand Down
6 changes: 6 additions & 0 deletions test-harness/config/Router.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ component {

route( "invalid-main-verbs" ).withVerbs( "post" ).to( "main.index" );

// Browser testing routes: tests/specs/browser
route( "/users/:id" ).as( "users.show" ).to( "browserTesting.user" )
route( "/browser-posts/:id?" ).as( "browserPosts" ).to( "browserTesting.user" )
route( "/browser-testing/login" ).as( "browserTesting.login" ).to( "browserTesting.login" )
route( "/browser-testing/whoami" ).as( "browserTesting.whoami" ).to( "browserTesting.whoami" )

// Default Application Routing
route( ":handler/:action?/:id-numeric?" ).end();

Expand Down
35 changes: 35 additions & 0 deletions test-harness/handlers/browserTesting.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Pages for the browser tests of tests/specs/browser
*/
component {

/**
* Show a user: the users.show named route
*/
function user( event, rc, prc ){
return "<h1>User #encodeForHTML( rc.id )#</h1>"
}

/**
* A login form, like an application login page: GET shows the form, POST logs the user in and shows who is
* logged in (no relocate(): the harness runs with the test controller, which intercepts relocations)
*/
function login( event, rc, prc ){
if ( event.getHTTPMethod() == "POST" ) {
session.browserTestingUser = rc.user ?: ""
return whoami( argumentCollection = arguments )
}
return "<form method=""post""><label for=""user"">User</label><input id=""user"" name=""user""><button type=""submit"">Sign in</button></form>"
}

/**
* Show who is logged in
*/
function whoami( event, rc, prc ){
if ( len( session.browserTestingUser ?: "" ) ) {
return "<h1>Logged in as #encodeForHTML( session.browserTestingUser )#</h1>"
}
return "<h1>Guest</h1>"
}

}
Loading
Loading