Skip to content
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.BrowserTestCase" {`. 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
9 changes: 9 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `coldbox.system.testing.BrowserTestCase` (BoxLang): browser tests for ColdBox applications built on TestBox browser support and bx-playwright. It loads your application like any integration test and adds `browse()`, `this.playwright()`, `browserAvailable()`, `browserUnavailableReason()`, the `browserProfile` and `baseURL` annotations, the TestBox browser matchers, and the ColdBox helpers `routeURL()`, `visitRoute()` and `assertRouteIs()`. Logged-in tests use bx-playwright saved sessions

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

## [8.2.0] - 2026-09-23

- <https://coldbox.ortusbooks.com/readme/release-history/whats-new-with-8.2.0>
Expand Down
353 changes: 353 additions & 0 deletions system/testing/BrowserTestCase.bx
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
/**
* Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp
* www.ortussolutions.com
* ---
* Base test case for ColdBox browser tests, built on TestBox browser support and the bx-playwright module.
* BoxLang only.
*
* It loads the ColdBox application like any ColdBox integration test, so the browser helpers know your
* routes, and drives a real browser against your running application:
*
* <pre>
* class extends="coldbox.system.testing.BrowserTestCase" {
* function run() {
* describe( "Users", () => {
* it( "shows a user", () => {
* browse( ( page ) => {
* visitRoute( page, "users.show", { id : 5 } )
* assertRouteIs( page, "users.show" )
* expect( page ).toSee( "User 5" )
* } )
* } )
* } )
* }
* }
* </pre>
*
* Class annotations, written as BoxLang annotations above `class`, besides the BaseTestCase ones
* (`@appMapping( "/root" )`, `@webMapping`, `@configMapping`, ...):
* - `@browserProfile( "ci" )`: bx-playwright profiles for the bundle browser, a list such as `ci,mobile`
* - `@baseURL( "http://127.0.0.1:8080" )`: the URL of your running application, relative visits resolve against it
*
* Everything browser related is delegated to testbox.system.browser.BrowserSupport, exactly like TestBox's
* BrowserSpec: the bundle shares one browser started on first use, every browse() call gets fresh, isolated
* pages, and the browser closes after the bundle through the `closeBrowser()` method, which carries the
* `afterAll` annotation. The browser matchers of testbox.system.browser.BrowserMatchers are registered for
* every spec of the bundle. `this.playwright()` returns the bundle manager; an unqualified `playwright()`
* still calls the bx-playwright BIF.
*
* For logged-in tests, use a bx-playwright saved session: log in through your login page once with
* `this.playwright().session( "admin", ( page ) => ... )`, then `browse( ( page ) => ..., { session : "admin" } )`.
*
* When bx-playwright is not installed, or the TestBox install has no browser support (testbox.system.browser),
* browse() and visitRoute() skip the running spec.
* Browser specs are not thread safe: do not use `asyncAll` in suites that browse.
*/
class extends="coldbox.system.testing.BaseTestCase" {

// Does the TestBox install have browser support (testbox.system.browser)? Older TestBox releases do not:
// the route helpers still work, and the browser helpers skip the running spec.
variables.$testBoxBrowserSupport = fileExists( expandPath( "/testbox/system/browser/BrowserSupport.bx" ) )

// Browser matchers for every spec of the bundle: expect( page ).toSee( "Welcome" )
if ( variables.$testBoxBrowserSupport ) {
addMatchers( new testbox.system.browser.BrowserMatchers() )
}

/**
* --------------------------------------------------------------------------
* Browser
* --------------------------------------------------------------------------
*/

/**
* Run a callback with fresh browser pages: one page per declared callback argument, each in its own
* browser context. When the callback throws, the kept screenshots, trace and videos are attached to the
* spec and the exception is rethrown. Skips the spec when browser testing is not available.
*
* @callback The function to run, receiving one page per declared argument
* @options Context options for bx-playwright newContext(), for example { viewport : { width : 390, height : 844 } }
*
* @return The callback result, or null when it returns nothing
*/
function browse( required function callback, struct options = {} ) {
ensureTestBoxBrowserSupport()
return getBrowserSupport().browse( argumentCollection = arguments )
}

/**
* The bx-playwright manager of this bundle, created on first use with the `browserProfile` and `baseURL`
* annotations. Skips the spec when browser testing is not available.
*
* Call it as `this.playwright()`: BoxLang resolves an unqualified `playwright()` call to the bx-playwright
* BIF, even inside this class, which returns a new manager that the bundle does not close.
*
* @return The bx-playwright manager (models.Playwright@playwright)
*/
function playwright() {
ensureTestBoxBrowserSupport()
return getBrowserSupport().getManager()
}

/**
* Can browser specs run here? True on BoxLang with the bx-playwright module installed and a TestBox
* release with browser support.
* Handy for skip constraints: it( title = "...", body = () => {}, skip = !browserAvailable() )
*
* @return True when browser testing is available
*/
boolean function browserAvailable() {
return variables.$testBoxBrowserSupport && getBrowserSupport().isAvailable()
}

/**
* Why browser specs cannot run here, or an empty string when they can.
*
* @return The reason browser testing is not available
*/
string function browserUnavailableReason() {
if ( !variables.$testBoxBrowserSupport ) {
return "Browser specs need TestBox browser support (testbox.system.browser.BrowserSupport), which this TestBox install does not have: update TestBox"
}
return getBrowserSupport().isAvailable() ? "" : getBrowserSupport().getUnavailableReason()
}

/**
* The browser support of this bundle, which owns the bundle manager.
*
* @return The testbox.system.browser.BrowserSupport of this bundle
*/
function getBrowserSupport() {
if ( !variables.$testBoxBrowserSupport ) {
throw(
type = "BrowserTestCase.BrowserSupportUnavailable",
message = browserUnavailableReason()
)
}
if ( isNull( variables.$browserSupport ) ) {
variables.$browserSupport = new testbox.system.browser.BrowserSupport( this )
}
return variables.$browserSupport
}

/**
* Close the bundle browser after all the specs ran. It carries the `afterAll` annotation, so TestBox runs it
* after your own afterAll() without a super call.
*
* @return This test case
*/
@afterAll
function closeBrowser() {
if ( !isNull( variables.$browserSupport ) ) {
variables.$browserSupport.close()
}
return this
}

/**
* --------------------------------------------------------------------------
* Routes
* --------------------------------------------------------------------------
*/

/**
* 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.
*
* <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 toPath( getRequestContext().route( arguments.name, arguments.params ) )
}

/**
* Visit a named route: page.visit( routeURL( name, params ) ). Relative routes resolve against the
* `baseURL` annotation. Pages come from browse(), which skips the spec when browser testing is not available.
*
* @page The bx-playwright page
* @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 the 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
* @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 #jsonSerialize( arguments.params )#"
pathRegex = quoteRegex( toPath( routeURL( arguments.name, arguments.params ), false ).reReplace( "/+$", "" ) )
}
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 ) ) {
rethrow
}
throw(
type = "TestBox.AssertionFailed",
message = "Expected the page to be on #expected#, but the path is [#toPath( arguments.page.url() )#]",
detail = "The page URL must match the regex [#urlRegex#]. #e.message#"
)
}
return arguments.page
}

/**
* --------------------------------------------------------------------------
* Private helpers
* --------------------------------------------------------------------------
*/

/**
* Skip the running spec when the TestBox install has no browser support.
*/
private void function ensureTestBoxBrowserSupport() {
if ( !variables.$testBoxBrowserSupport ) {
skip( browserUnavailableReason() )
}
}

/**
* 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 toPath( 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 = ( route.regexPattern ?: "" ).reReplace( "^/+|/+$", "", "all" )
if ( !variants.findNoCase( variant ) ) {
variants.append( variant )
}
}
}
if ( !matched ) {
throw( type = "InvalidArgumentException", message = "The named route '#arguments.name#' does not exist" )
}
var regex = quoteRegex( toPath( getRequestContext().getSESBaseURL(), false ).reReplace( "/+$", "" ) )
var entryPoint = moduleEntryPoint( arguments.name ).reReplace( "^/+|/+$", "", "all" )
if ( len( entryPoint ) ) {
regex &= "/" & quoteRegex( entryPoint )
}
var paths = variants.filter( ( variant ) => 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 moduleEntryPoint( 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 quoteRegex( required string text ) {
return arguments.text.reReplace( "([.*+?^$\{\}()|\[\]\\/])", "\\\1", "all" )
}

}
Loading
Loading