From 9b80c26007627376c19cc1a659004ecfcf8da28b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:43:55 +0000 Subject: [PATCH 1/2] BaseTestCase route helpers: web root paths, optional placeholders, no-app error - routeURL(), visitRoute() and assertRouteIs() build paths from the web root: the appMapping is a mapping, not a web path, so it is no longer part of them. A webMapping keeps the base path for apps served from a sub folder. - With params, a route with optional placeholders resolves to the longest variant the params fill, instead of the base route. A root route gives "/", not "//". - The helpers throw BaseTestCase.NoColdBoxApp when no ColdBox app is loaded, and InvalidArgumentException for a module that is not loaded. - assertRouteIs() rethrows unexpected errors as they are and reports the page URL as is when it is not a valid URI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UU9aLossoYUkQNwubnitBi --- changelog.md | 2 +- system/testing/BaseTestCase.cfc | 242 ++++++++++++++++------ test-harness/config/Router.cfc | 1 + tests/specs/browser/BrowserTestingSpec.bx | 106 ++++++++-- 4 files changed, 258 insertions(+), 93 deletions(-) diff --git a/changelog.md b/changelog.md index 59b71a339..7cd822cde 100644 --- a/changelog.md +++ b/changelog.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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). +- 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). The route helpers build paths from the web root: the `appMapping` is not part of them, set `webMapping` when the app is served from a sub folder. With params, a route with optional placeholders resolves to the longest variant the params fill. They throw `BaseTestCase.NoColdBoxApp` when no ColdBox application is loaded. - `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) diff --git a/system/testing/BaseTestCase.cfc b/system/testing/BaseTestCase.cfc index 8f0f98480..0a0a5f82e 100755 --- a/system/testing/BaseTestCase.cfc +++ b/system/testing/BaseTestCase.cfc @@ -898,8 +898,12 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" { /********************************************* 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. + * The path of a named route, without scheme and host, from the web root of the application: the base path of + * the request (the front controller, like /index.cfm, when the app does not use full rewrites), the module entry + * point for module routes (`name@module` or `module:name`) and the route pattern with its placeholders filled. + * The `appMapping` is not part of it, as it is a mapping, not a web path. When the app is served from a sub + * folder, set it with the `webMapping` annotation. For a route with optional placeholders, such as /posts/:id?, + * the params pick the longest variant they fill. * Use it with TestBox browser specs: annotate the test with `@browser`, `@browserProfile` or `@baseURL`. * *
@@ -910,12 +914,23 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	 * @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
+	 * @return The route path
 	 *
-	 * @throws InvalidArgumentException When the named route does not exist
+	 * @throws InvalidArgumentException When the named route or its module does not exist
+	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
 	 */
 	string function routeURL( required string name, struct params = {} ){
-		return routeLinkPath( getRequestContext().route( arguments.name, arguments.params ) )
+		var named = namedRoutes( arguments.name )
+		var path  = pickRoute( named.routes, arguments.params ).pattern
+		for ( var key in arguments.params ) {
+			path = reReplaceNoCase(
+				path,
+				":#key#-?[^/]*",
+				encodeForURL( arguments.params[ key ] ),
+				"all"
+			)
+		}
+		return joinRoutePath( [ routeBasePath(), named.entryPoint, path ] )
 	}
 
 	/**
@@ -927,6 +942,9 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	 * @params The route placeholder values, for example { id : 5 }
 	 *
 	 * @return The page
+	 *
+	 * @throws InvalidArgumentException When the named route or its module does not exist
+	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
 	 */
 	function visitRoute(
 		required page,
@@ -956,6 +974,8 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	 * @return The page
 	 *
 	 * @throws TestBox.AssertionFailed When the page path does not match the route before the assertion timeout
+	 * @throws InvalidArgumentException When the named route or its module does not exist
+	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
 	 */
 	function assertRouteIs(
 		required page,
@@ -968,13 +988,7 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 			pathRegex = routePathRegex( arguments.name )
 		} else {
 			expected  = "route [#arguments.name#] with params #serializeJSON( arguments.params )#"
-			pathRegex = quoteRouteRegex(
-				reReplace(
-					routeLinkPath( routeURL( arguments.name, arguments.params ), false ),
-					"/+$",
-					""
-				)
-			)
+			pathRegex = quoteRouteRegex( reReplace( routeURL( arguments.name, arguments.params ), "/+$", "" ) )
 		}
 		var urlRegex = "^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]*" & pathRegex & "/?(\?.*)?(##.*)?$"
 		var timeout  = arguments.page.getConfig().timeouts.assertion ?: 5000
@@ -982,17 +996,40 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 			arguments.page.waitForUrl( arguments.page.regex( urlRegex, "i" ), timeout )
 		} catch ( any e ) {
 			if ( !listFindNoCase( "Playwright.Timeout,Playwright.AssertionFailed", e.type ) ) {
-				throw( object = e )
+				rethrow;
+			}
+			var actual = arguments.page.url()
+			try {
+				actual = routeLinkPath( actual )
+			} catch ( any pathError ) {
+				// Not a valid URI: report it as it is
 			}
 			throw(
 				type    = "TestBox.AssertionFailed",
-				message = "Expected the page to be on #expected#, but the path is [#routeLinkPath( arguments.page.url() )#]",
+				message = "Expected the page to be on #expected#, but the path is [#actual#]",
 				detail  = "The page URL must match the regex [#urlRegex#]. #e.message#"
 			)
 		}
 		return arguments.page
 	}
 
+	/**
+	 * The ColdBox controller the route helpers read the routes from.
+	 *
+	 * @return The controller
+	 *
+	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
+	 */
+	private function routeController(){
+		if ( !isObject( variables.controller ) ) {
+			throw(
+				type    = "BaseTestCase.NoColdBoxApp",
+				message = "The route helpers need a loaded ColdBox application: extend coldbox.system.testing.BaseTestCase with loadColdbox=true"
+			)
+		}
+		return variables.controller
+	}
+
 	/**
 	 * The path of a URL, without scheme and host.
 	 *
@@ -1012,56 +1049,144 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	}
 
 	/**
-	 * 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.
+	 * The base path of the routes from the web root, without the trailing slash: the path of the request base URL
+	 * without the app mapping, unless the app sets a web mapping. Empty, or the front controller like /index.cfm.
+	 *
+	 * @return The base path
+	 *
+	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
+	 */
+	private string function routeBasePath(){
+		var controller = routeController()
+		var path       = reReplace(
+			routeLinkPath( getRequestContext().getSESBaseURL(), false ),
+			"/+$",
+			""
+		)
+		if ( !len( controller.getSetting( "WebMapping", "" ) ) ) {
+			var appPrefix = reReplace(
+				controller.getSetting( "RoutingAppMapping", "" ),
+				"/+$",
+				""
+			)
+			if ( len( appPrefix ) && ( path == appPrefix || left( path, len( appPrefix ) + 1 ) == appPrefix & "/" ) ) {
+				path = mid( path, len( appPrefix ) + 1, len( path ) )
+			}
+		}
+		return path
+	}
+
+	/**
+	 * Join route path parts with single slashes, with a leading slash.
+	 *
+	 * @parts The path parts
+	 *
+	 * @return The path
+	 */
+	private string function joinRoutePath( required array parts ){
+		var path = reReplace( arguments.parts.toList( "/" ), "/{2,}", "/", "all" )
+		return left( path, 1 ) == "/" ? path : "/" & path
+	}
+
+	/**
+	 * The routes registered with a route name, and the module entry point. A route with optional placeholders,
+	 * such as /posts/:id?, is registered as several routes with the same name (/posts/:id, then /posts).
 	 *
 	 * @name The route name, `name@module` or `module:name` for module routes
 	 *
-	 * @return The path regex
+	 * @return { routes : the routes, longest first, entryPoint : the module entry point or an empty string }
 	 *
-	 * @throws InvalidArgumentException When the named route does not exist
+	 * @throws InvalidArgumentException When the named route or its module does not exist
+	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
 	 */
-	private string function routePathRegex( required string name ){
-		var router    = getController().getWireBox().getInstance( "router@coldbox" )
-		var routes    = router.getRoutes()
-		var routeName = arguments.name
+	private struct function namedRoutes( required string name ){
+		var controller = routeController()
+		var router     = controller.getWireBox().getInstance( "router@coldbox" )
+		var module     = ""
+		var routeName  = arguments.name
 		if ( find( "@", arguments.name ) ) {
-			routes    = router.getModuleRoutes( getToken( arguments.name, 2, "@" ) )
+			module    = getToken( arguments.name, 2, "@" )
 			routeName = getToken( arguments.name, 1, "@" )
 		} else if ( find( ":", arguments.name ) ) {
-			routes    = router.getModuleRoutes( getToken( arguments.name, 1, ":" ) )
+			module    = 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 )
-				}
+		var entryPoint = ""
+		var routes     = router.getRoutes()
+		if ( len( module ) ) {
+			var modules = controller.getSetting( "modules" )
+			if ( !modules.keyExists( module ) ) {
+				throw(
+					type    = "InvalidArgumentException",
+					message = "The named route '#arguments.name#' does not exist: the module '#module#' is not loaded"
+				)
 			}
+			entryPoint = modules[ module ].inheritedEntryPoint
+			routes     = router.getModuleRoutes( module )
 		}
-		if ( !matched ) {
+		var found = routes.filter( function( route ){
+			return arguments.route.name == routeName
+		} )
+		if ( found.isEmpty() ) {
 			throw(
 				type    = "InvalidArgumentException",
 				message = "The named route '#arguments.name#' does not exist"
 			)
 		}
-		var regex = quoteRouteRegex(
-			reReplace(
-				routeLinkPath( getRequestContext().getSESBaseURL(), false ),
-				"/+$",
-				""
-			)
-		)
+		return { routes : found, entryPoint : entryPoint }
+	}
+
+	/**
+	 * Pick the route of a name that the params fill best: the one with the most placeholders, all of them in the
+	 * params. When none is filled, the last one registered, like event.route().
+	 *
+	 * @routes The routes registered with the name
+	 * @params The route placeholder values
+	 *
+	 * @return The route
+	 */
+	private struct function pickRoute( required array routes, required struct params ){
+		var routeParams = arguments.params
+		var picked      = arguments.routes[ arguments.routes.len() ]
+		var most        = -1
+		for ( var route in arguments.routes ) {
+			var placeholders = reMatch( ":[a-zA-Z0-9_]+", route.pattern )
+			var filled       = placeholders.every( function( placeholder ){
+				return routeParams.keyExists( mid( arguments.placeholder, 2, len( arguments.placeholder ) ) )
+			} )
+			if ( filled && placeholders.len() > most ) {
+				picked = route
+				most   = placeholders.len()
+			}
+		}
+		return picked
+	}
+
+	/**
+	 * The regex a page path must match to be on a named route, any value of its placeholders included: the base
+	 * path of the routes, 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 or its module does not exist
+	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
+	 */
+	private string function routePathRegex( required string name ){
+		var named    = namedRoutes( arguments.name )
+		var variants = []
+		for ( var route in named.routes ) {
+			var variant = reReplace( route.regexPattern ?: "", "^/+|/+$", "", "all" )
+			if ( !variants.findNoCase( variant ) ) {
+				variants.append( variant )
+			}
+		}
+		var regex      = quoteRouteRegex( routeBasePath() )
 		var entryPoint = reReplace(
-			routeModuleEntryPoint( arguments.name ),
+			named.entryPoint,
 			"^/+|/+$",
 			"",
 			"all"
@@ -1081,29 +1206,8 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	}
 
 	/**
-	 * 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.
+	 * Escape regex special characters one by one, without Java's \Q...\E quoting, so the regex reads the same in
+	 * any regex engine.
 	 *
 	 * @text The literal text
 	 *
diff --git a/test-harness/config/Router.cfc b/test-harness/config/Router.cfc
index 4fef49f1a..7b7303f74 100644
--- a/test-harness/config/Router.cfc
+++ b/test-harness/config/Router.cfc
@@ -173,6 +173,7 @@ component {
 		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" )
+		route( "/" ).withDomain( "browser-root.dev" ).as( "browserRoot" ).to( "main.index" )
 
 		// Default Application Routing
 		route( ":handler/:action?/:id-numeric?" ).end();
diff --git a/tests/specs/browser/BrowserTestingSpec.bx b/tests/specs/browser/BrowserTestingSpec.bx
index e985079f6..56c07e37e 100644
--- a/tests/specs/browser/BrowserTestingSpec.bx
+++ b/tests/specs/browser/BrowserTestingSpec.bx
@@ -16,49 +16,92 @@ class extends="coldbox.system.testing.BaseTestCase" {
 	function run() {
 		describe( "Browser testing routes", () => {
 			beforeEach( ( currentSpec ) => {
-				variables.basePath = createObject( "java", "java.net.URI" )
-					.create( getRequestContext().getSESBaseURL() )
-					.getRawPath()
-					.reReplace( "/+$", "" )
 				variables.host = "http://127.0.0.1:8599"
 			} )
 
 			it( "builds route paths without scheme and host", () => {
-				expect( routeURL( "users.show", { id : 5 } ) ).toBe( basePath & "/users/5/" )
-				expect( routeURL( "testRoute" ) ).toBe( basePath & "/testroute/" )
+				expect( routeURL( "users.show", { id : 5 } ) ).toBe( "/users/5/" )
+				expect( routeURL( "testRoute" ) ).toBe( "/testroute/" )
 				expect( routeURL( "users.show", { id : 5 } ) ).notToInclude( "://" )
 			} )
 
 			it( "builds module route paths with the module entry point", () => {
-				expect( routeURL( "photos.edit@resourcesTest", { id : 3 } ) ).toBe( basePath & "/resourcesTest/photos/3/edit/" )
-				expect( routeURL( "resourcesTest:photos.new" ) ).toBe( basePath & "/resourcesTest/photos/new/" )
+				expect( routeURL( "photos.edit@resourcesTest", { id : 3 } ) ).toBe( "/resourcesTest/photos/3/edit/" )
+				expect( routeURL( "resourcesTest:photos.new" ) ).toBe( "/resourcesTest/photos/new/" )
 			} )
 
-			it( "throws for unknown routes", () => {
+			it( "throws for unknown routes and modules", () => {
 				expect( () => routeURL( "does.not.exist" ) ).toThrow( "InvalidArgumentException" )
+				expect( () => routeURL( "home@notAModule" ) ).toThrow( "InvalidArgumentException" )
+				expect( () => routeURL( "notAModule:home" ) ).toThrow( "InvalidArgumentException" )
+			} )
+
+			it( "builds route paths from the web root, without the app mapping", () => {
+				withSESBaseURL( "http://127.0.0.1:8599/cbTestHarness/", () => {
+					expect( routeURL( "users.show", { id : 5 } ) ).toBe( "/users/5/" )
+					assertRouteIs( new tests.resources.browser.FakePage( host & "/users/5" ), "users.show" )
+					assertRouteIs( new tests.resources.browser.FakePage( host & "/users/5" ), "users.show", { id : 5 } )
+				} )
+				withSESBaseURL( "http://127.0.0.1:8599/cbTestHarness/index.cfm", () => {
+					expect( routeURL( "users.show", { id : 5 } ) ).toBe( "/index.cfm/users/5/" )
+				} )
+			} )
+
+			it( "keeps the base path when the app sets a web mapping", () => {
+				var controller = getController()
+				var original   = controller.getSetting( "WebMapping", "" )
+				controller.setSetting( "WebMapping", "/test-harness" )
+				try {
+					withSESBaseURL( "http://127.0.0.1:8599/test-harness/", () => {
+						expect( routeURL( "users.show", { id : 5 } ) ).toBe( "/test-harness/users/5/" )
+					} )
+				} finally {
+					controller.setSetting( "WebMapping", original )
+				}
+			} )
+
+			it( "fills the optional placeholders the params give", () => {
+				expect( routeURL( "browserPosts", { id : 12 } ) ).toBe( "/browser-posts/12/" )
+				expect( routeURL( "browserPosts" ) ).toBe( "/browser-posts/" )
+				var page = new tests.resources.browser.FakePage( host & "/browser-posts/12" )
+				assertRouteIs( page, "browserPosts", { id : 12 } )
+				expect( () => assertRouteIs( page, "browserPosts", { id : 13 } ) ).toThrow( "TestBox.AssertionFailed" )
+			} )
+
+			it( "builds the path of a root route with a single slash", () => {
+				expect( routeURL( "browserRoot" ) ).toBe( "/" )
+				assertRouteIs( new tests.resources.browser.FakePage( host & "/" ), "browserRoot" )
+			} )
+
+			it( "needs a loaded ColdBox application", () => {
+				var test = prepareMock( new coldbox.system.testing.BaseTestCase() ).$property(
+					propertyName = "controller",
+					mock         = ""
+				)
+				expect( () => test.routeURL( "users.show" ) ).toThrow( "BaseTestCase.NoColdBoxApp" )
 			} )
 
 			it( "asserts the page is on a route, whatever its placeholders", () => {
-				var page = new tests.resources.browser.FakePage( host & basePath & "/users/5/" )
+				var page = new tests.resources.browser.FakePage( host & "/users/5/" )
 				expect( assertRouteIs( page, "users.show" ) ).toBe( page )
 				expect( page.getLastTimeout() ).toBe( 5000 )
-				assertRouteIs( new tests.resources.browser.FakePage( host & basePath & "/users/abc" ), "users.show" )
+				assertRouteIs( new tests.resources.browser.FakePage( host & "/users/abc" ), "users.show" )
 			} )
 
 			it( "ignores the trailing slash, case, query string and hash like ColdBox routing", () => {
 				for ( var path in [ "/users/5", "/users/5/", "/USERS/5", "/users/5?tab=posts", "/users/5/##bio" ] ) {
-					assertRouteIs( new tests.resources.browser.FakePage( host & basePath & path ), "users.show" )
+					assertRouteIs( new tests.resources.browser.FakePage( host & path ), "users.show" )
 				}
 			} )
 
 			it( "asserts the page is on a route with given params", () => {
-				var page = new tests.resources.browser.FakePage( host & basePath & "/users/5/" )
+				var page = new tests.resources.browser.FakePage( host & "/users/5/" )
 				assertRouteIs( page, "users.show", { id : 5 } )
 				expect( () => assertRouteIs( page, "users.show", { id : 6 } ) ).toThrow( "TestBox.AssertionFailed" )
 			} )
 
 			it( "fails with the expected route and the actual path", () => {
-				var page    = new tests.resources.browser.FakePage( host & basePath & "/contactus/" )
+				var page    = new tests.resources.browser.FakePage( host & "/contactus/" )
 				var failure = ""
 				try {
 					assertRouteIs( page, "users.show" )
@@ -68,7 +111,7 @@ class extends="coldbox.system.testing.BaseTestCase" {
 				expect( failure )
 					.toInclude( "TestBox.AssertionFailed: " )
 					.toInclude( "route [users.show]" )
-					.toInclude( "the path is [#basePath#/contactus/]" )
+					.toInclude( "the path is [/contactus/]" )
 
 				failure = ""
 				try {
@@ -80,14 +123,14 @@ class extends="coldbox.system.testing.BaseTestCase" {
 			} )
 
 			it( "does not match a longer path or another route", () => {
-				var longer = new tests.resources.browser.FakePage( host & basePath & "/users/5/posts" )
-				var user   = new tests.resources.browser.FakePage( host & basePath & "/users/5/" )
+				var longer = new tests.resources.browser.FakePage( host & "/users/5/posts" )
+				var user   = new tests.resources.browser.FakePage( host & "/users/5/" )
 				expect( () => assertRouteIs( longer, "users.show" ) ).toThrow( "TestBox.AssertionFailed" )
 				expect( () => assertRouteIs( user, "testRoute" ) ).toThrow( "TestBox.AssertionFailed" )
 			} )
 
 			it( "asserts module routes", () => {
-				var page = new tests.resources.browser.FakePage( host & basePath & "/resourcesTest/photos/9/edit" )
+				var page = new tests.resources.browser.FakePage( host & "/resourcesTest/photos/9/edit" )
 				assertRouteIs( page, "photos.edit@resourcesTest" )
 				assertRouteIs( page, "photos.edit@resourcesTest", { id : 9 } )
 			} )
@@ -105,16 +148,16 @@ class extends="coldbox.system.testing.BaseTestCase" {
 			)
 
 			it( "asserts routes with optional placeholders, with and without them", () => {
-				assertRouteIs( new tests.resources.browser.FakePage( host & basePath & "/browser-posts/12" ), "browserPosts" )
-				assertRouteIs( new tests.resources.browser.FakePage( host & basePath & "/browser-posts/" ), "browserPosts" )
-				var other = new tests.resources.browser.FakePage( host & basePath & "/users/5/" )
+				assertRouteIs( new tests.resources.browser.FakePage( host & "/browser-posts/12" ), "browserPosts" )
+				assertRouteIs( new tests.resources.browser.FakePage( host & "/browser-posts/" ), "browserPosts" )
+				var other = new tests.resources.browser.FakePage( host & "/users/5/" )
 				expect( () => assertRouteIs( other, "browserPosts" ) ).toThrow( "TestBox.AssertionFailed" )
 			} )
 
 			it( "visits named routes without a browser", () => {
 				var page = new tests.resources.browser.FakePage()
 				expect( visitRoute( page, "users.show", { id : 7 } ) ).toBe( page )
-				expect( page.getVisits() ).toBe( [ basePath & "/users/7/" ] )
+				expect( page.getVisits() ).toBe( [ "/users/7/" ] )
 			} )
 		} )
 
@@ -122,7 +165,7 @@ class extends="coldbox.system.testing.BaseTestCase" {
 			title = "Browser testing in a browser",
 			body  = () => {
 				beforeEach( ( currentSpec ) => {
-					// The test server serves the harness through its front controller
+					// The test server serves the harness from /test-harness through its front controller, as an app sets its base URL
 					variables.originalSESBaseURL = getRequestContext().getSESBaseURL()
 					getRequestContext().setSESBaseURL( "http://127.0.0.1:8599/test-harness/index.cfm" )
 				} )
@@ -194,4 +237,21 @@ class extends="coldbox.system.testing.BaseTestCase" {
 		}
 	}
 
+	/**
+	 * Run a closure with another request base URL, as the app would set it.
+	 *
+	 * @sesBaseURL The base URL
+	 * @closure    The closure to run
+	 */
+	private function withSESBaseURL( required string sesBaseURL, required function closure ) {
+		var event    = getRequestContext()
+		var original = event.getSESBaseURL()
+		event.setSESBaseURL( arguments.sesBaseURL )
+		try {
+			arguments.closure()
+		} finally {
+			event.setSESBaseURL( original )
+		}
+	}
+
 }

From 14b35b0ddfe34d94a37914f4376377ac9fbac4b5 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sat, 10 Oct 2026 13:53:06 +0000
Subject: [PATCH 2/2] cfformat: route helper call wrapping and @throws
 alignment

Co-Authored-By: Claude Opus 5.5 
Claude-Session: https://claude.ai/code/session_01UU9aLossoYUkQNwubnitBi
---
 system/testing/BaseTestCase.cfc | 42 +++++++++++++++++++++------------
 1 file changed, 27 insertions(+), 15 deletions(-)

diff --git a/system/testing/BaseTestCase.cfc b/system/testing/BaseTestCase.cfc
index 0a0a5f82e..d5168eb13 100755
--- a/system/testing/BaseTestCase.cfc
+++ b/system/testing/BaseTestCase.cfc
@@ -916,7 +916,7 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	 *
 	 * @return The route path
 	 *
-	 * @throws InvalidArgumentException When the named route or its module does not exist
+	 * @throws InvalidArgumentException  When the named route or its module does not exist
 	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
 	 */
 	string function routeURL( required string name, struct params = {} ){
@@ -943,7 +943,7 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	 *
 	 * @return The page
 	 *
-	 * @throws InvalidArgumentException When the named route or its module does not exist
+	 * @throws InvalidArgumentException  When the named route or its module does not exist
 	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
 	 */
 	function visitRoute(
@@ -973,8 +973,8 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	 *
 	 * @return The page
 	 *
-	 * @throws TestBox.AssertionFailed When the page path does not match the route before the assertion timeout
-	 * @throws InvalidArgumentException When the named route or its module does not exist
+	 * @throws TestBox.AssertionFailed   When the page path does not match the route before the assertion timeout
+	 * @throws InvalidArgumentException  When the named route or its module does not exist
 	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
 	 */
 	function assertRouteIs(
@@ -988,7 +988,13 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 			pathRegex = routePathRegex( arguments.name )
 		} else {
 			expected  = "route [#arguments.name#] with params #serializeJSON( arguments.params )#"
-			pathRegex = quoteRouteRegex( reReplace( routeURL( arguments.name, arguments.params ), "/+$", "" ) )
+			pathRegex = quoteRouteRegex(
+				reReplace(
+					routeURL( arguments.name, arguments.params ),
+					"/+$",
+					""
+				)
+			)
 		}
 		var urlRegex = "^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]*" & pathRegex & "/?(\?.*)?(##.*)?$"
 		var timeout  = arguments.page.getConfig().timeouts.assertion ?: 5000
@@ -1084,7 +1090,12 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	 * @return The path
 	 */
 	private string function joinRoutePath( required array parts ){
-		var path = reReplace( arguments.parts.toList( "/" ), "/{2,}", "/", "all" )
+		var path = reReplace(
+			arguments.parts.toList( "/" ),
+			"/{2,}",
+			"/",
+			"all"
+		)
 		return left( path, 1 ) == "/" ? path : "/" & path
 	}
 
@@ -1096,7 +1107,7 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	 *
 	 * @return { routes : the routes, longest first, entryPoint : the module entry point or an empty string }
 	 *
-	 * @throws InvalidArgumentException When the named route or its module does not exist
+	 * @throws InvalidArgumentException  When the named route or its module does not exist
 	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
 	 */
 	private struct function namedRoutes( required string name ){
@@ -1152,7 +1163,13 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 		for ( var route in arguments.routes ) {
 			var placeholders = reMatch( ":[a-zA-Z0-9_]+", route.pattern )
 			var filled       = placeholders.every( function( placeholder ){
-				return routeParams.keyExists( mid( arguments.placeholder, 2, len( arguments.placeholder ) ) )
+				return routeParams.keyExists(
+					mid(
+						arguments.placeholder,
+						2,
+						len( arguments.placeholder )
+					)
+				)
 			} )
 			if ( filled && placeholders.len() > most ) {
 				picked = route
@@ -1172,7 +1189,7 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 	 *
 	 * @return The path regex
 	 *
-	 * @throws InvalidArgumentException When the named route or its module does not exist
+	 * @throws InvalidArgumentException  When the named route or its module does not exist
 	 * @throws BaseTestCase.NoColdBoxApp When no ColdBox application is loaded
 	 */
 	private string function routePathRegex( required string name ){
@@ -1185,12 +1202,7 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" {
 			}
 		}
 		var regex      = quoteRouteRegex( routeBasePath() )
-		var entryPoint = reReplace(
-			named.entryPoint,
-			"^/+|/+$",
-			"",
-			"all"
-		)
+		var entryPoint = reReplace( named.entryPoint, "^/+|/+$", "", "all" )
 		if ( len( entryPoint ) ) {
 			regex &= "/" & quoteRouteRegex( entryPoint )
 		}